Skip to content
KONIGI

AI Assistants / Turn and response / Streaming response

6 of 6

Streaming response

A model takes ten seconds to finish a thought, and ten seconds of spinner reads as broken.

Updated September 12, 2026

Problem

Someone asks a question and the model needs thirty seconds to answer it. A spinner held for thirty seconds is indistinguishable from a page that’s crashed. The viewer can’t tell whether the thing is working, stuck, or already failed, and the only available action is to wait or reload.

Solution

Open a long-lived response and emit the answer in pieces as it’s produced. The transport is server-sent events: a single HTTP response with a text/event-stream content type that stays open while the server writes named events into it. Anthropic’s stream is a fair model of the shape—message_start, then a run of content_block_delta events each carrying a text_delta, then message_delta and message_stop. The client appends each delta and re-renders.

The reason this works isn’t that it’s faster. End to end it’s usually a little slower, because chunked encoding and per-event overhead cost something. It works because it moves the wait. Nielsen’s three limits put 0.1 seconds at the threshold of feeling instantaneous, 1 second at the limit of uninterrupted flow, and 10 seconds at the limit of holding attention at all. A thirty-second generation blows through all three. The same generation streamed becomes a one-second wait followed by twenty-nine seconds of reading, and reading isn’t waiting. The number that governs the experience is time to first token.

Three decisions carry most of the design:

  1. The gap before the first token. This is the only part that is still a wait, and it needs its own treatment—a caret, a shimmer, a “thinking” line. Once deltas arrive the text itself is the progress indicator.
  2. Incremental markdown. Text streams a character at a time, but a fenced code block, a table, or a link is only meaningful once closed. Rendering markdown on every delta makes tables reflow and half-written links flash as literal brackets. The usual fix is to render closed blocks and hold open ones as plain text until their delimiter arrives.
  3. Scroll. Pinning the viewport to the bottom is correct until the reader scrolls up to re-read something. Detach on any upward scroll, then offer an explicit return to the live edge.

Screen readers need the opposite of what sighted readers get. An aria-live region fed one token at a time either interrupts itself continuously or queues thousands of announcements. Announce the response once on completion, and expose the arrival of the first token and the end of the stream as discrete cues rather than narrating the middle.

Use when

The output is linear prose a person reads top to bottom, and generation runs longer than about a second. This covers nearly every chat answer.

Don’t use when

The output is only usable whole. A JSON payload, an image, a table that reflows on every row, or a three-word answer all do better arriving complete. Streaming a structure that rearranges itself as it grows is more agitating than a spinner.

Trade-offs

Streaming publishes the first sentence before the model has written the last one, so a confident wrong opening stays on screen and can’t be silently revised. It also makes latency look better than it measures, which quietly rewards teams for optimising the wrong number. Incremental rendering costs real complexity in the markdown layer, and every product that skipped it shipped flickering tables. Auto-scroll is the single most complained-about behaviour in the pattern, and it always comes from someone reading while the answer arrives.

Checklist

  • What’s the time to first token, and is it measured separately from total generation time?
  • What fills the gap between send and first token?
  • Does a half-written code fence, table, or link render as literal markdown while it streams?
  • When the reader scrolls up mid-stream, does the view stay where they put it?
  • Is there an obvious way back to the live edge once they have detached?
  • What does a screen reader hear while three hundred tokens arrive?
  • If the connection drops at token 200, what stays on screen and what does the viewer see?
  • Can the viewer copy or act on a partial response before it finishes?
  • Does the composer stay usable while a response is streaming?
  • Is the end of the stream marked in a way a person and a screen reader can both detect?

Compare

ChatGPT keeps the composer live while a response streams, so a correction can be typed before the answer lands. Claude moves long structured output into a side panel rather than the transcript, so a document being generated does not reflow the conversation underneath it. Perplexity resolves and shows its sources before the prose begins, so the citations a sentence points at already exist by the time the sentence arrives. GitHub Copilot goes the other way for inline code completion and presents a suggestion whole, because ghost text that grows character by character inside an editor is unreadable.

Message turn is the container each streamed answer lands in. Stop generation is the control that only exists because streaming made the middle of a response a place the viewer can stand. Reasoning disclosure streams too, on a separate track and usually collapsed. Artifact panel is where output goes when streaming it into the transcript would wreck the transcript. Generation error is what the pattern owes you when the stream dies halfway.

Streaming response anatomy An assistant turn part-way through generation: three finished lines, a fourth still arriving with a caret at its end, an unterminated code fence rendering as literal text, and a timeline below showing that the first token arrives in about a second while the whole response takes thirty. One second of waiting, twenty-nine of reading Why is the build slow? ```bash npm run build 1s 30s total 2 3 1 4 1 TIME TO FIRST TOKEN The only part that is actually a wait. It needs its own treatment. 2 THE LIVE EDGE Where text is arriving. Scroll pins here until the reader scrolls up. 3 AN UNCLOSED FENCE Markdown is only meaningful once the delimiter arrives. Hold it as plain text. 4 TOTAL IS NO SHORTER End to end it is slightly slower. The win is where the wait sits, not its length. Optimise the first token. Total latency is the number that looks worse and feels better.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

A response part-way through generation, with a caret on the line still arriving. First token lands in about a second and the whole response takes thirty, so the reader spends twenty-nine of those reading rather than waiting.

shadcn
npx shadcn@latest add button
npm
lucide-react
Tokens
--foreground--card--muted--muted-foreground--border--chart-1

Why is the build slow?

Three things are stacking up. The TypeScript check runs twice, once in the lint step and again in the build, because both scripts call tsc and neither passes --incremental. The image pipeline re-encodes every asset on every run, since the cache key includes a timestamp. And the test step sits on the critical path even though nothing downstream depends on it.

The quickest win is the double type-check. Run it once, up front, and reuse the result:

```bash npm run build
Responding
1s30s total

