Skip to content
aiaz·labs
All posts
AI Interfaces4 min read·

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.

stream.ts
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.

useStreamedText.ts
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.

ts
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