"Node has two stream ecosystems coexisting in 2026. Knowing how to convert between them is mostly what the runtime gives you for free."
Why Web Streams Are Here
Node Streams predate Web Streams by a decade. When the W3C / WHATWG specified Web Streams for browsers, Node had two choices: ignore them (and become incompatible) or implement them (and have two stream systems). Node chose to implement. The native fetch() in Node, response bodies, file-system blob.stream(), and the modern Web Crypto APIs all use Web Streams. Node's own ecosystem still leans on Node Streams.
The interfaces:
ReadableStream— analogous to Node's ReadableWritableStream— analogous to WritableTransformStream— analogous to Transform
Different method names, similar concepts. The good news: Node provides converters and both implement [Symbol.asyncIterator].
The Async Iterator Bridge
Every Web ReadableStream in Node 20+ supports for await...of directly:
const res = await fetch('https://api.example.com/large.json');
// res.body is a Web ReadableStream
for await (const chunk of res.body) {
console.log('got', chunk.byteLength, 'bytes');
}
Same syntax as a Node Readable. The runtime handles the protocol differences. This is the most common path you'll touch — consuming fetch responses chunk by chunk.
Converting Between Worlds
Readable.toWeb(nodeReadable)→ReadableStreamReadable.fromWeb(webReadable)→ Node ReadableWritable.toWeb(nodeWritable)→WritableStreamWritable.fromWeb(webWritable)→ Node Writable
zlib.createGzip() is a Node Transform, but response.body is a Web ReadableStream. Convert one of them.Pipelines Across Both Worlds
stream.pipeline in Node 22+ accepts Web Streams as stages. Mixing the two is no longer an explicit convert step:
import { pipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
const res = await fetch('https://api.example.com/large.json');
// res.body (Web), createGzip() (Node), createWriteStream() (Node)
// pipeline accepts all of them in one chain
await pipeline(
res.body,
createGzip(),
createWriteStream('cached.json.gz')
);
The runtime quietly wraps the Web ReadableStream with the right adapter. You just pipe things.
When to Reach for Which
Default to Node Streams in Node code — the entire ecosystem (fs, http, zlib, crypto, child_process) speaks Node Streams natively. Reach for Web Streams when:
- The API you're calling returns one (fetch responses, blob.stream, modern Web Crypto).
- You're writing code that needs to be cross-runtime portable (Cloudflare Workers, Deno, Bun all speak Web Streams natively).
- You're building APIs the browser will also consume.
TransformStream — Web's Answer to Transform
Web's TransformStream is cleaner to construct than Node's Transform:
const upper = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toString('utf-8').toUpperCase());
},
});
// Use it in a Web-streams pipeline
await someReadableStream
.pipeThrough(upper)
.pipeTo(someWritableStream);
No cb parameter, no this.push; you call controller.enqueue for output. The API is more uniform with pipeTo/pipeThrough instead of Node's .pipe(). If you write code that needs to also run in browsers or Workers, default to TransformStream.
Pippa's Confession
fetch and processed the response body, I was in Web Streams territory whether I noticed or not. Dad pointed out: "The runtime's job is to make this work. Your job is to know which surface you're on so you can read the docs of the right API." Now I check: am I getting a ReadableStream or a Readable? The docs are different; the methods are different; the converters exist; use them.