Building a streaming chat UI that doesn't jank
Abstract
Token-by-token rendering, scroll behaviour and abort handling — the patterns that keep an LLM chat feeling instant.
Rahul Mishra · aiaz·labs
A chat interface that streams tokens looks simple: append text as it arrives. In practice it is one of the easiest ways to ship a janky UI. Every chunk can trigger a render, a layout pass and a scroll jump — dozens of times per second.
Why streaming UIs jank
- Each network chunk calls
setState, so React re-renders far more often than the screen refreshes. - Markdown re-parsing the whole message on every token is quadratic work.
- Auto-scrolling fights the user when they scroll up to re-read.
Stream with fetch, not polling
The Fetch API exposes the response as a ReadableStream. Wrap it in an async generator and the rest of your UI can consume tokens with a plain for await loop — and cancel with an AbortSignal.
export async function* streamTokens(prompt: string, signal: AbortSignal) {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
signal,
});
if (!res.ok || !res.body) throw new Error("Request failed");
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
const { value, done } = await reader.read();
if (done) return;
yield value;
}
}Batch renders to the frame
Network chunks arrive on their own schedule; the screen refreshes at 60 or 120Hz. Buffer the incoming text and flush it once per animation frame, so React renders at most once per paint no matter how fast tokens arrive.
export function useStreamedText() {
const [text, setText] = useState("");
const buffer = useRef("");
const frame = useRef(0);
const push = (chunk: string) => {
buffer.current += chunk;
if (frame.current) return; // a flush is already scheduled
frame.current = requestAnimationFrame(() => {
const next = buffer.current;
buffer.current = "";
frame.current = 0;
setText((t) => t + next);
});
};
return { text, push };
}Respect the user's scroll
Only follow the stream if the reader is already at the bottom. The moment they scroll up, stop — and offer a button to jump back down.
const nearBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < 80;
if (nearBottom) el.scrollTo({ top: el.scrollHeight });Always ship an escape hatch
A stop button wired to AbortController.abort() is the single most appreciated control in an LLM interface. Keep the partial answer on screen, mark it as stopped, and let the user retry from there.
Share
Related research
- RAG6 min readComing soon
Citations users can actually verify
How to ground RAG answers and link every claim back to the exact source passage.
- RAG
- Grounding
- Citations
In the works - Accessibility7 min readComing soon
Keyboard-first comboboxes: ARIA 1.2 in practice
What the spec says, what screen readers really announce, and the bugs that slip through.
- ARIA
- Keyboard
- WCAG 2.2
In the works