The Streams API
Say you need to download a 2 GB video, run every byte through a checksum, and save it to disk. The naive version reads the whole file into a variable, then loops over it. On a phone with 1 GB of free memory, that program is dead before it starts.
Streams fix this by refusing to hold the whole thing at once. Data arrives in small chunks, you handle each chunk as it lands, and the finished pieces are free to be garbage-collected while the rest is still on the wire. Memory stays flat no matter how big the payload is.
This is the model behind the Streams API: a set of built-in objects for producing, transforming, and consuming data incrementally. It’s baked into fetch, into compression, into text decoding, and you can build your own pipelines on top.
Three kinds of stream
The whole API is three objects that snap together.
ReadableStream— a source. You read chunks out of it. Afetchresponse body is one.WritableStream— a sink. You write chunks into it. Its job is to swallow data and put it somewhere (a file, a socket, the screen).TransformStream— a pipe segment. Data goes in one end, changed data comes out the other. Compression and text decoding are transforms.
Everything else — reading, writing, piping, backpressure — is how these three cooperate. Let’s take them in order.
Reading: ReadableStream
The most common readable stream you’ll meet is response.body from a fetch call. It’s a ReadableStream of Uint8Array chunks — the raw bytes as they arrive off the network.
To pull chunks out, get a reader and call read() in a loop:
const response = await fetch('/big-file.bin');
const reader = response.body.getReader();
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break; // stream is exhausted
received += value.length; // value is a Uint8Array chunk
console.log(`got ${value.length} bytes, ${received} total`);
}
Each read() returns a promise for an object with two fields:
value— the next chunk (here aUint8Array), orundefinedwhen the stream is finished.done—falsewhile chunks remain,trueonce there are no more.
A reader locks the stream. While a reader is active, nothing else can read from that stream — an exclusive claim that keeps two consumers from stealing each other’s chunks. Call reader.releaseLock() when you’re done and want to hand the stream to someone else, or reader.cancel() to tell the source you’ve lost interest (it stops producing and frees resources).
Building a readable stream by hand
You rarely construct one from scratch, but seeing it demystifies the rest. You pass an underlying source object with up to three methods:
const numbers = new ReadableStream({
start(controller) {
// called once, when the stream is set up
controller.enqueue('one');
controller.enqueue('two');
},
pull(controller) {
// called whenever the internal queue wants more
controller.enqueue('three');
controller.close(); // no more data
},
cancel(reason) {
// called if the consumer bails out early
}
});
startruns once so you can seed the queue or open a connection.pullis the backpressure hook — the stream calls it only when it has room, asking you toenqueuemore. This is the lever that lets a slow consumer throttle a fast producer, which we’ll get to.cancellets you clean up if the reader gives up.
controller.enqueue(chunk) adds a chunk, controller.close() ends the stream, controller.error(e) tears it down with an error.
Writing: WritableStream
A writable stream is the mirror image. Instead of reading chunks out, you write chunks in, and an underlying sink decides where they go.
const log = new WritableStream({
write(chunk) {
// called for each chunk written
console.log('sink received:', chunk);
},
close() { console.log('done'); },
abort(err) { console.error('aborted', err); }
});
const writer = log.getWriter();
await writer.write('a');
await writer.write('b');
await writer.close();
Like readers, a writer locks the stream — getWriter() gives you exclusive write access. The important detail is what write() returns: a promise that you can await. That promise is the sink’s way of saying “I’ve got room for the next one” — and that’s backpressure again, this time flowing the other direction.
Transforming: TransformStream
A transform is the piece that makes streams composable. It is literally a writable side wired to a readable side, with your code in the middle: whatever you write into its writable gets reshaped and appears on its readable.
You build one with a transformer object. Here’s a transform that upper-cases text chunks:
const upper = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
}
});
transform(chunk, controller) runs for every chunk. Call controller.enqueue as many times as you like — zero to split things away, once to map, many to fan out. An optional flush(controller) runs at the very end, which is where a transform that batches or buffers (say, splitting a byte stream into lines) emits whatever it was holding back.
The two useful properties are upper.writable (write into this) and upper.readable (read out of this). You almost never touch them directly, because piping does it for you.
Piping: connecting the pieces
Reading in a while loop and manually writing to a sink works, but it’s a lot of plumbing. Piping wires streams together and handles the loop, the backpressure, and error propagation for you.
Two methods do it, both on ReadableStream:
readable.pipeThrough(transform)connects a readable to a transform and returns the transform’s readable side, so you can keep chaining.readable.pipeTo(writable)connects a readable to a final sink and returns a promise that resolves when everything has flowed through and closed.
In code, that whole chain reads top-to-bottom:
const response = await fetch('/data.json.gz');
await response.body
.pipeThrough(new DecompressionStream('gzip'))
.pipeThrough(new TextDecoderStream())
.pipeTo(new WritableStream({
write(textChunk) {
console.log('decoded piece:', textChunk);
}
}));
console.log('all done');
Bytes come off the network, get gunzipped, get decoded to text, and land in your sink — all chunk by chunk, never fully in memory. DecompressionStream and TextDecoderStream are transforms the platform ships for you; you supplied only the final sink.
Backpressure: the reason streams are worth it
Here’s the problem streams quietly solve. Your source (the network) can hand you bytes faster than your sink (writing to slow disk, or a busy UI) can absorb them. Without a brake, chunks pile up in memory and you’re back to buffering the whole thing — the exact disaster you were avoiding.
Backpressure is the brake. Every stream has a small internal queue. When the queue fills past a threshold, the stream signals upstream: stop sending, I’m full. The source’s pull stops being called. When the consumer drains the queue, the signal releases and data flows again. The whole chain self-regulates to the speed of its slowest link.
The number driving all of this is desiredSize, computed from a queuing strategy:
desiredSize = highWaterMark − (total size of queued chunks)
highWaterMarkis how much the queue wants to hold before it considers itself full.- When
desiredSizeis positive, the stream asks the source for more (pullfires). - When it hits zero or goes negative, the source is expected to stop.
You set the strategy when you construct a stream. Two built-ins cover most cases:
// count chunks: full when 3 chunks are queued, regardless of size
new ReadableStream(source, new CountQueuingStrategy({ highWaterMark: 3 }));
// count bytes: full when queued chunks total 64 KB
new ReadableStream(source, new ByteLengthQueuingStrategy({ highWaterMark: 64 * 1024 }));
CountQueuingStrategy measures the queue in number of chunks; ByteLengthQueuingStrategy measures it in bytes (using each chunk’s byteLength). For byte streams — network data, files — byte length is the honest measure, since one chunk might be 1 byte and the next 40 KB.
Here’s how the signal travels through the pull hook when you build a source. The stream only calls pull when there’s room, so simply enqueuing one chunk per pull gives you a well-behaved, backpressure-aware source for free:
Where streams already live
You don’t have to invent uses for this — the platform is full of streams already, and knowing they’re streams unlocks the whole toolkit.
fetch response bodies. response.body is a ReadableStream. When you call response.json() or response.text(), the browser is quietly reading that stream to the end and buffering it for you. Reaching for .body directly is how you process a response before it has fully arrived — showing download progress, parsing NDJSON line by line, rendering a stream of tokens from a model as they come.
Request bodies. You can pass a ReadableStream as the body of a fetch to upload without buffering — but this one has real caveats. You must set duplex: 'half' on the request, it requires HTTPS over HTTP/2, and as of mid-2026 it’s effectively Chromium-only; Firefox and Safari don’t ship it. Treat it as progressive enhancement, not a baseline feature.
await fetch('/upload', {
method: 'POST',
body: someReadableStream,
duplex: 'half', // required whenever body is a stream
});
Compression. CompressionStream and DecompressionStream gzip/deflate data on the fly as transforms. These arrived across all three engines relatively recently, so for the broadest reach, feature-detect before relying on them.
Text decoding. TextDecoderStream turns a stream of bytes into a stream of strings, correctly handling multibyte characters that straddle a chunk boundary — a real hazard when a single emoji’s bytes get split across two network packets. (For the non-streaming, buffer-at-once version, see TextDecoder.)
A worked pipeline: progress without buffering
Let’s combine the ideas. Download a file, report progress as it arrives, and never hold more than a chunk at a time. This is the pattern behind a real download bar.
async function downloadWithProgress(url, onProgress) {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const total = Number(response.headers.get('Content-Length')) || 0;
const reader = response.body.getReader();
const chunks = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
if (total) onProgress(received / total); // 0 → 1
}
return new Blob(chunks); // reassemble only at the end, if you must
}
await downloadWithProgress('/video.mp4', p =>
console.log(`${Math.round(p * 100)}%`)
);
Notice the shape: you read, you update progress, you keep going. The Content-Length header gives you the denominator; each chunk’s length moves the numerator. If you’re piping straight to disk or a decoder you wouldn’t even collect chunks — that array is only there because the example ends with a Blob. (For a deeper treatment of the progress case specifically, see fetch download progress.)
Gotchas worth internalizing
A few things trip people up once they leave the happy path:
- Locks are exclusive and sticky. Once you
getReader(), the stream is locked until youreleaseLock(). Trying topipeToa locked stream throws. Read or pipe, not both. - A stream is single-use. You can consume a
ReadableStreamonce. After it’s read to the end (or you called.json()on the response), it’s spent. Usetee()if you need two passes. - Chunks aren’t lines or records.
response.bodychunks fall on arbitrary byte boundaries — a JSON object or a UTF-8 character can be split across two chunks. If you’re parsing structure, buffer across chunk edges (aTransformStreamwith aflushis the clean way) rather than assuming one chunk equals one record. - Errors travel down the pipe. An error at the source rejects the
pipeTopromise at the far end. Don’t scatter try/catch on every segment; catch once where youawaitthe pipe. transformcan be async. Return a promise fromtransformand the stream waits for it before the next chunk — a natural place to do async work per chunk while backpressure holds the line.
Where this stands
The core Streams API — ReadableStream, WritableStream, TransformStream, pipeThrough, pipeTo, tee, the queuing strategies — is a stable WHATWG standard and Baseline: widely available across current Chrome, Edge, Firefox, and Safari, and present in Node.js and Deno as stream/web. You can lean on it in production today.
The edges are younger. Async iteration over a readable stream, streaming fetch request bodies, and the compression streams each shipped later than the core and have thinner support — feature-detect or provide a fallback for those specifically. Everything in the worked examples above uses the well-supported core.
Summary
- Streams process data incrementally in chunks, so memory stays flat regardless of payload size — the alternative is buffering the whole thing at once.
ReadableStreamis a source:getReader()then loop onread()for{ value, done }, orfor awaitover it on modern engines. Build one with an underlying source’sstart/pull/cancel.WritableStreamis a sink:getWriter(), thenwrite()/close(). Awaitingwrite()(orwriter.ready) is the backpressure signal in the write direction.TransformStreamis a writable+readable pair with yourtransform(chunk, controller)in between — the composable middle of a pipeline.pipeThrough(transform)chains and returns a readable;pipeTo(sink)ends the chain and returns a completion promise. Piping wires up backpressure and error propagation for you.- Backpressure self-throttles the pipeline to its slowest link via
desiredSize = highWaterMark − queued, set throughCountQueuingStrategyorByteLengthQueuingStrategy. - Streams are already everywhere:
fetchresponse.body,DecompressionStream,TextDecoderStream. The core is Baseline; async iteration, streaming request bodies, and compression streams are newer — feature-detect those.