| name | wsh-crok-render-optimization |
| description | Crok AI chat rendering optimization — SSE debouncing, ChatMessage memoization, Markdown re-render prevention |
WSH: Crok AI Rendering Optimization
Crok AI chat user flow scores TBT=0.00/25. The server sends ALL SSE chunks in a synchronous loop (no delays), causing the client to receive and process all events nearly simultaneously. Each event triggers React state updates and Markdown re-rendering.
Use this skill when optimizing the Crok AI chat performance, SSE handling, or Markdown rendering.
Techniques
1. Debounce SSE State Updates with requestAnimationFrame
The current useSSE hook calls setContent() on every SSE message event. When the server sends all chunks synchronously, the client receives a burst of events that each trigger a React render.
eventSource.onmessage = (event) => {
contentRef.current = newContent;
setContent(newContent);
};
const rafRef = useRef<number | null>(null);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data) as T;
const isDone = options.onDone?.(data) ?? false;
if (isDone) {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
setContent(contentRef.current);
options.onComplete?.(contentRef.current);
stop();
return;
}
contentRef.current = options.onMessage(data, contentRef.current);
if (rafRef.current === null) {
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
setContent(contentRef.current);
});
}
};
Impact: Reduces React renders from N (one per SSE event) to ~1-2 per frame. Major TBT reduction.
2. Add Server-Side Delays Between Chunks
The server at server/src/routes/api/crok.ts sends all chunks in a synchronous for loop:
for (let i = 0; i < lines.length; i += CHUNK_SIZE) {
res.write(...);
}
for (let i = 0; i < lines.length; i += CHUNK_SIZE) {
if (res.closed) break;
res.write(...);
if (i + CHUNK_SIZE < lines.length) {
await new Promise(resolve => setImmediate(resolve));
}
}
This lets the event loop breathe between writes, preventing one massive data burst.
Impact: Spreads SSE events over time, reducing client-side event processing burst.
3. Memoize ChatMessage Component
ChatMessage is not wrapped in React.memo, so parent re-renders cause unnecessary recalculation:
export const ChatMessage = ({ message }: Props) => { ... };
export const ChatMessage = React.memo(({ message }: Props) => { ... });
The Markdown component inside AssistantMessage is the heaviest part — it parses and renders markdown with rehypeKatex and remarkGfm plugins on every render.
Impact: Prevents completed messages from re-rendering when new content streams in.
Pitfalls
| Pitfall | Symptom | Fix |
|---|
| rAF debounce loses final content | Last chunk not displayed | Cancel rAF and do final setContent in the isDone handler |
| Server delay too long | Chat feels sluggish | Use setImmediate (not setTimeout) — yields but doesn't delay |
| memo on ChatMessage with object props | memo never skips (new object each render) | Ensure message objects have stable references (use index-based comparison) |
VRT Risks
| Change | Visual Impact | Mitigation |
|---|
| Debouncing SSE | None — final content is same | Ensure final content renders completely |
| Server delays | None — same content, slightly different timing | N/A |
| Memoizing ChatMessage | None — same output | Run crok-chat VRT test |
Project-Specific Notes
- SSE hook:
client/src/hooks/use_sse.ts
- Crok container:
client/src/containers/CrokContainer.tsx
- ChatMessage:
client/src/components/crok/ChatMessage.tsx
- Crok API:
server/src/routes/api/crok.ts
- Server reads response from
crok-response.md file
- CrokContainer already uses
useDeferredValue but it's not sufficient when all events arrive at once
- CHUNK_SIZE is currently 5 lines