Web Streams & Interop

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-03, and every example below was run on all three lines unless the text says otherwise. The Web Streams API is the WHATWG Streams Standard: the same ReadableStream, WritableStream and TransformStream that browsers, Deno, Bun and edge runtimes expose. Node.js has had its own, older stream API since its first release (see Streams); web streams arrived later and are what fetch() returns as a response body, so any Node.js program that talks HTTP meets both. The API is Stability 2 (Stable) since v21.0.0, and the interoperability methods Readable.toWeb(), Readable.fromWeb() and their Writable and Duplex counterparts have been Stable since v24.0.0 and v22.17.0 (they were experimental before). This page owns the web-stream classes, the bridge between the two worlds and the decision of which to pick; the Node.js classes are covered in Streams and Implementing Custom Streams, and the zlib module that sits behind CompressionStream in Compression with zlib. The running scenario is the Bookshelf application that grows across this section, whose architecture is described in Introduction & Architecture.

The stability and version map

Table 1. APIs covered on this page
API Stability (Node.js 26 docs) Version notes

ReadableStream, WritableStream, TransformStream and their readers, writers and controllers

2 Stable (node:stream/web)

Added in v16.5.0; no longer experimental in v21.0.0; no runtime warning since v18.0.0; exposed as globals since v18.0.0.

ReadableStream.from(iterable)

Stable

Added in v20.6.0.

TextEncoderStream, TextDecoderStream

Stable (globals)

Added in v16.6.0; globals since v18.0.0.

CompressionStream, DecompressionStream

Stable (globals)

Added in v17.0.0; the 'deflate-raw' format since v21.2.0 and v20.12.0; the 'brotli' format since v24.7.0 and v22.20.0.

node:stream/consumers (text(), json(), buffer(), arrayBuffer(), blob(), bytes())

Stable

Added in v16.7.0; bytes() added in v25.6.0 and v24.14.0.

Readable.toWeb(), Readable.fromWeb(), Writable.toWeb(), Writable.fromWeb(), Duplex.toWeb(), Duplex.fromWeb()

Stable

Added in v17.0.0; marked stable in v24.0.0 and v22.17.0; the type: 'bytes' option of Readable.toWeb() was added in v25.4.0 and v24.14.0, and Duplex.toWeb() gained readableType in v25.7.0 (its type option is now a deprecated alias).

stream.pipeline(), stream.finished() and Duplex.from() with web streams

Stable

pipeline() accepts web streams since v19.7.0 and v18.16.0; finished() since v19.5.0 and v18.14.0; Duplex.from() since v19.5.0 and v18.17.0.

filehandle.readableWebStream() (node:fs/promises)

Stable

Added in v17.0.0; stable in v24.0.0 and v22.17.0; always a byte stream since v23.8.0 and v22.15.0.

Two stream APIs, one runtime

Node.js ships two unrelated stream implementations that solve the same problem: moving data piece by piece, with a bounded memory budget and backpressure, instead of loading it all at once. The Node.js API (node:stream) is built on EventEmitter, uses Buffer chunks by default and has .pipe(), 'data' events and callbacks. The web API (node:stream/web, also available as globals) is built on promises and Uint8Array, has no events, and is the one standardised across runtimes.

flowchart LR subgraph node["Node.js streams (node:stream)"] R["Readable"] T["Transform"] W["Writable"] end subgraph web["Web streams (globals, node:stream/web)"] RS["ReadableStream"] TS["TransformStream"] WS["WritableStream"] end R -- "Readable.toWeb()" --> RS RS -- "Readable.fromWeb()" --> R W -- "Writable.toWeb()" --> WS WS -- "Writable.fromWeb()" --> W T -- "Duplex.toWeb()" --> TS TS -- "Duplex.fromWeb()" --> T

The classes are globals, so no import is needed in ES modules or CommonJS; node:stream/web exports the same objects, which the first example checks. Both families fix the same three roles: a source (Readable or ReadableStream), a destination (Writable or WritableStream) and a processing step in the middle (Transform or TransformStream). A web TransformStream is a pair, with a writable half that you write into and a readable half that you read from, rather than one object that is both.

Table 2. Comparison of the two APIs
Aspect Node.js streams (node:stream) Web streams (WHATWG)

Chunks

