Command Palette

Search for a command to run...

Hectal
PHASE 0Beginner ~24 min· topic 4 of 5

Topic 0.4

Your First Streamed Answer

In one line

A streamed answer is shown by reading the response body piece by piece and adding each piece to the screen. A request moves through four states (idle, waiting, streaming, done) plus failure, and the screen must show every one of them.

Think of it like this

Watching a parcel on a tracking page. "Order placed", then "Out for delivery", then "Delivered". You are never left wondering whether anything is happening, and if the van breaks down the page says so. A streamed answer needs the same honesty: the screen always says which stage the answer is in.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Response body
The content of a response. In the browser it is a stream that arrives over time.
ReadableStream
An object that hands out data in chunks as it becomes available.
Chunk
One piece of data from a stream. Its size is decided by the network, not by words or sentences.
Reader
The object you get from getReader(). Its read() returns the next chunk, or says the stream is done.
TextDecoderStream
A converter you pipe a byte stream through to get text chunks.
State
The stage a request is in: idle, waiting, streaming, done or error.
res.ok
True when the status is 200–299. fetch does not throw on 404, 429 or 500, so you must check this.
Partial answer
The text that arrived before a stream stopped or failed.

Step by step

01The version that freezes

This is correct code, and a poor experience: the page shows nothing until the model has written its last word.

all-at-once.jswhole filejs
const res = await fetch("/api/ask", { method: "POST" });
const answer = await res.text(); // waits for the WHOLE answer
show(answer);

02Read the body as it arrives

Replace the one await res.text() with a loop. Each turn of the loop gets the next piece and adds it to the screen.

pipeThrough(new TextDecoderStream()) converts bytes to text. read() returns done: true once the server has finished.

streaming.jswhole filejs
const res = await fetch("/api/ask", { method: "POST" });
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  append(value); // show this piece now
}

03Name the states

The first live example keeps one status value and moves it through idle → waiting → streaming → done. The page shows the status and a block cursor ▍ while text is arriving, and the button is disabled while a request runs.

Read the console: one waiting line, then a streaming line per piece, then done. The time between waiting and the first streaming is the time to first token.

Name the statesdiagram
Rendering diagram…

04Three failures, three messages

The second live example has three buttons. *Server says no* returns 429. *Server crashes* returns 500. *Connection drops* sends two pieces and then breaks the stream.

The code checks res.ok first, so the two status failures never reach the reading loop. The third failure happens *inside* the loop: reader.read() throws. The catch keeps the text that arrived and shows "The answer was cut off".

05Words for people, details for the console

Notice what the page says: "Mitra is busy. Please try again in a moment." It does not say "429" or "model provider is unreachable". Status codes and server messages are for you; the user gets what happened and what to do next. Topic 1.3 builds the full mapping.

Try it yourself

  1. 1

    Feel the freeze

    In the first example, replace the reading loop with const whole = await res.text(); setAnswer(whole); (keep the status lines). Run it and watch the page, not the console.

    Nothing appears for a moment (about a third of a second here), then the whole sentence at once. With a real model and a long answer, that gap is many seconds.

  2. 2

    Remove the disabled button

    Delete the disabled={…} attribute in the first example, press Run, then click the button twice quickly.

    Two answers stream into the same paragraph and their words interleave. Disabling the control while a request runs is the simplest fix; Topic 4.7 covers the general problem.

  3. 3

    Throw the partial answer away

    In the second example's catch, add setAnswer("");. Click *Connection drops*.

    The half sentence the user had already read disappears and only an error remains. Compare how that feels with keeping the text and marking it as cut off.

Code & diagrams

Showing an answer while it arrives

The last line presses the button for you once, so the example runs by itself. Press the button again to watch the states change on the page.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

waiting
streaming: "Masala"
streaming: " chai"
streaming: " is"
streaming: " black"
streaming: " tea"
streaming: " boiled"
streaming: " with"
streaming: " milk,"
streaming: " sugar"
streaming: " and"
streaming: " spices."
done
Three ways it fails, and what the user is told

The example presses the three buttons in turn. role="alert" makes screen readers announce the error. The (server) line is the server's own log; the user never sees it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

refused: kept "" | shown: Mitra is busy. Please try again in a moment.
(server) Error: model provider is unreachable
crash: kept "" | shown: Mitra could not answer. Please try again.
cut: kept "Masala chai is black tea" | shown: The answer was cut off. Please try again.

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget to check res.ok

