Topic 2.2
ReadableStream and the Reader
In one line
A response body is a ReadableStream: a queue of byte chunks that arrive over time. You take chunks out with a reader, looping on read() until done is true. A chunk is whatever the network handed over, not a word or a line, and a stream can be read only once by one reader at a time.
Think of it like this
A letterbox on your front door. Parcels drop through at different times of the day, in different sizes, and you cannot know how many are coming. You pick them up one by one. When the postman says "that's everything", you stop. You cannot pick up the same parcel twice, and two people reaching for the slot at once would get in each other's way. A stream is that letterbox, and a reader is the person at the door.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- ReadableStream
- An object that holds data arriving over time and lets you take it out in order.
- Reader
- The handle from
getReader()that takes chunks out of a stream. One reader at a time. - Chunk
- One piece taken from a stream. For a response body it is a
Uint8Arrayof bytes. - Uint8Array
- A list of numbers from 0 to 255, used for raw bytes.
- done
- The flag
read()returns.truemeans the stream has ended and there are no more chunks. - Locked
- A stream that has a reader attached. Nothing else can read it until the reader lets go.
- Cancel
- Telling a stream (and its source) that you will not read any more.
- Async iteration
- The
for awaitloop, which callsread()for you.
Step by step
01From fetch to a reader
await fetch(…) gives you a Response as soon as the headers arrive. Its body is a stream. getReader() returns the object you pull chunks from. This is the whole loop, with every name explained in the comments.
const res = await fetch("/api/ask", { method: "POST" });
if (!res.body) throw new Error("This response has no body"); // e.g. a 204
const reader = res.body.getReader();
for (;;) {
const { done, value } = await reader.read(); // waits for the next chunk
if (done) break; // the server finished: value is undefined here
use(value); // a Uint8Array of bytes
}02Run the loop and look at what comes out
The first live example runs the loop on a small answer. It prints the first two reads, the final read where done is true, and the same answer collected with for await. Notice the chunks are Uint8Arrays, not strings, and that the last read() still has to be made to learn that the stream is over.
03Locked, used, and cancelled
The second example makes each rule visible. After getReader() the stream is locked, a second reader is refused, and res.text() is refused. After cancel(), the server's own cancel callback runs (the line starting (server)) and the next read() is immediately done.
Read the three error texts. They are what you will see in the console when you mix the shortcut methods with a reader, so recognising them saves time.
04Chunks follow the network, not your words
The third example sends a sentence ten bytes at a time. The pieces cut words in the middle ("Masala cha", "i is black"). Joined together they are the sentence, but no single piece can be trusted to be a unit of meaning.
The same example leaves a for await loop early (the server is told to stop), and reads two responses that are empty in different ways: a 204 with no body at all, and an empty body.
Try it yourself
- 1
Read twice, on purpose
In the first example, add
const copy = res2.clone();on the line afterconst res2 = await fetch(…), and addconsole.log("the clone still has the text:", JSON.stringify(await copy.text()));at the very end.res2is used up by the loop, but the clone still holds the whole text:the clone still has the text: "Masala chai is black tea.". The price is memory, because the clone keeps its copy until it is read or discarded. - 2
Forget the done check
Do not run this one for real: reason about it. In the first example, delete the
if (done) { … break; }block. The loop never ends, because after the last chunk everyread()returnsdone: trueimmediately, andnkeeps counting up.Your page would spin and freeze. The
donecheck is the loop's only exit, so every reader loop needs it. - 3
Leave the loop early
In the third example, change
if (++n === 3) break;toif (++n === 6) break;. The(server)line still appears right after the loop is left, and the next line saysleft the loop after 6 chunks | locked afterwards: false.The server's stream is told to stop at whatever point the loop is left. Remove the
breakand the endless stream would never finish.
Code & diagrams
tokenStream sends one word per chunk here. A real network can join or split them any way it likes. The last read has done: true and no value.
Preview
Console
Nothing logged yet.Expected console output
response arrived, body is a ReadableStream: true
read 1 -> done: false | chunk is Uint8Array: true | bytes: 6
read 2 -> done: false | chunk is Uint8Array: true | bytes: 5
read 6 -> done: true value: undefined
chunks: 5
for await collected: "Masala chai is black tea."
body already used: true
TypeError: Failed to execute 'text' on 'Response': body stream already readThe (server) line is the stream's own cancel callback. In a real app, this is the moment your route learns the user left and can stop the model.
Preview
Console
Nothing logged yet.Expected console output
locked after getReader: true
TypeError: Failed to execute 'getReader' on 'ReadableStream': ReadableStreamDefaultReader constructor can only accept readable streams that are not yet locked to a reader
TypeError: Failed to execute 'text' on 'Response': body stream is locked
first chunk: "word1 "
(server) stream cancelled, reason: user pressed stop
after cancel: { done: true, value: undefined }
locked after releaseLock: falsedecode(chunk, { stream: true }) is explained in Topic 2.3. Here it only turns bytes into text so you can see where the cuts fall.
Preview
Console
Nothing logged yet.Expected console output
"Masala cha" "i is black" " tea boile" "d with mil" "k."
pieces joined = the text: true
(server) the reader walked away, stop generating
left the loop after 3 chunks | locked afterwards: false
a 204 response: status 204 | body: null
an empty string body: the first read is done: trueBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Use the shortcut while a reader holds the stream
In the second example, delete the try { and } catch (e) { … } that wrap await res.text().
Myth vs fact
Myth
Each read() returns one word, or one line, or one message.
Fact
It returns whatever bytes the network delivered. The third example's pieces cut words in half. Parsing has to cope with any boundary (Topics 2.3 to 2.5).
Myth
value is a string.
Fact
It is a Uint8Array of bytes. You get strings by decoding, either with TextDecoderStream or TextDecoder (Topic 2.3).
Myth
Once I have the response, fetch has finished.
Fact
The promise resolves when the headers arrive. The body may still take seconds. That gap is what streaming uses.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
pipeThrough(new TextDecoderStream())is itself a stream stage: it takes the byte stream and gives you a text stream. The same idea chains: bytes, then text, then lines, then parsed events. Every stage is aTransformStream, and each only runs as fast as you read, which is called backpressure. - ▸
Always release or cancel. A reader that is abandoned without
cancel()orreleaseLock()leaves the connection open and the server generating. Put the cleanup infinally, and cancel on unmount in React (Topic 3.1 shows the full pattern). - ▸
Streaming
fetchrequest bodies, andfor awaitoverReadableStream, are newer thangetReader()and differ between browsers. When you must support older browsers, stay with the explicit reader loop and test on the browsers you ship to.
Remember this
- 1
The problem this solves: Topic 0.4 used
getReader()and a loop without explaining them. When something goes wrong (a locked stream, a loop that never ends, a half-read body) you need to know exactly what these objects are. This topic takes the reader loop apart so it never feels like magic. - 2
**
fetchhands you the response early.** The promise fromfetchresolves as soon as the status and headers have arrived. The body is still travelling.res.bodyis a **ReadableStream**: an object that holds the body's bytes in a queue as they arrive and lets you take them out. The shortcutsres.text()andres.json()simply read that stream to the end for you and join the pieces. - 3
The reader loop.
const reader = res.body.getReader()gives you a reader. Eachawait reader.read()waits for the next chunk and returns an object{ done, value }. While the body is still coming,doneisfalseandvalueis the chunk. After the last chunk,read()returns{ done: true, value: undefined }. So the loop is: read, checkdone, usevalue, repeat. If you never checkdone, the loop spins forever on a stream that has ended. - 4
What a chunk is, and is not. A chunk is a
Uint8Array, a list of bytes. Its size is decided by the network and by how the server wrote its data, not by your text. One chunk can hold half a word, three whole lines, or the end of one message and the start of the next. Never assume one chunk is one word, one line or one JSON object. Topics 2.3 to 2.5 turn this into three concrete problems and their fixes. - 5
Locking: one reader, one pass. While a reader exists, the stream is locked: nothing else may read it. Calling
res.text()then throwsTypeError: Failed to execute 'text' on 'Response': body stream is locked. Once the body has been read (or started to be read),res.text()throwsbody stream already readinstead. A stream is a one-way pipe. If you need the data twice, keep what you read, or useres.clone()before reading. - 6
**
for awaitis the short way.**for await (const chunk of res.body) { … }does the same loop for you: it gets the reader, callsread()until done, and releases the lock. If you leave the loop early withbreak, the stream is cancelled, which tells the server to stop. It is newer thangetReader(), and support for it onReadableStreamis not in every browser (MDN's compatibility table forReadableStreamshows which), so check it before relying on it in a product. The explicit reader loop works wherever streamingfetchworks. - 7
Cancelling.
reader.cancel(reason)says "I don't want the rest". The source is told to stop and the connection is released. For a real request the usual tool is anAbortController(Topic 3.1), which also stops the network transfer. The important part for now: if the user has gone away, stop reading, so the server stops generating words nobody will see (and you stop paying for them). - 8
Edge cases to expect. A response can have no body (
res.bodyisnull, for example after a204), so check before callinggetReader(). A body can be empty: the firstread()is alreadydone. Andread()can reject at any point if the connection breaks, which is why the loop belongs insidetry/catch(Topic 0.4).
Explain it without notes
What is res.body, and how does res.text() relate to it?
Describe the reader loop and what done and value mean on each turn.
Why can you not call res.text() after getReader(), and what are the two different error messages?
Why must a reader loop never assume one chunk is one word or one JSON object?
What happens to the server when you leave a for await loop early?
Practice
Write readAll(response) that reads a response with a reader loop and returns the text. It must return "" for a response with no body.
Write firstChunkSize(response) that reads only the first chunk, returns its size in bytes, and then cancels the stream so the server stops.
Trade-offs
- ↔
The explicit reader gives you control (early exit, cancel, errors in one place) and works widely.
for awaitis shorter and cancels onbreak, but depends on newer browser support. - ↔
Reading the whole body with
res.text()is the simplest code and the right choice when you do not need partial results. Switching to a reader is a decision to take on states, partial failure and cancellation.
Done when you can
I can write the reader loop from memory and explain
doneandvalue.I know a chunk is a
Uint8Arraywhose size the network decides.I can explain why a body can only be read once and what a locked stream is.
I can recognise
body stream is lockedandbody stream already read.I check for
res.body === nulland for empty bodies, and I cancel or release infinally.