Buffer or string (or any value in object mode)

Any JavaScript value; byte streams carry Uint8Array

Programming model

Events ('data', 'end', 'error'), callbacks, .pipe()

Promises, readers and writers, pipeTo() and pipeThrough()

Backpressure signal

write() returns false, then 'drain'; highWaterMark in bytes or objects

controller.desiredSize and writer.ready; queuing strategies (highWaterMark plus a size() function)

Error and cleanup helpers

pipeline(), finished(), destroy()

pipeTo() and pipeThrough() propagate errors and cancellation; abort() and cancel()

Where it is native

Node.js: fs, net, http, zlib, child_process and process.stdout are Node streams

fetch() bodies, Blob.stream(), Response, CompressionStream, TextDecoderStream, browsers and other runtimes

Portability

Node.js only (partially emulated elsewhere)

The same code runs in browsers, Deno, Bun and workers

ReadableStream

A ReadableStream represents a source of data. You create one from an underlying source, an object with start(), pull() and cancel() callbacks, and an optional queuing strategy. Chunks that the source enqueues wait in an internal queue; the highWaterMark of the strategy is the size beyond which the stream asks for no more data, and controller.desiredSize tells the source how much room remains. Node.js calls pull() only while the queue is below the mark, which is how a web stream does backpressure without events. The easiest way to build one is ReadableStream.from() with any iterable or async iterable (an array, a generator, an async generator), and the easiest way to consume one is for await.

Create a ReadableStream from an iterable, and from a pull-based source with a high-water mark
console.log(ReadableStream === (await import('node:stream/web')).ReadableStream);

const stream = ReadableStream.from(['Dune', 'Emma', 'Ulysses']);

for await (const title of stream) {
  console.log(title);
}

// A pull-based source: pull() is called only while the queue is below the highWaterMark.
let n = 0;
const counter = new ReadableStream(
  {
    pull(controller) {
      console.log('pull, desiredSize =', controller.desiredSize);
      controller.enqueue(n++);
      if (n === 5) controller.close();
    },
    cancel(reason) {
      console.log('cancelled:', reason);
    },
  },
  { highWaterMark: 2 },
);

const reader = counter.getReader();
console.log(await reader.read());
console.log(await reader.read());
await reader.cancel('enough');
console.log(await reader.closed);
Output on Node.js 26 (24 and 22 print the same, except that they list the keys of each result object in the order value, done)
true
Dune
Emma
Ulysses
pull, desiredSize = 2
{ done: false, value: 0 }
pull, desiredSize = 2
{ done: false, value: 1 }
cancelled: enough
undefined

Three behaviours are specific to the web model and surprise people coming from Node.js streams:

  • Locking. A stream has at most one reader at a time. getReader(), for await, pipeTo() and pipeThrough() all lock it (readableStream.locked becomes true) until releaseLock() or the end of the iteration. Reading a locked stream throws.

  • Early exit cancels. Leaving a for await loop with break, return or throw cancels the stream, which calls the source’s cancel() callback. To keep the stream, iterate stream.values({ preventCancel: true }). Node.js streams, by contrast, have to be destroyed explicitly.

  • Cancel is not close. reader.cancel(reason) tells the source that nobody will read any more, and controller.close() is how the source says it has no more data. The first is the consumer’s signal, the second the producer’s.

Streams are single-consumer by design. To read the same data twice, use stream.tee(), which returns two branches that each receive every chunk (and locks the original). The data is buffered for the slower branch, so a branch that is never read keeps memory growing, which is the web equivalent of an ignored backpressure signal.

Byte streams and BYOB readers

A stream created with type: 'bytes' is a byte stream: its chunks must be ArrayBufferView`s, and besides the default reader it supports a `ReadableStreamBYOBReader ("bring your own buffer"), where the consumer passes the buffer to fill. That avoids allocating a new Uint8Array per read, which is the point on hot paths such as parsers. The buffer you pass is transferred to the stream, so you must use the returned value, not your original reference, and you can hand its memory back for the next read.

Read a byte stream into a fixed 4-byte buffer
const bytes = new ReadableStream({
  type: 'bytes',
  start(controller) {
    controller.enqueue(new TextEncoder().encode('Dune,Herbert\n'));
    controller.close();
  },
});

