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.

Buffer everythingone giant blob in memory2 GB held all at onceStream in chunkschunkchunkchunk→ handle → drop →only a chunk or two live at any moment
Buffering loads the entire payload before you touch it; streaming hands you one chunk at a time and lets finished chunks go.

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. A fetch response 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.
ReadableStreamsource · read()TransformStreamreshape each chunkWritableStreamsink · write()chunks flow left to right; only what fits in the small queues is ever in flight
A source produces chunks, a sink consumes them, and a transform sits in the middle reshaping each chunk as it passes.

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 a Uint8Array), or undefined when the stream is finished.
  • donefalse while chunks remain, true once there are no more.
reader.read() — one call per chunk{ value: A,done: false }{ value: B,done: false }{ value: C,done: false }{ value: undefined,done: true }↓ loopwhile (!done) { handle(value) }
Each read() resolves with a value and a done flag. You loop until done flips to true; the reader holds an exclusive lock while you do.

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
  }
});
  • start runs once so you can seed the queue or open a connection.
  • pull is the backpressure hook — the stream calls it only when it has room, asking you to enqueue more. This is the lever that lets a slow consumer throttle a fast producer, which we’ll get to.
  • cancel lets 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.

TransformStreamwritableyou write heretransform(chunk)reshape + enqueuereadablechunks come outin →→ outone chunk in can produce many out, or none — you control what enqueue emits
A TransformStream is a writable/readable pair. Chunks written in are passed to transform(), which enqueues zero or more chunks out the readable side.

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.
response.bodybytesDecompression.pipeThroughTextDecoder.pipeThroughsink.pipeToUint8ArrayUint8Arraystringeach arrow is a small queue; a stall anywhere ripples back to the source
pipeThrough returns a readable, so it chains; pipeTo ends the chain at a sink and returns a promise for completion.

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.

fast sourcesourcevalve (pull)slow sink · queuedesiredSize = highWaterMark − queueddesiredSize > 0 → keep pullingdesiredSize ≤ 0 → pause the sourcethe valve reopens as the sink drains the queue
When the sink can't keep up, its queue fills, desiredSize drops to zero or below, and the source is told to pause — like a valve closing upstream.

The number driving all of this is desiredSize, computed from a queuing strategy:

desiredSize = highWaterMark − (total size of queued chunks)
  • highWaterMark is how much the queue wants to hold before it considers itself full.
  • When desiredSize is positive, the stream asks the source for more (pull fires).
  • 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:

A pull source respecting backpressure
1/5
Variables
desiredSize=2
start() runs once. desiredSize = highWaterMark(2) − 0 queued = 2, so the stream wants chunks.

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 you releaseLock(). Trying to pipeTo a locked stream throws. Read or pipe, not both.
  • A stream is single-use. You can consume a ReadableStream once. After it’s read to the end (or you called .json() on the response), it’s spent. Use tee() if you need two passes.
  • Chunks aren’t lines or records. response.body chunks 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 (a TransformStream with a flush is the clean way) rather than assuming one chunk equals one record.
  • Errors travel down the pipe. An error at the source rejects the pipeTo promise at the far end. Don’t scatter try/catch on every segment; catch once where you await the pipe.
  • transform can be async. Return a promise from transform and 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.
  • ReadableStream is a source: getReader() then loop on read() for { value, done }, or for await over it on modern engines. Build one with an underlying source’s start / pull / cancel.
  • WritableStream is a sink: getWriter(), then write() / close(). Awaiting write() (or writer.ready) is the backpressure signal in the write direction.
  • TransformStream is a writable+readable pair with your transform(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 through CountQueuingStrategy or ByteLengthQueuingStrategy.
  • Streams are already everywhere: fetch response.body, DecompressionStream, TextDecoderStream. The core is Baseline; async iteration, streaming request bodies, and compression streams are newer — feature-detect those.