Streams and large responses
ReadableStream and fetch streaming — when streaming earns its keep.
You've been writing await response.json() for the whole course. That call quietly waits for the entire body to arrive before it gives you anything. For a 2 KB JSON response that's fine. For a 200 MB log file or a model that's being generated word-by-word, it's a problem — the user sees nothing for ten seconds, then everything at once. Streams let you start using the response while it's still arriving.
A stream is one of those concepts that sounds advanced and is actually small once you see the shape. It's a queue with a producer on one end and a consumer on the other.
What a stream actually is
A ReadableStream is a sequence of chunks that arrive over time. Bytes come in (from the network, a file, another stream), and a reader pulls them out one chunk at a time. There's no array of all the chunks — when you've read one, the next one might not exist yet.
Think of a conveyor belt: a chunk rolls past, you pick it up, you wait for the next one. If you stop pulling, the producer eventually pauses (this is "backpressure" — the queue between them isn't infinite).
response.body is a ReadableStream of bytes. When you call response.json(), the browser is reading that stream all the way to the end and JSON.parse-ing the result for you. If you want the chunks instead, you ignore .json() and read the stream yourself.
Reading a stream
To consume a stream, ask for a reader and call .read() in a loop. Each read() returns a promise that resolves to { value, done }.
const response = await fetch("/api/big-file");
const reader = response.body.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
console.log("got", value.byteLength, "bytes");
/* value is a Uint8Array — raw bytes */
}value is a Uint8Array — a typed array of byte values from 0 to 255. done flips to true when the stream is finished and value is undefined.
The pattern is while/break because the loop has no count to iterate over. You don't know how many chunks there will be; you only know "another one or finished".
Calling response.body.getReader() "locks" the body — you can't call response.json() or response.text() on the same response any more. Streams have one consumer at a time. If you need a backup, response.clone() first.
Decoding bytes into text
Raw bytes are rarely what you want. For a text response, you decode them with TextDecoder, which knows how to turn UTF-8 (or any other encoding) into a JavaScript string.
const response = await fetch("/api/log");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let text = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
/* render the partial text — user sees it growing */
output.textContent = text;
}
text += decoder.decode(); /* flush the final bytes */Two important details in this snippet:
{ stream: true }tells the decoder "more bytes might be coming". Without it, the decoder would corrupt characters that span chunk boundaries (a single emoji is four bytes; if the chunk boundary falls inside it, you'd see two garbage characters instead of one cat).- The empty
decoder.decode()at the end flushes any trailing state. Always call it once after the loop.
This is the shape of "streaming text into the UI as it arrives" — a chat reply being typed in real time, an LLM response, a long log file.
pipeThrough and pipeTo
Streams can be chained. pipeThrough(transform) plugs a transform stream into the pipeline; pipeTo(destination) connects the output to a writable stream and runs the whole thing to completion.
The most useful built-in transform is TextDecoderStream — it does the decoding above for you.
const response = await fetch("/api/log");
const stream = response.body.pipeThrough(new TextDecoderStream());
/* stream is now a stream of strings, not bytes */
const reader = stream.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
output.textContent += value;
}pipeThrough returns a new stream — you can chain it again. A typical pipeline is body → TextDecoderStream → your-transform → writer. The for await...of syntax also works on streams in modern browsers, which is cleaner than the while/break loop:
for await (const chunk of response.body.pipeThrough(new TextDecoderStream())) {
output.textContent += chunk;
}pipeTo is what you use when the destination is a writable stream — saving to disk via the File System Access API, or piping to a WritableStream you wrote.
for await on streams is convenient, but if you throw out of the loop, the underlying stream is cancelled. If you need to keep reading after handling an error, use a manual reader and try/catch around read().
When streaming earns its keep
Streams are not free — the manual reader loop is more code, and you lose the ergonomics of "one variable holding the whole result". Reach for streaming when one of these is true:
- The body is too large to fit comfortably in memory. A 1 GB CSV import, an offline cache fill, a video. Buffering the whole thing as one string is wasteful or impossible.
- The user benefits from seeing partial results. Search results that render as they arrive. Chatbot replies that type out word-by-word. Log viewers.
- You want to start work before the body finishes. Streaming JSON parsers, line-by-line processors, anything that turns "wait then process" into "process while waiting".
For a normal API call returning a few KB of JSON, await response.json() is the right tool. Streaming is a power tool — it earns its keep when the body is big or slow, not for every fetch you write.
Try it yourself
reader.read() resolve to once the stream is finished?response.body.getReader(), calling response.blob() throws "body already used". What's the right fix?