const reader = bytes.getReader({ mode: 'byob' });
let buffer = new Uint8Array(4);
while (true) {
  const { done, value } = await reader.read(buffer);
  if (done) break;
  console.log(value.byteLength, JSON.stringify(new TextDecoder().decode(value)));
  buffer = new Uint8Array(value.buffer); // hand the same memory back
}
Output on Node.js 26, 24 and 22
4 "Dune"
4 ",Her"
4 "bert"
1 "\n"

A stream that is not a byte stream rejects a BYOB reader with ERR_INVALID_ARG_VALUE ("must be a byte stream"); in the experiments behind this page that is what getReader({ mode: 'byob' }) did on a stream converted with Readable.toWeb() on Node.js 22, because the type: 'bytes' option of Readable.toWeb() only exists from v25.4.0 and v24.14.0.

WritableStream

A WritableStream is a destination. Its underlying sink has start(), write(chunk, controller), close() and abort(reason) callbacks, and write() may return a promise: the stream does not call it again for the next chunk until that promise settles. You write through a WritableStreamDefaultWriter obtained with getWriter(), which exposes the backpressure state directly: writer.desiredSize is the room left in the queue and await writer.ready resolves when there is room. This is the web version of write() returning false followed by 'drain'.

Pipe through a TransformStream into a WritableStream, then watch the writer’s backpressure signals
const upper = new TransformStream({
  transform(chunk, controller) {
    controller.enqueue(chunk.toUpperCase());
  },
});

const lines = [];
const sink = new WritableStream({
  write(chunk) {
    lines.push(chunk);
  },
  close() {
    console.log('closed with', lines);
  },
});

await ReadableStream.from(['dune', 'emma']).pipeThrough(upper).pipeTo(sink);

// Writer API and backpressure: ready resolves when desiredSize > 0
const w = new WritableStream(
  { write() { return new Promise((resolve) => setTimeout(resolve, 10)); } },
  { highWaterMark: 1 },
).getWriter();
console.log('desiredSize', w.desiredSize);
w.write('x');
console.log('after one write', w.desiredSize);
await w.ready;
console.log('ready again', w.desiredSize);
await w.close();
Output on Node.js 26, 24 and 22
closed with [ 'DUNE', 'EMMA' ]
desiredSize 1
after one write 0
ready again 1

Do not call writer.write() in a tight loop without awaiting writer.ready (or the promise write() returns): the queue grows without bound exactly as it does when a Node.js producer ignores the false from write(). pipeTo() does this for you.

TransformStream and composing pipelines

A TransformStream connects a writable side to a readable side: whatever you write is passed through transform(chunk, controller), which calls controller.enqueue() zero, one or many times, and then comes out of the readable side. flush(controller) runs when the writable side is closed, which is where a stateful transformer emits what it still holds. readable.pipeThrough(transformStream) returns the readable half, so steps chain, and pipeTo(writableStream) ends the chain and returns a promise that resolves when everything has been written and closed. The Bookshelf import needs a classic stateful step: chunks do not respect line boundaries, so the transformer keeps the incomplete tail between calls.

A line-splitting TransformStream that survives chunk boundaries, tee(), error propagation and cancellation
class LineSplitter extends TransformStream {
  constructor() {
    let rest = '';
    super({
      transform(chunk, controller) {
        const parts = (rest + chunk).split('\n');
        rest = parts.pop();
        for (const line of parts) controller.enqueue(line);
      },
      flush(controller) {
        if (rest) controller.enqueue(rest);
      },
    });
  }
}

const out = await Array.fromAsync(
  ReadableStream.from(['title,au', 'thor\nDune,Her', 'bert\nEmma,Austen'])
    .pipeThrough(new LineSplitter()),
);
console.log(out);

// tee: two branches, each sees every chunk
const [a, b] = ReadableStream.from([1, 2, 3]).tee();
console.log(await Array.fromAsync(a), await Array.fromAsync(b));

// Errors: an error in the transform rejects the pipeTo() promise
try {
  await ReadableStream.from(['a'])
    .pipeThrough(new TransformStream({ transform() { throw new Error('boom'); } }))
    .pipeTo(new WritableStream());
} catch (e) { console.log('pipeTo rejected:', e.message); }