In the second example, delete the line that starts if (!res.ok) throw new Error( and click *Server says no (429)*.

terminal
$ The page, after clicking
── what you'll see ──
{"error":"Too many requests."}

Myth vs fact

Myth

fetch throws when the server returns an error.

Fact

It throws only when no response arrives (offline, blocked, aborted). For 4xx and 5xx it returns a response with ok: false, and you must check it.

Myth

If the first piece arrived, the answer will complete.

Fact

A stream can break at any point. Reading can throw after any number of pieces, so the reading loop needs its own error handling.

Myth

Each chunk is one word.

Fact

Chunk boundaries come from the network. A chunk can hold several tokens, half a word, or even half a character (Topic 2.3).

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    setAnswer((text) => text + value) (the updater form) matters. The ask function is created once per render, so the answer inside it is the value from the render when the button was clicked (an empty string). setAnswer(answer + value) would therefore set that old empty text plus the newest piece every time, and the page would show only the last piece (measured: tea. instead of the whole sentence). The second example avoids the problem by building the text in a local variable and setting the whole string.

  • ▸

    A reader locks its stream: while you hold it, nothing else can read res.body, and res.text() would throw TypeError: Failed to execute 'text' on 'Response': body stream is locked. If you stop reading early, call reader.cancel() so the connection is released instead of buffering in the background (Topic 3.1).

  • ▸

    The disabled button fixes double clicks but not every overlap: a new question can start while an old stream is still finishing after a state change. The robust fix is a request id that late pieces are checked against (Topic 4.7).

Remember this

  1. 1

    The problem this solves: The simplest code, await fetch(...) then await res.text(), shows nothing until the whole answer has arrived. With a model that can mean ten seconds of a frozen page. And if the request fails halfway, that code loses everything. This topic shows how to display text as it arrives, and how to name the stages so the user always knows what is going on.

  2. 2

    A fetch gives you a Response as soon as the server starts answering, *before* the body has arrived. The body is a stream: res.body is a ReadableStream, an object that hands out the data in chunks as they come in. res.text() and res.json() are shortcuts that wait for all of it. To show text early you read the stream yourself.

  3. 3

    Reading a stream takes three steps. Get a reader: res.body.getReader(). Call await reader.read() in a loop; each call waits for the next chunk and returns { done, value }. Stop when done is true. The chunks are raw bytes, so pass the body through a TextDecoderStream first to turn bytes into text. (Topic 2.3 explains why you must not decode each chunk on its own.)

  4. 4

    Four states. *Idle*: nothing asked yet. *Waiting*: the request is sent and no text has arrived; show that something is happening. *Streaming*: text is arriving; show it, plus a sign that more is coming (a cursor). *Done*: the answer is complete. Keep the state in one variable that holds exactly one of these names. Screens that use several true/false flags (isLoading, isStreaming) end up in impossible combinations.

  5. 5

    The button while it runs. While the state is waiting or streaming, disable the button. Otherwise a double click starts two answers that write into the same place and interleave their words. Topic 1.2 covers this and the Enter-key version of the same bug.

  6. 6

    Failure has three shapes, and each needs its own message. (1) The server answers with a failure status such as 429 (too many requests) or 500 (the server broke). fetch does not throw for these: it returns a response whose ok is false, so you must check res.ok before reading. (2) No response at all (offline, blocked): fetch throws. (3) The stream breaks halfway: reader.read() throws after some text has already been shown.

  7. 7

    Edge cases to expect. The stream can end with no pieces at all (an empty answer): the loop simply finishes, the state becomes done, and the page shows nothing, so a real screen shows a line such as "Mitra had nothing to say. Try rephrasing." A very long answer makes the page grow while the user reads, so layout and scrolling need care (Topic 2.7). A stream that goes silent without breaking is a fourth case, handled by a watchdog timer in Topic 3.5, and a user who presses Stop is Topic 3.1. Recall from Topic 0.1: pieces are tokens, and a piece may be half a word or half a character.

  8. 8

    Keep what arrived. In case (3) the user has already read half an answer. Deleting it is worse than keeping it. Leave the text, and say clearly that it was cut off and can be retried. Never leave a half answer on screen looking complete: a sentence that stops in the middle of a price or a policy can mislead.

Explain it without notes

01

How do you show a response while it is still arriving?

02

What states does a streamed request go through, and why keep them in one variable?

03

What are the three ways a streamed request can fail, and how does each surface in code?

04

A stream breaks after half the answer. What should the UI do?

Practice

01

Change the first example so the status line reads "Thinking…" while waiting, "Answering…" while streaming and "Done" at the end, instead of the raw state names.

02

In the second example, add a fourth button "Offline" that fetches a path nobody serves, and show "You seem to be offline. Check your connection." for it.

Trade-offs

  • ↔

    Reading the stream by hand gives full control and is worth doing once to understand it. In real projects a library (Phase 5) does the loop, the parsing and the state for you; you still need to know what it is doing when something breaks.

  • ↔

    Disabling the button is simple and safe but blocks the user from asking something new while an answer runs. A stop button (Phase 3) gives that control back.

Done when you can

  • I can read a response body piece by piece with a reader loop.

  • I can name the four states of a streamed request and show each on screen.

  • I can explain why fetch doesn't throw on a 429 or 500, and check res.ok.

  • I can handle a stream that breaks midway and keep the partial answer.

  • I can write an error message for a person instead of showing a status code.