Command Palette

Search for a command to run...

Hectal
PHASE 2Beginner ~25 min· topic 2 of 8

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 Uint8Array of bytes.
Uint8Array
A list of numbers from 0 to 255, used for raw bytes.
done
The flag read() returns. true means 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 await loop, which calls read() 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.

the reader loopwhole filejs
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.

Chunks follow the network, not your wordsdiagram
Rendering diagram…

Try it yourself

  1. 1

    Read twice, on purpose

    In the first example, add const copy = res2.clone(); on the line after const res2 = await fetch(…), and add console.log("the clone still has the text:", JSON.stringify(await copy.text())); at the very end.

    res2 is 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. 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 every read() returns done: true immediately, and n keeps counting up.

    Your page would spin and freeze. The done check is the loop's only exit, so every reader loop needs it.

  3. 3

    Leave the loop early

    In the third example, change if (++n === 3) break; to if (++n === 6) break;. The (server) line still appears right after the loop is left, and the next line says left 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 break and the endless stream would never finish.

Code & diagrams

The reader loop, step by step

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

Press Run to see the page.

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 read
Locked, refused and cancelled

The (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

Press Run to see the page.

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: false
Chunks follow the network, and bodies can be empty

decode(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

Press Run to see the page.

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: true

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

Use the shortcut while a reader holds the stream

In the second example, delete the try { and } catch (e) { … } that wrap await res.text().

terminal
$ The console
── what you'll see ──
Uncaught TypeError: Failed to execute 'text' on 'Response': body stream is locked

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 a TransformStream, and each only runs as fast as you read, which is called backpressure.

  • ▸

    Always release or cancel. A reader that is abandoned without cancel() or releaseLock() leaves the connection open and the server generating. Put the cleanup in finally, and cancel on unmount in React (Topic 3.1 shows the full pattern).

  • ▸

    Streaming fetch request bodies, and for await over ReadableStream, are newer than getReader() 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. 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. 2

    **fetch hands you the response early.** The promise from fetch resolves as soon as the status and headers have arrived. The body is still travelling. res.body is a **ReadableStream**: an object that holds the body's bytes in a queue as they arrive and lets you take them out. The shortcuts res.text() and res.json() simply read that stream to the end for you and join the pieces.

  3. 3

    The reader loop. const reader = res.body.getReader() gives you a reader. Each await reader.read() waits for the next chunk and returns an object { done, value }. While the body is still coming, done is false and value is the chunk. After the last chunk, read() returns { done: true, value: undefined }. So the loop is: read, check done, use value, repeat. If you never check done, the loop spins forever on a stream that has ended.

  4. 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. 5

    Locking: one reader, one pass. While a reader exists, the stream is locked: nothing else may read it. Calling res.text() then throws TypeError: Failed to execute 'text' on 'Response': body stream is locked. Once the body has been read (or started to be read), res.text() throws body stream already read instead. A stream is a one-way pipe. If you need the data twice, keep what you read, or use res.clone() before reading.

  6. 6

    **for await is the short way.** for await (const chunk of res.body) { … } does the same loop for you: it gets the reader, calls read() until done, and releases the lock. If you leave the loop early with break, the stream is cancelled, which tells the server to stop. It is newer than getReader(), and support for it on ReadableStream is not in every browser (MDN's compatibility table for ReadableStream shows which), so check it before relying on it in a product. The explicit reader loop works wherever streaming fetch works.

  7. 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 an AbortController (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. 8

    Edge cases to expect. A response can have no body (res.body is null, for example after a 204), so check before calling getReader(). A body can be empty: the first read() is already done. And read() can reject at any point if the connection breaks, which is why the loop belongs inside try/catch (Topic 0.4).

Explain it without notes

01

What is res.body, and how does res.text() relate to it?

02

Describe the reader loop and what done and value mean on each turn.

03

Why can you not call res.text() after getReader(), and what are the two different error messages?

04

Why must a reader loop never assume one chunk is one word or one JSON object?

05

What happens to the server when you leave a for await loop early?

Practice

01

Write readAll(response) that reads a response with a reader loop and returns the text. It must return "" for a response with no body.

02

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 await is shorter and cancels on break, 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 done and value.

  • I know a chunk is a Uint8Array whose 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 locked and body stream already read.

  • I check for res.body === null and for empty bodies, and I cancel or release in finally.