// Cancellation with an AbortSignal
const ac = new AbortController();
const slow = new ReadableStream({
  async pull(c) { await new Promise((r) => setTimeout(r, 20)); c.enqueue('tick'); },
});
const p = slow.pipeTo(new WritableStream(), { signal: ac.signal });
setTimeout(() => ac.abort(), 50);
await p.catch((e) => console.log(e.name));
Output on Node.js 26, 24 and 22
[ 'title,author', 'Dune,Herbert', 'Emma,Austen' ]
[ 1, 2, 3 ] [ 1, 2, 3 ]
pipeTo rejected: boom
AbortError

The error and cancellation rules mirror what pipeline() gives Node.js streams: an error in any stage errors the others and rejects the pipeTo() promise, and an AbortSignal passed in the signal option stops the transfer. The preventAbort, preventCancel and preventClose options turn the individual propagation steps off when you need to reuse a stream after a failed pipe. TransformStream accepts a cancel transformer callback since v21.5.0 and v20.14.0, which is where you release resources when the readable side is cancelled. Web streams can also be transferred to a worker with postMessage() (listing them in the transfer list), which Node.js streams cannot; see Worker Threads.

TextEncoderStream and CompressionStream

Four ready-made transform streams exist as globals, so a byte pipeline needs no hand-written transformer:

  • TextEncoderStream turns string chunks into UTF-8 Uint8Array chunks; TextDecoderStream turns bytes back into strings. The decoder keeps state, so a multi-byte character split across two chunks is reassembled (the example below splits the three bytes of a character in two and gets it back). TextDecoderStream takes an encoding (default 'utf-8') and the fatal and ignoreBOM options of TextDecoder.

  • CompressionStream(format) compresses bytes; DecompressionStream(format) reverses it. The formats are 'gzip', 'deflate' (the zlib wrapper), 'deflate-raw' and, in current lines, 'brotli'.

  • Both directions are real transform streams, so they work with pipeThrough() and also directly inside Node.js pipeline(), as a later example shows.

Text codecs, a split multi-byte character, and a compression round trip in four formats
import { text, json, buffer } from 'node:stream/consumers';

const encoded = ReadableStream.from(['Café ', '☕']).pipeThrough(new TextEncoderStream());
const chunks = await Array.fromAsync(encoded);
console.log(chunks.map((c) => c.constructor.name), chunks[0].byteLength);

// A multi-byte character split across chunks is reassembled by the decoder
const bytes = new TextEncoder().encode('☕');
const split = ReadableStream.from([bytes.subarray(0, 1), bytes.subarray(1)]);
console.log(await text(split.pipeThrough(new TextDecoderStream())));

for (const format of ['gzip', 'deflate', 'deflate-raw', 'brotli']) {
  const input = 'Dune,Herbert\n'.repeat(1000);
  const packed = await buffer(
    ReadableStream.from([input])
      .pipeThrough(new TextEncoderStream())
      .pipeThrough(new CompressionStream(format)),
  );
  const unpacked = await text(
    ReadableStream.from([packed]).pipeThrough(new DecompressionStream(format)),
  );
  console.log(format, input.length, '->', packed.length, unpacked === input);
}

console.log(await json(ReadableStream.from([
  new TextEncoder().encode('{"ok":'),
  new TextEncoder().encode('true}'),
])));
Output on Node.js 26, 24 and 22 (the compressed sizes come from this input and the bundled zlib and brotli versions)
[ 'Uint8Array', 'Uint8Array' ] 6
☕
gzip 13000 -> 78 true
deflate 13000 -> 66 true
deflate-raw 13000 -> 60 true
brotli 13000 -> 27 true
{ ok: true }

The example also shows the third helper family, node:stream/consumers: text(), json(), buffer(), arrayBuffer(), blob() and (from v25.6.0 and v24.14.0) bytes() read a whole stream into one value. They accept web streams, Node.js Readable`s and async iterators, so they are the shortest bridge between the two worlds, but they buffer everything, so they belong on small, bounded inputs only. `CompressionStream is the portable choice; the node:zlib module offers more (compression levels, dictionaries, flush modes, zstd, one-shot calls, crc32) and is the better tool when you are on Node.js only, see Compression with zlib.

Converting between Node.js streams and web streams

