Web Streams API Explained: Readable, Writable, Transform
The Streams API lets JavaScript process data as chunks arrive instead of buffering it all in memory. How ReadableStream, WritableStream, and pipes work.
The Streams API is a browser standard for reading and writing data incrementally, in chunks, instead of waiting for an entire payload to arrive and load it into memory at once. It gives JavaScript three core building blocks — ReadableStream, WritableStream, and TransformStream — that plug into fetch() bodies, file I/O, and custom data pipelines, and that can be piped together the way shell commands pipe into each other.
Why buffering isn’t always enough
Before the Streams API, most web platform APIs that dealt with data — fetch(), FileReader, Blob — worked in an all-or-nothing fashion. You called a method, waited for the whole response, and got back a complete string, Blob, or ArrayBuffer. That’s fine for a small JSON payload but wasteful for a multi-gigabyte video file, a live server-sent feed, or any case where you want to start processing before the transfer finishes.
Streaming fixes this by exposing data as a sequence of chunks that you can consume as they arrive. A fetch() response body, for instance, is a ReadableStream — you can start decoding, parsing, or rendering the first chunk while later chunks are still in transit over the network.
ReadableStream
A ReadableStream represents a source of data you pull chunks from. You get one automatically from response.body after a fetch() call, or you can construct one yourself by supplying a start()/pull() controller:
const stream = new ReadableStream({
start(controller) {
controller.enqueue("first chunk");
controller.enqueue("second chunk");
controller.close();
},
});
const reader = stream.getReader();
const { value, done } = await reader.read();
Each call to reader.read() returns a promise that resolves with the next chunk and a done flag. This pull-based model composes naturally with async/await — see our guide on async/await vs. promises for the underlying mechanics.
Modern browsers also let you skip the manual reader.read() loop entirely and iterate a ReadableStream with for await...of, which handles the read-and-check-done bookkeeping for you and reads naturally next to any other asynchronous iteration in your codebase. If you need to consume the same stream twice — logging it while also parsing it, say — stream.tee() splits one readable stream into two independent readable streams over the same underlying data, each with its own reader.
WritableStream
A WritableStream is the mirror image: a sink you push chunks into. It’s useful for anything that consumes data incrementally — writing to a file handle, uploading in chunks, or feeding a custom encoder:
const writable = new WritableStream({
write(chunk) {
console.log("received:", chunk);
},
});
const writer = writable.getWriter();
await writer.write("hello");
await writer.close();
Backpressure is built in: writer.write() returns a promise that only resolves once the sink is ready for more data, so a fast producer can’t overwhelm a slow consumer. That’s the same problem Node’s stream implementation solves on the server side — see what backpressure is for the general concept, and Node.js streams explained for how the server-side API differs from the browser one.
TransformStream and piping
A TransformStream sits between a readable and a writable side, transforming each chunk as it passes through — decompression, text decoding, or a custom parser. The real power shows up when you chain streams together with .pipeThrough() and .pipeTo():
const response = await fetch("/large-file.txt");
const decompressed = response.body
.pipeThrough(new DecompressionStream("gzip"))
.pipeThrough(new TextDecoderStream());
for await (const chunk of decompressed) {
process(chunk);
}
This reads almost exactly like a Unix pipeline: data flows from the network response, through decompression, through text decoding, into your processing loop — with backpressure propagated automatically at every stage.
Streams vs. buffered APIs
Buffered (.text(), .json(), .blob()) | Streams (.body, ReadableStream) | |
|---|---|---|
| Memory use | Whole payload held at once | Only the current chunk(s) |
| First byte to first use | Waits for full transfer | Can start immediately |
| Backpressure | Not applicable | Built in |
| Cancellation | Limited (via AbortController) | Native .cancel() on the stream |
| Best for | Small, complete payloads | Large or long-lived payloads |
For most fetch() calls returning small JSON responses, the buffered convenience methods are simpler and perfectly fine. Reach for streaming when the payload is large, unbounded (a live feed), or when you want to start rendering before the transfer completes.
Cancellation and cleanup
Streams support cancellation natively: calling reader.cancel() or stream.cancel() signals upstream that no more data is needed, which a well-behaved source (like a fetch() response) uses to abort the underlying network request. This pairs naturally with AbortController, which many streaming APIs accept as a signal option to tie stream cancellation to a broader operation, like a user navigating away mid-download.
Where streams show up in practice
- Fetch response bodies — process a large download chunk by chunk instead of buffering it whole.
- File uploads — read a local file as a stream and upload it incrementally, useful for resumable or progress-tracked uploads.
- Compression —
CompressionStreamandDecompressionStreamwrap gzip/deflate directly, no library required. - Service workers — construct a synthetic
Responsefrom aReadableStreamto stream generated or proxied content. - Server-sent data — while server-sent events have their own dedicated API, a raw streaming
fetch()response is a lighter-weight alternative for one-directional data that doesn’t need the SSE protocol’s framing.
The takeaway
The Streams API turns data transfer from an all-or-nothing operation into a composable pipeline of chunks, with backpressure handled for you. Use ReadableStream to consume incremental data, WritableStream to produce it, and TransformStream with .pipeThrough() to build processing pipelines — reaching for the buffered fetch() convenience methods only when the payload is small enough that streaming wouldn’t buy you anything.
Keep reading
Takina · · 4 min read npm and pnpm Workspaces: Managing a Monorepo
Workspaces let npm and pnpm manage multiple packages in one repository, sharing a single dependency tree and letting packages reference each other locally.
Takina · · 5 min read toSorted, toReversed, with(): JS's New Array Methods
toSorted(), toReversed(), toSpliced(), and with() copy an array instead of mutating it, fixing a long-standing footgun in JavaScript's Array API.
Takina · · 3 min read JavaScript Array.sort() Pitfalls (and How to Fix Them)
Array.sort() converts elements to strings by default, so numbers sort out of order. Here's why, and how a compare function fixes it.