JavaScript Streams API: ReadableStream, WritableStream, TransformStream
The Web Streams API is the modern, cross-platform way to handle chunks of data that arrive (or leave) over time. It's what powers response.body in fetch, what file inputs use to stream uploads, what Node.js streams interop with, and what AI APIs hand back for token-by-token completions.
This lesson covers the three core types — ReadableStream, WritableStream, TransformStream — the consumer patterns, async-iteration interop, and the practical idioms for piping data from one place to another.
What a stream is #
A stream is an abstraction over chunks of data that arrive (or are sent) over time, not all at once. Think "a file being downloaded" rather than "a file already in memory."
Three shapes:
ReadableStream— you consume from it (data flows out).WritableStream— you push into it (data flows in).TransformStream— a pair: data flows in, gets transformed, flows out.
Streams have built-in backpressure — if the consumer is slow, the producer pauses automatically. This is what makes them well-suited to networking and file I/O where rates are unpredictable.
ReadableStream #
The most-used type. Many APIs hand you one:
const res = await fetch('/api/users');
const stream = res.body; // ReadableStream<Uint8Array>
Consuming via for await...of #
In modern browsers and Node 18+, ReadableStream is async-iterable:
for await (const chunk of stream) {
console.log(chunk); // Uint8Array of bytes
}
Combined with TextDecoderStream to convert bytes to strings:
for await (const text of stream.pipeThrough(new TextDecoderStream())) {
console.log(text); // string
}
Consuming via reader #
The lower-level API:
const reader = stream.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
console.log(value);
}
reader.releaseLock();
Useful when you need fine-grained control (cancellation, manual buffering). For app code, for await...of is cleaner.
Cancellation #
stream.cancel('user navigated away');
Signals the source to stop producing. Used internally by fetch when an AbortSignal fires.
Creating a ReadableStream #
A stream you author:
const stream = new ReadableStream({
start(controller) {
controller.enqueue('hello ');
controller.enqueue('world');
controller.close();
},
});
The controller methods: enqueue(chunk) adds a chunk, close() ends the stream, error(err) signals failure.
For async sources, use the pull(controller) method instead of start — it's called whenever the consumer is ready for more:
let i = 0;
const countingStream = new ReadableStream({
async pull(controller) {
if (i >= 10) {
controller.close();
return;
}
await new Promise(r => setTimeout(r, 100));
controller.enqueue(i++);
},
});
The stream produces 0, 1, 2… with 100ms gaps, but only as fast as the consumer asks for them.
From an async generator #
The modern shortcut — ReadableStream.from(asyncIterable):
async function* count() {
for (let i = 0; i < 10; i++) {
yield i;
await new Promise(r => setTimeout(r, 100));
}
}
const stream = ReadableStream.from(count());
No controller boilerplate. Any async generator becomes a stream.
WritableStream #
The sink side. You write data; it ends up somewhere (network, file, processing pipeline).
const writer = somewhere.getWriter();
await writer.write('hello');
await writer.write(' world');
await writer.close();
writer.releaseLock();
Creating one:
const saver = new WritableStream({
write(chunk) {
console.log('saving:', chunk);
// could be async — return a Promise to apply backpressure
},
close() {
console.log('done');
},
abort(reason) {
console.log('aborted:', reason);
},
});
const writer = saver.getWriter();
await writer.write('hi');
await writer.close();
WritableStreams are less common in application code than Readable ones — usually you'd write to a Response, a file, or pipe through a transform.
TransformStream #
A pair of streams — a writable side and a readable side, with a function in the middle.
const upper = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
},
});
await ReadableStream.from(['hello', 'world'])
.pipeThrough(upper)
.pipeTo(new WritableStream({
write(chunk) { console.log(chunk); } // 'HELLO', 'WORLD'
}));
Built-in transforms you'll meet:
TextDecoderStream— bytes → strings (you'll use this with fetch bodies daily)TextEncoderStream— strings → bytesCompressionStream/DecompressionStream— gzip, deflate (browser-side compression!)
// Compress text on the fly
const compressed = ReadableStream.from(['hello world repeated lots of times'])
.pipeThrough(new TextEncoderStream())
.pipeThrough(new CompressionStream('gzip'));
const chunks = [];
for await (const chunk of compressed) chunks.push(chunk);
console.log('Total bytes:', chunks.reduce((s, c) => s + c.length, 0));
Piping #
Three pipe operations:
readable.pipeTo(writable); // connect read → write, returns a Promise
readable.pipeThrough(transform); // returns a new readable (transform's readable side)
readable.tee(); // splits into two identical readables
pipeTo #
The terminal connection. Returns a Promise that resolves when the data has flowed end-to-end:
const fileSink = new WritableStream({
write(chunk) { /* save to disk */ },
});
await fetch('/api/big-export').then(res => res.body.pipeTo(fileSink));
Backpressure is handled automatically. The pipe takes care of the read-write coordination.
pipeThrough #
For chaining transforms:
await fetch('/api/data')
.then(res =>
res.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(new TransformStream({
transform(chunk, controller) {
for (const line of chunk.split('\n')) controller.enqueue(line);
},
}))
.pipeTo(new WritableStream({
write(line) { console.log('line:', line); },
}))
);
Four stages in one pipeline: HTTP → bytes → strings → lines → log.
tee #
Split one stream into two:
const [a, b] = response.body.tee();
// Consume `a` for display, `b` to save to file simultaneously
Useful when you need to use the same data for two things (display AND cache, e.g.).
Backpressure in detail #
The core feature streams give you over raw async iteration: explicit, configurable backpressure.
Every readable has a highWaterMark — how many chunks can buffer before the producer is asked to pause. The default is 1 for byte streams, more for object streams.
const stream = new ReadableStream({
pull(controller) { /* ... */ },
}, { highWaterMark: 10 }); // buffer up to 10 chunks
When the buffer is full, pull isn't called again until the consumer drains it. When you await writer.write(chunk) on a writable, the Promise only resolves once the writable has accepted the data — providing the same throttling.
For most code, you don't tune highWaterMark. Knowing it's there explains why streams "just work" under varying speeds.
When to use streams vs alternatives #
| Need | Use |
|---|---|
| Process a fetch body chunk by chunk | response.body (ReadableStream) |
| Process an AI completion token stream | ReadableStream + TextDecoderStream + line parser |
| Stream a file upload | body: stream on fetch() |
| Read a Node file line by line | createReadStream + readline (async-iterable) |
| Send a server response chunk by chunk | new Response(stream) |
| Compress data in the browser | CompressionStream |
| Process a queue with backpressure | An async generator (Lesson 10.2), or a stream if interop matters |
For pure in-process producer-consumer loops, async generators are usually simpler. For anything that crosses runtime boundaries (network, file, worker), Streams are the lingua franca.
Browser vs Node interop #
The Web Streams API is now the shared standard between browser and Node 18+. Same ReadableStream, same methods, same piping. Older Node has its own stream module (Readable, Writable, Duplex) — that's a separate, older API. Modern code uses Web Streams everywhere; Node provides adapters (Readable.fromWeb(), Readable.toWeb()) where needed.
A summary #
- Three types:
ReadableStream(consume),WritableStream(sink),TransformStream(in→transform→out). for await ofis the easiest way to consume a Readable.pipeToterminates a pipeline.pipeThroughchains transforms.tee()splits a stream so two consumers can share it.- Backpressure is built-in via
highWaterMarkand the promise-based read/write. ReadableStream.from(asyncIterable)is the shortcut to create a stream from a generator.- Web Streams are now standard in both browser and Node 18+.
What's next #
Lesson 10.4 covers Web Workers — running JavaScript on a separate thread, the postMessage interface, structured cloning, and when offloading work to a worker actually helps.
Try it yourself #
The pipeThrough chain is the easiest way to feel the composition power. Predict the output:
const reverse = new TransformStream({
transform(chunk, controller) { controller.enqueue([...chunk].reverse().join('')); }
});
const upper = new TransformStream({
transform(chunk, controller) { controller.enqueue(chunk.toUpperCase()); }
});
const out = [];
await ReadableStream.from(['hello', 'world'])
.pipeThrough(reverse)
.pipeThrough(upper)
.pipeTo(new WritableStream({ write(c) { out.push(c); } }));
console.log(out);js_sandboxOutput: ['OLLEH', 'DLROW'].Each chunk flowed
'hello' → 'olleh' → 'OLLEH'. Each TransformStream applied its function to each chunk, in order. pipeTo awaits the whole pipeline to finish.That’s the entire mental model: chunks flow left to right through transforms, and the writable at the end collects the results.
Once streams compose this cleanly, plumbing data through your code stops being the bottleneck.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