Each Node.js stream class has a pair of static conversion methods. All of them are stable since v24.0.0 and v22.17.0:

Table 3. The conversion methods
Method Converts Notable options

Readable.toWeb(readable[, options])

A Node.js Readable into a ReadableStream

strategy.highWaterMark and strategy.size (the readable’s own mark is used by default); type: 'bytes' from v25.4.0 and v24.14.0 to get a byte stream.

Readable.fromWeb(readableStream[, options])

A ReadableStream into a Node.js Readable

encoding, highWaterMark, objectMode, signal.

Writable.toWeb(writable)

A Node.js Writable into a WritableStream

None.

Writable.fromWeb(writableStream[, options])

A WritableStream into a Node.js Writable

decodeStrings, highWaterMark, objectMode, signal.

Duplex.toWeb(duplex[, options])

A Node.js Duplex into a { readable, writable } pair

readableType: 'bytes' from v25.7.0 (type is its deprecated alias, available from v25.4.0 and v24.14.0).

Duplex.fromWeb({ readable, writable }[, options])

A pair of web streams into a Node.js Duplex

allowHalfOpen, decodeStrings, encoding, highWaterMark, objectMode, signal.

Three things to remember about the bridge. The conversion wraps the original stream rather than copying the data, so you should treat the original as owned by the wrapper and stop using it directly; backpressure and errors cross the bridge (observed in the last example). A Node.js Readable yields Buffer`s, so after `toWeb() the chunks are Uint8Array views (a Buffer is one), which is what TextDecoderStream and CompressionStream need. A Readable in object mode produces arbitrary values, which only object-friendly consumers can handle. And errors cross the bridge both ways: destroying a Node.js stream with an error rejects the pending read() on the web side, as the last example shows.

