JavaScript Streams Table
| Piece | What it does | Field note |
|---|---|---|
response.body | A ReadableStream | fetch responses ARE streams - process chunks as they arrive |
reader.read() | Pull a chunk | { value, done } loop - value is a Uint8Array, done ends it |
getReader() | Lock the stream | One reader at a time - the lock is the flow-control contract |
TextDecoder stream | Decode chunked text | decode(v, { stream: true }) across chunk boundaries |
pipeThrough / pipeTo | Wire streams together | Backpressure handled FOR you - the composition primitive |
TransformStream | A processing stage | Map/filter for byte flows - gzip, line-split, parse |
backpressure | The flow contract | Slow consumer signals producer - queues do not grow unbounded |
new Response(stream) | Streams OUT too | Service workers and servers stream responses the same way |
Streams turn 'wait for the whole payload' into 'process as it arrives': response.body is a ReadableStream, chunks arrive as Uint8Array pieces, and the first part of your data renders while the rest is still downloading. Every large-payload feature on the modern web - progressive video, streaming AI responses, big file handling - is this API.
Bottom line: the organizing concept is BACKPRESSURE - a slow consumer signals the producer to pause, so memory stays bounded even on a 10GB download. pipeThrough and pipeTo wire stages together with backpressure handled for you; the manual read() loop is where you opt into managing it yourself.
The honest part: streams compose but do not rewind. A consumed chunk is gone - no random access, no re-read. Code that needs the whole payload anyway (JSON.parse of a complete body) gains nothing from streams; they pay off when processing is genuinely incremental: line-by-line logs, chunked media, progressive parsing.
How to use
- Process a fetch incrementally: for await (const chunk of response.body) - async iteration over chunks, first-byte processing while the download continues.
- Decode text across chunks: decoder.decode(value, { stream: true }) - multi-byte characters split between chunks only survive with the stream flag.
- Compose instead of hand-rolling: readable.pipeThrough(new TransformStream({ transform })).pipeTo(writable) - stages with backpressure wired for you.
Frequently asked questions
What is backpressure and why does it make streams safe for huge payloads?
It is the flow contract between producer and consumer. Without it, reading a 10GB file means the producer fires chunks as fast as disks allow while a slow consumer (UI work, network writes) falls behind - the queue in memory grows until the tab dies. With backpressure, the consumer PULLS: read() asks for the next chunk only when ready, and pipeTo propagates that pause signal upstream so the source (disk, network) legitimately slows down. Memory stays bounded by chunk size, not payload size. This is the entire reason streams exist as an API rather than just 'events with bytes'.
Why does TextDecoder need { stream: true } for chunked text?
Because characters cross chunk boundaries. UTF-8 encodes some characters in multiple bytes, and a chunk edge can split one down the middle - decoding each chunk independently turns the split character into garbage (the replacement character). The stream flag tells the decoder to hold trailing incomplete bytes and carry them into the next decode call. The same pattern applies to any chunked PARSING: JSON objects split across chunks, lines split across chunks - either buffer to a boundary (line splitting) or use an incremental parser. The bug shape is always 'works on small payloads, corrupts on large ones'.
When do pipeThrough and pipeTo beat a manual read loop?
Whenever the processing is a pipeline. A manual loop means you own backpressure, error propagation, cancellation and cleanup for every stage - plumbing that dominates the actual logic. pipeTo(writable) wires source to sink with all four handled; pipeThrough(transform) inserts a processing stage (decompress, split lines, parse) and returns the readable for the next hop. Go manual when you need custom control between chunks - updating a progress bar per chunk, deciding to abort mid-stream based on content - that is what the read() loop's lock is for. The rule: pipelines compose, bespoke logic loops.
How do you stream a response back OUT, not just consume one in?
The same interface runs in reverse: a ReadableStream is a valid Response body. A service worker can answer a fetch with new Response(generatedStream) - the browser renders chunks as produced (the trick behind streaming SSR and fake-typing effects). The construction pattern is a ReadableStream with a start or pull callback pushing encoded chunks, or more readably, an async generator piped through: new Response(generatorToStream(async function* () { yield chunk; })). The same primitive powers servers and workers - once data flows as streams, the direction is symmetric and the composition is identical.