StreamingResponse.tsxHolds an unclosed fence as plain text, pins scroll to the live edge until the reader scrolls up, and announces once at the end.

import { useEffect, useRef, useState, type ReactNode } from "react";
import { ArrowDown } from "lucide-react";
import { Button } from "@/components/ui/button";

type Props = {
  /** Everything received so far. The caller appends each text_delta. */
  text: string;
  /** True once message_stop arrives. */
  done: boolean;
  /** Your markdown renderer. Only closed blocks reach it. */
  render: (markdown: string) => ReactNode;
  /** Time to first token and total, when the product wants to show them. */
  timing?: { firstToken: number; total: number };
};

/**
 * Markdown is only meaningful once its delimiter arrives. A fence opened on
 * the last delta would render as an empty code block that reflows on every
 * token, so split at the last unclosed one and hold the tail as plain text.
 */
function splitOpenFence(text: string) {
  const fences = [...text.matchAll(/^```/gm)];
  if (fences.length % 2 === 0) return { closed: text, open: "" };
  const at = fences[fences.length - 1].index!;
  return { closed: text.slice(0, at), open: text.slice(at) };
}

export function StreamingResponse({ text, done, render, timing }: Props) {
  const box = useRef<HTMLDivElement>(null);
  const [pinned, setPinned] = useState(true);
  const { closed, open } = splitOpenFence(text);

  // Pin the viewport to the live edge until the reader scrolls up. Any
  // upward scroll detaches; the button below is the way back.
  useEffect(() => {
    const el = box.current;
    if (el && pinned) el.scrollTop = el.scrollHeight;
  }, [text, pinned]);
  const onScroll = () => {
    const el = box.current!;
    setPinned(el.scrollTop + el.clientHeight >= el.scrollHeight - 8);
  };

  return (
    <div className="relative">
      <div ref={box} onScroll={onScroll} className="max-h-96 overflow-y-auto text-sm leading-relaxed text-foreground">
        {render(closed)}
        {open && <pre className="whitespace-pre-wrap font-mono text-xs text-muted-foreground">{open}</pre>}
        {!done && <span aria-hidden="true" className="ml-0.5 inline-block h-3.5 w-0.5 animate-pulse bg-foreground align-middle" />}
      </div>

      {!pinned && !done && (
        <Button size="sm" variant="outline" className="absolute bottom-2 left-1/2 -translate-x-1/2 shadow" onClick={() => setPinned(true)}>
          <ArrowDown className="size-4" /> back to live
        </Button>
      )}

      {/* A live region fed per token interrupts itself forever. Announce the
          start and the end as discrete cues, and the whole text once. */}
      <div className="sr-only" aria-live="polite">
        {done ? text : text ? "Responding" : ""}
      </div>

      {timing && (
        <div className="mt-4">
          <div className="flex h-3 w-full overflow-hidden rounded-[2px]" role="img" aria-label={`first token after ${timing.firstToken}s, ${timing.total}s total`}>
            <span className="bg-chart-1" style={{ width: `${(100 * timing.firstToken) / timing.total}%` }} />
            <span className="flex-1 bg-muted" />
          </div>
          <div className="mt-1.5 flex justify-between text-xs tabular-nums text-muted-foreground">
            <span>{timing.firstToken}s</span><span>{timing.total}s total</span>
          </div>
        </div>
      )}
    </div>
  );
}

demo.tsxHow it is called: text that keeps arriving, a renderer for closed blocks, and the timing footer.

import { useEffect, useState } from "react";
import { StreamingResponse } from "./StreamingResponse";

/**
 * The answer arrives a few words at a time. It starts part-way through, with
 * a fence just opened, which is the moment the wireframe draws: a closed
 * paragraph, a line still arriving, and markdown held as plain text until
 * its delimiter comes.
 */
const HEAD =
  "Three things are stacking up. The TypeScript check runs twice, once in the lint step and again in the build, because both scripts call tsc and neither passes --incremental. The image pipeline re-encodes every asset on every run, since the cache key includes a timestamp. And the test step sits on the critical path even though nothing downstream depends on it.\n\nThe quickest win is the double type-check. Run it once, up front, and reuse the result:\n\n```bash npm run build";
const TAIL =
  " --skip-typecheck\n```\n\nThen key the image cache on a content hash rather than mtime, and move the tests into a parallel job that gates the deploy rather than the build.";

/** Plain paragraphs. A real app hands this to its markdown renderer. */
const render = (md: string) =>
  md.split(/\n\n+/).filter(Boolean).map((p, i) => (
    p.startsWith("```")
      ? <pre key={i} className="mt-3 rounded-md bg-muted p-3 font-mono text-xs">{p.replace(/^```\w*\s?|```$/g, "").trim()}</pre>
      : <p key={i} className={i ? "mt-3" : ""}>{p}</p>
  ));

export default function Demo() {
  const [text, setText] = useState(HEAD);
  const [done, setDone] = useState(false);

  useEffect(() => {
    const words = TAIL.split(/(?<=\s)/);
    let i = 0;
    const id = setInterval(() => {
      if (i >= words.length) { setDone(true); clearInterval(id); return; }
      setText((t) => t + words[i++]);
    }, 120);
    return () => clearInterval(id);
  }, []);

  return (
    <div className="rounded-lg border bg-card p-4">
      <div className="flex justify-end">
        <p className="max-w-md rounded-xl bg-muted px-3.5 py-2 text-sm text-foreground">Why is the build slow?</p>
      </div>
      <div className="mt-5">
        <StreamingResponse text={text} done={done} render={render} timing={{ firstToken: 1, total: 30 }} />
      </div>
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.