Node.js to web and back, web streams inside pipeline(), writing a web stream into a file, and byte streams
import { Readable, Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import { readFile } from 'node:fs/promises';

// Node -> Web
const web = Readable.toWeb(createReadStream('books.csv'));
console.log(web.constructor.name);
for await (const c of web.pipeThrough(new TextDecoderStream())) console.log(JSON.stringify(c));

// Web -> Node
const node = Readable.fromWeb(ReadableStream.from(['a', 'b']), { objectMode: true });
for await (const c of node) console.log(c);

// Mix both worlds in pipeline(): web streams are accepted directly
await pipeline(
  createReadStream('books.csv'),
  new CompressionStream('gzip'),
  createWriteStream('books.csv.gz'),
);
console.log((await readFile('books.csv.gz')).byteLength, 'bytes gzipped');

// Writable.toWeb: let a web stream write into a Node.js Writable
await ReadableStream.from(['x\n', 'y\n']).pipeTo(Writable.toWeb(createWriteStream('out.txt')));
console.log(JSON.stringify(await readFile('out.txt', 'utf8')));

// A byte stream (Node.js 24.14+, 25.4+) can be read with a BYOB reader
const b = Readable.toWeb(createReadStream('books.csv'), { type: 'bytes' });
console.log(b.getReader({ mode: 'byob' }).constructor.name);

// Errors cross the bridge
const r = Readable.toWeb(new Readable({ read() { this.destroy(new Error('disk gone')); } }));
await r.getReader().read().catch((e) => console.log('web side rejected:', e.message));
Output on Node.js 26 and 24 (on 22 the type: 'bytes' line throws ERR_INVALID_ARG_VALUE: the option does not exist there, see the version map)
ReadableStream
"title,author\nDune,Herbert\nEmma,Austen\nUlysses,Joyce\n"
a
b
72 bytes gzipped
"x\ny\n"
ReadableStreamBYOBReader
web side rejected: disk gone

Web streams that Node.js APIs give you

Several built-ins already speak web streams, so you often convert only at the edges:

  • fetch() and Response: response.body is a ReadableStream, and new Response(body) accepts one. The next example shows both directions. The consequence for requests is covered in Fetch & HTTP Clients.

  • Blob.stream() returns a ReadableStream of Uint8Array chunks.

  • filehandle.readableWebStream() from node:fs/promises gives a byte stream over an open file, which you pair with fs.open() when you want web streams without createReadStream(); see File System.

  • stream.pipeline(), stream.finished() and Duplex.from() accept web streams, so a mixed chain needs no explicit conversion.

fetch() bodies, Response bodies that must be bytes, FileHandle and Blob streams
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs';
import { open } from 'node:fs/promises';
import http from 'node:http';

const server = http.createServer((req, res) => res.end('Dune,Herbert\nEmma,Austen\n')).listen({ port: 0 });
await new Promise((resolve) => server.once('listening', resolve));
const { port } = server.address();

const res = await fetch(`http://127.0.0.1:${port}/`);
console.log(res.body.constructor.name);
await pipeline(Readable.fromWeb(res.body), createWriteStream('fetched.csv'));

// A Response body must be bytes: string chunks are rejected
const ok = new Response(Readable.toWeb(Readable.from([Buffer.from('a'), Buffer.from('b')])));
console.log(await ok.text());
const bad = new Response(Readable.toWeb(Readable.from(['a', 'b'])));
await bad.text().catch((e) => console.log(e.name, e.message));

const fh = await open('books.csv');
for await (const c of fh.readableWebStream()) console.log(c.constructor.name, c.byteLength);
await fh.close();

console.log((await new Blob(['hi']).stream().getReader().read()).value);
server.close();
Output on Node.js 26, 24 and 22
ReadableStream
ab
TypeError Received non-Uint8Array chunk
Uint8Array 52
Uint8Array(2) [ 104, 105 ]

The failed Response is the one pitfall of this section worth memorising: a Readable made from strings converts to a web stream of strings, and fetch-family consumers (Response, Request bodies) require Uint8Array chunks, so the read fails with TypeError: Received non-Uint8Array chunk. Encode first (a Buffer is a Uint8Array, or pipe through TextEncoderStream).

A Bookshelf pipeline, entirely in web streams

The Node.js version of the Bookshelf CSV export is a pipeline() of a file Readable, a parsing Transform and a file Writable. Written with web streams the stages are the same, and the only Node.js-specific parts are the two file bridges at the ends. Everything between them (the decoder, the line splitter, the mapping step, the encoder and the gzip step) is portable, and could run in a browser or an edge worker unchanged.

Read books.csv, turn each row into an NDJSON line, gzip it and write the file, then read it back
import { Readable, Writable } from 'node:stream';
import { createReadStream, createWriteStream } from 'node:fs';
import { text } from 'node:stream/consumers';

class LineSplitter extends TransformStream {
  constructor() {
    let rest = '';
    super({
      transform(chunk, controller) {
        const parts = (rest + chunk).split('\n');
        rest = parts.pop();
        for (const line of parts) controller.enqueue(line);
      },
      flush(controller) {
        if (rest) controller.enqueue(rest);
      },
    });
  }
}

await Readable.toWeb(createReadStream('books.csv'))
  .pipeThrough(new TextDecoderStream())
  .pipeThrough(new LineSplitter())
  .pipeThrough(new TransformStream({
    transform(line, controller) {
      const [title, author] = line.split(',');
      if (title !== 'title') controller.enqueue(`${JSON.stringify({ title, author })}\n`);
    },
  }))
  .pipeThrough(new TextEncoderStream())
  .pipeThrough(new CompressionStream('gzip'))
  .pipeTo(Writable.toWeb(createWriteStream('books.ndjson.gz')));

console.log(await text(
  Readable.toWeb(createReadStream('books.ndjson.gz')).pipeThrough(new DecompressionStream('gzip')),
));
Output on Node.js 26, 24 and 22
{"title":"Dune","author":"Herbert"}
{"title":"Emma","author":"Austen"}
{"title":"Ulysses","author":"Joyce"}

The naive line splitter in this example does not handle quoted fields, which is why the real Bookshelf importer in Implementing Custom Streams and any production CSV code should use a parser library.

When to use which

Neither API is deprecated, and neither is going away: Node.js streams are the foundation of fs, net, http and zlib, and web streams are the standard that fetch() and the other runtimes use. The choice is mostly decided by what the code touches and where it must run.

Table 4. Choosing between Node.js streams and web streams
Situation Prefer Why

File, socket, HTTP server request or response, child process stdio

Node.js streams

These APIs produce and consume them natively; no conversion layer, and pipeline() handles cleanup. See Streams.

Code shared between Node.js and browsers, Deno, Bun or edge workers

Web streams

Only the WHATWG API is portable; keep Node.js specifics at the edges with toWeb() and fromWeb().

Consuming a fetch() response body

Web streams, converting with Readable.fromWeb() when you need to pipe into a file or socket

response.body is already a ReadableStream; iterate it with for await, or convert once at the boundary.

Compression inside a portable pipeline

CompressionStream and DecompressionStream

Standard API in all runtimes; for Node.js-only code node:zlib gives more options, see Compression with zlib.

Text encoding or decoding of chunks

TextEncoderStream and TextDecoderStream

Correct across chunk boundaries and available everywhere. The Node.js alternative is setEncoding() or string_decoder.

Custom streaming source or transformer with a promise-based code style

Web streams, or an async generator with Readable.from() or in a pipeline()

Generators and promises compose well with both; see Implementing Custom Streams.

Streaming data to or from a worker thread

Web streams (transferable) or a MessagePort

Web streams can be transferred with postMessage(); Node.js streams cannot. See Worker Threads.

Object-mode pipelines, EventEmitter-style integration or existing Node.js libraries

Node.js streams

Much of the ecosystem (parsers, upload handlers, loggers) is written against node:stream; convert at the boundary only if you must.

Two final cautions. Do not convert at every step of a pipeline: each toWeb() or fromWeb() adds a wrapper and a queue, so convert once at each boundary and keep the interior in a single model. And prefer the stable interop methods over the hand-rolled adapters found in older code, for example a Readable built by hand around reader.read(): fromWeb() is the documented bridge and takes the encoding, object-mode, highWaterMark and signal options.

What changed recently

Table 5. Web streams changes by Node.js version
Version Change

v18.0.0

The web stream classes, TextEncoderStream, TextDecoderStream and CompressionStream became globals, and using the API no longer prints a runtime warning.

v20.6.0

ReadableStream.from() added.

v20.12.0 and v21.2.0

CompressionStream and DecompressionStream accept 'deflate-raw'.

v21.0.0

The Web Streams API is no longer experimental.

v22.15.0 and v23.8.0

filehandle.readableWebStream() always creates a byte stream; the option to choose the type was removed.

v22.17.0 and v24.0.0

Readable.toWeb(), Readable.fromWeb(), Writable.toWeb(), Writable.fromWeb(), Duplex.toWeb(), Duplex.fromWeb(), Readable.isDisturbed() and filehandle.readableWebStream() marked stable.

v22.20.0 and v24.7.0

CompressionStream and DecompressionStream accept 'brotli'.

v24.14.0 and v25.4.0

type: 'bytes' option for Readable.toWeb() and Duplex.toWeb(); stream/consumers bytes() in v24.14.0 and v25.6.0.

v25.7.0

Duplex.toWeb() takes readableType; type is a deprecated alias.

Where the books are outdated or incomplete

Efficient Node.js (Samer Buna, O’Reilly 2025) teaches streams through the Node.js API only: pipe(), pipeline(), the readable and writable events and for await over a Readable. In the passages consulted for this page it does not cover web streams, toWeb() and fromWeb(), or the CompressionStream family, so a reader who only knows the book will not know why fetch() hands back an object without .pipe(). Node.js Projects (Jonathan Wexler, O’Reilly 2025) uses streams at the level of concepts and file reading and writing, and likewise does not mention the web stream classes. Both books predate the stabilisation of the interoperability methods (v24.0.0 and v22.17.0) and the brotli format (v24.7.0), so older advice such as "web streams are experimental" or "convert with a hand-written adapter" no longer applies.

Table 6. Quick corrections for older tutorials
Statement you may meet Current situation

"Web streams are experimental in Node.js"

Stable since v21.0.0; the interop methods are stable since v24.0.0 and v22.17.0.

“fetch()` returns a Node.js stream you can `.pipe()”

response.body is a web ReadableStream; use Readable.fromWeb() or for await.

"You must import ReadableStream from `stream/web`"

They are globals since v18.0.0; the import still works and is the same object.

"`CompressionStream` only does gzip and deflate"

'deflate-raw' since v21.2.0 and v20.12.0, and 'brotli' since v24.7.0 and v22.20.0.

"A Response can be built from any stream"

Its body chunks must be Uint8Array; string chunks fail with Received non-Uint8Array chunk.

References