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(). Itsread()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.
fetchdoes 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.
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.
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.
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
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
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
Throw the partial answer away
In the second example's
catch, addsetAnswer("");. 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
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
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."
doneThe 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
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)*.
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. Theaskfunction is created once per render, so theanswerinside 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, andres.text()would throwTypeError: Failed to execute 'text' on 'Response': body stream is locked. If you stop reading early, callreader.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
The problem this solves: The simplest code,
await fetch(...)thenawait 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
A
fetchgives you aResponseas soon as the server starts answering, *before* the body has arrived. The body is a stream:res.bodyis aReadableStream, an object that hands out the data in chunks as they come in.res.text()andres.json()are shortcuts that wait for all of it. To show text early you read the stream yourself. - 3
Reading a stream takes three steps. Get a reader:
res.body.getReader(). Callawait reader.read()in a loop; each call waits for the next chunk and returns{ done, value }. Stop whendoneistrue. The chunks are raw bytes, so pass the body through aTextDecoderStreamfirst to turn bytes into text. (Topic 2.3 explains why you must not decode each chunk on its own.) - 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
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
Failure has three shapes, and each needs its own message. (1) The server answers with a failure status such as
429(too many requests) or500(the server broke).fetchdoes not throw for these: it returns a response whoseokisfalse, so you must checkres.okbefore reading. (2) No response at all (offline, blocked):fetchthrows. (3) The stream breaks halfway:reader.read()throws after some text has already been shown. - 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
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
How do you show a response while it is still arriving?
What states does a streamed request go through, and why keep them in one variable?
What are the three ways a streamed request can fail, and how does each surface in code?
A stream breaks after half the answer. What should the UI do?
Practice
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.
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
fetchdoesn't throw on a 429 or 500, and checkres.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.