javascript
Fix LLM Streaming in React: Prevent Re-renders, Jank, and Token Flooding
import { useEffect, useMemo, useRef, useState, startTransition } from "react";
export function useLLMStream() {
const [text, setText] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const abortRef = useRef(null);
const bufferRef = useRef("");
const flushTimerRef = useRef(null);
const flush = useMemo(() => {
let last = 0;
return (force = false) => {
const now = performance.now();
if (!force && now - last < 50) return; // cadence: 50ms
last = now;
const chunk = bufferRef.current;
if (!chunk) return;
bufferRef.current = "";
startTransition(() => {
setText((t) => t + chunk);
});
};
}, []);
useEffect(() => {
return () => {
if (abortRef.current) abortRef.current.abort();
if (flushTimerRef.current) clearInterval(flushTimerRef.current);
};
}, []);
async function start(prompt) {
// cancel any in-flight stream
if (abortRef.current) abortRef.current.abort();
const ac = new AbortController();
abortRef.current = ac;
setText("");
bufferRef.current = "";
setIsStreaming(true);
// flush buffer on a steady cadence
if (flushTimerRef.current) clearInterval(flushTimerRef.current);
flushTimerRef.current = setInterval(() => flush(false), 50);
try {
const res = await fetch("/api/llm/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
signal: ac.signal
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.body) throw new Error("Missing response body stream");
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
// Decode bytes to string (stream-safe)
const str = decoder.decode(value, { stream: true });
bufferRef.current += str;
// Safety: if buffer is huge, flush immediately
if (bufferRef.current.length > 32_000) flush(true);
}
// final flush
flush(true);
} finally {
setIsStreaming(false);
if (flushTimerRef.current) {
clearInterval(flushTimerRef.current);
flushTimerRef.current = null;
}
}
}
function stop() {
abortRef.current?.abort();
}
return { text, isStreaming, start, stop };
}
Problem: token streaming makes your UI laggy
- Streaming one token at a time into React state triggers excessive renders.
- Markdown rendering on every chunk is expensive and causes input/scroll jank.
- Fast streams can overwhelm the main thread and make the page feel frozen.
Solution: buffer chunks, flush on a schedule, and keep the DOM stable
- Accumulate streamed text in a ref (no render).
- Flush to state at a fixed cadence (e.g., every 50ms) or on animation frames.
- Use
startTransitionso updates don’t block user input. - Abort previous streams to avoid interleaving responses.
Implementation (fetch + ReadableStream)
- Use an
AbortControllerper request. - Decode bytes with
TextDecoderand append to a buffer ref. - Flush buffered text on an interval; keep the interval short but not per-token.
Extra hardening
- Backpressure: if the buffer grows too large, flush immediately (or reduce cadence) to avoid memory spikes.
- Scrolling: avoid calling
scrollIntoViewper token—do it per flush, or only when user is at bottom. - Markdown: defer parsing; render plain text while streaming and convert to markdown once completed (or parse per flush).