Streaming LLM responses into a chat interface pushes React’s default rendering model to its limit. Every token that arrives over the wire tempts you to call setState, and doing that per token wrecks react token streaming re-render performance on anything longer than a sentence. This guide gives an ordered, practical path to decouple network cadence from paint cadence so the UI stays at 60fps while text fills the screen.
The reconciliation bottleneck
Before writing code, understand where the time goes. React’s renderer walks the fiber tree from the changed state owner downward. If your chat stores the entire conversation as a single messages array and you mutate the last message’s content on every token, React re-creates that array, re-runs the parent reducer, and re-renders every Message unless each is memoized with a stable prop. Even with React.memo, a new array reference invalidates the list’s children. The root cause of poor react token streaming re-render performance is uncontrolled state granularity, not React itself.
1. Treat the stream as a side effect, not a state update
Write a thin transport that opens the stream and parses chunks without touching React state. Use the browser’s fetch and ReadableStream directly; don’t wrap it in a data-fetching library that buffers the body.
async function streamCompletion(
body: string,
onToken: (t: string) => void,
onMeta: (m: unknown) => void,
signal: AbortSignal
) {
const res = await fetch("https://api.example.com/v1/chat/completions", {
method: "POST",
headers: { "content-type": "application/json" },
body,
signal,
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") return;
const json = JSON.parse(payload);
if (json.usage) onMeta(json.usage);
else onToken(json.choices[0].delta.content ?? "");
}
}
}
The onToken callback must not call setState. Push into a mutable buffer owned by a ref.
2. Buffer tokens in a ref, flush on a frame
React re-renders are cheap only when batched. Create a hook that accumulates tokens in a useRef string and commits to state on requestAnimationFrame. This caps updates to display refresh rate and coalesces bursts from the network.
function useTokenStream() {
const [text, setText] = useState("");
const buffer = useRef("");
const rafId = useRef<number>();
const flush = () => {
setText(buffer.current);
rafId.current = undefined;
};
const onToken = (t: string) => {
buffer.current += t;
if (rafId.current === undefined) {
rafId.current = requestAnimationFrame(flush);
}
};
useEffect(() => () => {
if (rafId.current) cancelAnimationFrame(rafId.current);
}, []);
return { text, onToken };
}
This single change removes most jank. You trade at most one frame (≈16ms) of latency for a massive reduction in render count. If the model streams faster than 60fps, you still paint only 60 times per second. For slower cadences, rAF naturally aligns with arrivals.
Tradeoff: requestAnimationFrame pauses in background tabs. If you need the buffer to stay fresh when hidden, fall back to setInterval(flush, 100) and clear it on visibility change.
3. Isolate the streaming message from the list
A common mistake is storing an array of messages and appending a token to the last element per flush. That still creates a new array and new message object each frame. Instead, render static messages from a normal array, and mount a dedicated StreamingMessage component that owns the hook above.
function Chat({ history }: { history: Message[] }) {
const [streaming, setStreaming] = useState(true);
return (
<div className="scroll">
{history.map(m => <MemoMessage key={m.id} msg={m} />)}
{streaming && <StreamingMessage onDone={() => setStreaming(false)} />}
</div>
);
}
function StreamingMessage({ onDone }: { onDone: () => void }) {
const { text, onToken } = useTokenStream();
useEffect(() => {
const ctrl = new AbortController();
streamCompletion(JSON.stringify({/* request */}), onToken, () => {}, ctrl.signal)
.finally(onDone);
return () => ctrl.abort();
}, []);
return <div className="msg assistant"><MemoizedText text={text} /></div>;
}
Static messages can be plain strings rendered by React.memoized components keyed by stable IDs, not indices.
4. Memoize the leaf, not the tree
Wrap the text renderer in React.memo and ensure it only depends on the text prop. Avoid passing inline functions or changing objects as props.
const MemoizedText = React.memo(function MemoizedText({ text }: { text: string }) {
return <>{text}</>;
});
If you need markdown or syntax highlighting, do it inside this leaf with a cached parser. Running a markdown parser every frame on a growing string is the silent killer of react token streaming re-render performance. Parse only the diff, or debounce the heavy transform by 200ms. useDeferredValue helps mask latency but does not reduce render count; batching does.
5. Use CSS containment and controlled scrolling
Even with batched state, a long message forces relayout of the whole document if the container is not isolated. Apply contain: content to message bubbles and give the scroll container a fixed height.
.msg { contain: content; padding: 0.5rem 0.75rem; }
.scroll { height: 60vh; overflow-y: auto; }
Auto-scroll only when the user is pinned to the bottom. Use a layout effect that checks proximity before setting scrollTop.
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
if (el.scrollHeight - el.scrollTop - el.clientHeight < 40) {
el.scrollTop = el.scrollHeight;
}
});
Avoid animating height or transform on the streaming bubble.
6. Handle interruption and provider metadata
Users abort generations. Your stream hook must support AbortController and discard partial state on unmount. If you source tokens from a gateway that performs automatic fallback when a provider is degraded, expect a possible stream reset or a final usage event after text stops. Forward the abort signal and treat any non-token event as metadata.
When pulling from an OpenAI-compatible endpoint such as n4n.ai, the per-token usage meter arrives as a trailing JSON object; ignore it in onToken and read it in a separate onMeta callback to avoid corrupting the displayed string.
7. Measure before tuning further
Open React DevTools, enable “Highlight updates”, and watch the streaming message. You should see exactly one component flashing per frame, not the whole list. If the list re-renders, a key or context is leaking.
For deeper analysis, wrap flush in performance.mark and compute inter-flush deltas. If you see bursts of sub-4ms flushes, lower the cadence to setInterval(flush, 50) to cap at 20fps; text doesn’t need 60fps to feel responsive.
Common pitfalls and tradeoffs
- Per-token setState: simplest to write, unusable beyond ~50 tokens. Don’t.
- Markdown on every frame: pretty but expensive. Debounce 200ms or parse incrementally.
- Index keys: cause React to reuse DOM nodes incorrectly when messages reorder. Use UUIDs.
- Context for stream state: putting the buffer in a context re-renders all consumers. Keep it local.
- Virtualization during stream: libraries like
react-windowfight dynamic heights. Skip virtualization until history exceeds a few hundred messages; then measure. - Background tab stalls: rAF stops; use interval fallback.
Final ordered checklist
- Open stream with
fetch+ReadableStream, parse outside React. - Accumulate tokens in a ref; flush to state on
requestAnimationFrame. - Isolate streaming message in its own component; static list stays immutable.
- Memoize leaf text node; debounce heavy transforms.
- Apply CSS
containand fixed scroll container with smart auto-scroll. - Abort on unmount; ignore meta events in text path.
- Profile with DevTools; drop to 20fps if needed.
Follow that and react token streaming re-render performance stops being a mystery: you control the paint schedule, not the network.