Implementing Custom Streams

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-04, and every example below was run on all three lines unless the text says otherwise. The node:stream module is Stability 2 (Stable), and so is everything this page implements: the four base classes (Readable, Writable, Duplex, Transform), the underscore methods you override, stream.pipeline() and Readable.from(). The one exception is the last section: node:stream/iter is Stability 1 (Experimental) and only loads with the --experimental-stream-iter flag. This page assumes you know how to consume streams, backpressure and pipeline(), which Streams owns; here you write the producer, consumer and transformer yourself. The running scenario is the Bookshelf application that grows across this section: its CSV import is a custom Transform built below, and its file system and buffer pages supply the bytes it reads.

The stability and version map

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

stream.Readable, Writable, Duplex, Transform, PassThrough and the underscore methods

2 Stable

autoDestroy defaults to true since v14.0.0; the default highWaterMark of byte streams was raised from 16 KiB to 64 KiB in v22.0.0 (16 KiB stays on Windows).

_construct() / construct option

2 Stable

Added in v15.0.0.

signal option of the stream constructors

2 Stable

Added in v15.5.0.

Readable.from(iterable[, options])

2 Stable

Added in v12.3.0 and v10.17.0; defaults objectMode to true.

readable.push() with a TypedArray or DataView

2 Stable

Accepted since v22.0.0 and v20.13.0 (a Uint8Array since v8.0.0).

stream.pipeline() with generator functions as stages

2 Stable

Available on all supported lines; stream/promises returns a promise.

stream.getDefaultHighWaterMark()

2 Stable

Added in v19.9.0 and v18.17.0.

node:stream/iter

1 Experimental, behind --experimental-stream-iter

Documented as added in v25.9.0. Observed: it also loads with the flag on Node.js 24.21.0 (the Node.js 24 API docs were not consulted for its status); on Node.js 22.23.3 the flag is rejected as a bad option.

readable[Symbol.for('Stream.toAsyncStreamable')]()

1 Experimental

Added in v26.1.0; only works with the flag above (observed to work on 24.21.0 too).

What you implement

Every stream class is a template: the base class owns the public API that consumers call (write(), read(), pipe(), events, buffering, backpressure and the state machine) and calls your underscore methods at the right moment. You implement the small internal part and never call it yourself.

Table 2. Which methods to implement for each class
Use case Class Method(s) to implement

Produce data

Readable

_read() (required); optionally _construct(), _destroy()

Consume data

Writable

_write() and/or _writev() (at least one); optionally _final(), _construct(), _destroy()

Both, independently

Duplex

_read() and _write()/_writev(); optionally _final()

Compute the output from the input

Transform

_transform() (required); optionally _flush(), _final()

Three rules from the documentation hold for every class:

  • The underscore methods are called by the stream machinery only; application code calls the public methods.

  • Do not override public methods such as write(), end(), read() or destroy(), and do not emit internal events ('data', 'end', 'finish', 'close', 'error') with .emit(): that breaks invariants other stream utilities rely on. Report failure through the callback or through destroy(err) instead.

  • When you extend a class, forward only the constructor options you intend to support. If your implementation depends on autoDestroy or emitClose, do not let callers override them.

You can implement a stream in two styles. The subclass style (class Counter extends Readable { read() {} }) suits streams with state and a name. The _simplified construction style passes the methods as constructor options without the underscore (read, write, writev, final, construct, destroy, transform, flush) and suits one-off streams. Both produce the same stream, and both are used below.

Subclass style versus constructor options
import { Readable, Writable } from 'node:stream';

// Subclass style: private state in fields, methods with the underscore.
class Hello extends Readable {
  _read() {
    this.push('hello');
    this.push(null);
  }
}

// Simplified construction: the same methods, passed as options.
const sink = new Writable({
  write(chunk, encoding, callback) {
    console.log('got', chunk.toString());
    callback();
  },
});

new Hello().pipe(sink);

Implementing a Readable: read and push

A Readable is a pull-driven buffer. Consumers drain it; when its internal buffer drops below highWaterMark, the stream calls your _read(size), and you answer by calling this.push(chunk) zero or more times. Two details carry the whole design:

  • push() returns a boolean. true means "the buffer has room, keep going"; false means "stop until _read() is called again". _read() is called again after the buffer drains, so you do not need a timer or an event.

  • push(null) signals the end of the stream (EOF); nothing can be pushed after it. Pushing an empty string or an empty buffer does not trigger another _read(), and size is only advisory.

The first example is a synchronous source that respects the push() return value. It uses object mode (see Object mode) so each pushed value is one item.

A synchronous Readable that honours the push() return value
import { Readable } from 'node:stream';

class Countdown extends Readable {
  #next;
  constructor(from, options) {
    super({ ...options, objectMode: true });
    this.#next = from;
  }
  _read() {
    // Push until push() returns false or the source is exhausted.
    let more = true;
    while (more) {
      if (this.#next < 0) {
        this.push(null); // end of stream
        return;
      }
      more = this.push({ n: this.#next-- });
    }
  }
}

const values = [];
for await (const item of new Countdown(5)) values.push(item.n);
console.log(values);
Output (identical on Node.js 26, 24 and 22)
[ 5, 4, 3, 2, 1, 0 ]

A real source is usually asynchronous: a database cursor, a paged HTTP API, a socket. The rule becomes "call push() when the data arrives, and report errors with destroy(err)`". The documentation states that throwing inside `_read(), or emitting 'error' by hand, is undefined behaviour; destroy(err) is the supported path.

An asynchronous source: one page of rows per _read() call
import { Readable } from 'node:stream';
import { setTimeout as sleep } from 'node:timers/promises';

// Stand-in for a paged API or a database cursor.
let page = 0;
const fetchPage = async (n) => {
  await sleep(5);
  return n < 3 ? [`row-${n}-a`, `row-${n}-b`] : [];
};

const paged = new Readable({
  objectMode: true,
  read() {
    fetchPage(page++).then(
      (rows) => {
        if (rows.length === 0) this.push(null);
        else for (const r of rows) this.push(r);
      },
      (err) => this.destroy(err), // errors go through destroy(), never throw
    );
  },
});

console.log(await paged.toArray());
Output (identical on Node.js 26, 24 and 22)
[ 'row-0-a', 'row-0-b', 'row-1-a', 'row-1-b', 'row-2-a', 'row-2-b' ]

readable.toArray() used above (and the other Readable operators such as map() and filter()) are Stability 1 (Experimental) in the Node.js 26 documentation; they are convenient in examples and scripts, and for await is the stable way to consume a stream.

Implementing a Writable: write and final

A Writable is a push-driven consumer. Producers call write(chunk); the stream calls your _write(chunk, encoding, callback) for one chunk at a time and calls the next only after you invoke callback(). While you have not called callback, further writes are queued, and write() starts returning false once the queued data reaches highWaterMark: that is how a slow consumer slows the producer down. You signal completion by calling callback() (success) or callback(err) (failure), either synchronously or in a later tick.

Two optional methods matter in practice:

  • _final(callback) runs after the last write, before 'finish' is emitted. Flush buffered data and close resources there, and 'finish' waits for you.

  • _writev(chunks, callback) receives an array of buffered chunks ({ chunk, encoding }) at once. If you implement it and there is queued data, it is called instead of _write(), which is useful when a batch is cheaper than N single writes (a multi-row INSERT). The documentation warns that a poorly written _writev() can reduce throughput.

The Bookshelf import writes rows in batches. This Writable accumulates rows, hands a full batch to an async sink, and uses _final() for the last partial batch.

A batching Writable using write and final
import { Readable, Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';

class BatchWriter extends Writable {
  #batch = [];
  #size;
  #sink;
  constructor(sink, { batchSize = 3 } = {}) {
    super({ objectMode: true, highWaterMark: 4 });
    this.#size = batchSize;
    this.#sink = sink;
  }
  _write(row, _encoding, callback) {
    this.#batch.push(row);
    if (this.#batch.length < this.#size) return callback();
    this.#flush().then(() => callback(), callback);
  }
  _final(callback) {
    // Called after the last write(), before 'finish'.
    this.#flush().then(() => callback(), callback);
  }
  async #flush() {
    if (this.#batch.length === 0) return;
    const rows = this.#batch;
    this.#batch = [];
    await this.#sink(rows);
  }
}

const batches = [];
await pipeline(
  Readable.from(Array.from({ length: 8 }, (_, i) => ({ id: i }))),
  new BatchWriter(async (rows) => { batches.push(rows.map((r) => r.id)); }),
);
console.log(batches);
Output (identical on Node.js 26, 24 and 22)
[ [ 0, 1, 2 ], [ 3, 4, 5 ], [ 6, 7 ] ]

_writev() and cork() cooperate: while a producer has called cork(), writes are buffered, and uncork() flushes them to _writev() as one batch.

Batching with cork(), uncork() and writev
import { Writable } from 'node:stream';

const w = new Writable({
  objectMode: true,
  write(chunk, _enc, cb) { console.log('write', chunk); setImmediate(cb); },
  writev(entries, cb) { console.log('writev', entries.map((e) => e.chunk)); setImmediate(cb); },
});

w.cork();
w.write('a'); w.write('b'); w.write('c');
w.uncork();
w.end('d');
await new Promise((resolve) => w.on('finish', resolve));
Output (identical on Node.js 26, 24 and 22)
writev [ 'a', 'b', 'c' ]
write d

By default a byte-mode Writable converts strings to Buffer before calling _write() (decodeStrings: true); set decodeStrings: false if your implementation handles strings natively, and then use the encoding argument. In object mode none of this applies and the chunk is passed through unchanged.

Implementing a Duplex

A Duplex is a Readable and a Writable in one object (a TCP socket is the classic example). The key point of the documentation is that the two sides operate independently: what you read is not necessarily related to what you wrote. You implement _read() for the readable side and _write() (or _writev()) for the writable side. Because JavaScript has no multiple inheritance, Duplex inherits from Readable and mimics Writable; instanceof works for both.

Useful Duplex options: allowHalfOpen (default true; when false the writable side ends automatically when the readable side ends), readableObjectMode and writableObjectMode (object mode for one side only), and readableHighWaterMark and writableHighWaterMark (per-side buffer limits).

The example is a small echo duplex, written so that backpressure from the readable side reaches the writable side: when push() returns false, the write callback is parked and released by the next _read().

A Duplex whose writable side feeds its readable side with backpressure
import { Duplex } from 'node:stream';

class Echo extends Duplex {
  #resume = null;
  constructor() {
    super({ objectMode: true, readableHighWaterMark: 2 });
  }
  _write(message, _encoding, callback) {
    // Bridge backpressure: hold the write callback while the readable side is full.
    if (this.push(`echo: ${message}`)) callback();
    else this.#resume = callback;
  }
  _read() {
    const resume = this.#resume;
    this.#resume = null;
    resume?.();
  }
  _final(callback) {
    this.push(null);
    callback();
  }
}

const echo = new Echo();
for (const word of ['one', 'two', 'three', 'four', 'five']) echo.write(word);
echo.end();
console.log(await echo.toArray());
Output (identical on Node.js 26, 24 and 22)
[ 'echo: one', 'echo: two', 'echo: three', 'echo: four', 'echo: five' ]

If you only need to combine an existing writable and readable, or turn an async generator into a duplex, stream.compose() and Duplex.from() do it without a subclass; they are described in Streams.

Implementing a Transform: the Bookshelf CSV parser

A Transform is a Duplex whose output is computed from its input (compression, encryption, parsing). You implement _transform(chunk, encoding, callback) and optionally _flush(callback):

  • _transform() processes one chunk. Call this.push(output) zero or more times (one input chunk can yield no, one or many outputs), and call callback() when the chunk is fully consumed. callback(null, data) is shorthand for this.push(data); callback(). It is never called in parallel: the next chunk is delivered only after callback.

  • _flush() runs after all input was written, before 'end'. It is where trailing state (the last unterminated line, a compression footer) leaves the stream.

A streaming parser has to expect that chunk boundaries fall anywhere: in the middle of a line, and in the middle of a multi-byte character. Decoding each chunk with chunk.toString() corrupts a character split across two chunks; node:string_decoder buffers the incomplete bytes. The parser below keeps a decoder and the incomplete last line as private state, uses a byte-mode writable side and an object-mode readable side (readableObjectMode: true), and emits each row as an array of fields. The test input splits the three-byte euro sign across two chunks on purpose.

The Bookshelf CSV parser as a Transform with a byte-mode writable and object-mode readable side
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { StringDecoder } from 'node:string_decoder';

// Bytes in, row arrays out.
export class CsvParser extends Transform {
  #decoder = new StringDecoder('utf8');
  #rest = '';
  constructor() {
    super({ readableObjectMode: true });
  }
  _transform(chunk, _encoding, callback) {
    this.#rest += this.#decoder.write(chunk); // safe for characters split across chunks
    const lines = this.#rest.split(/\r?\n/);
    this.#rest = lines.pop(); // the last element is an unterminated line
    for (const line of lines) if (line) this.push(line.split(','));
    callback();
  }
  _flush(callback) {
    this.#rest += this.#decoder.end();
    if (this.#rest) this.push(this.#rest.split(','));
    callback();
  }
}

// Split the euro sign (three bytes) across two chunks on purpose.
const bytes = Buffer.from('title,price\nPraga,10€\nDune,8\n');
const cut = bytes.indexOf(0xe2) + 1;

const rows = [];
await pipeline(
  Readable.from([bytes.subarray(0, cut), bytes.subarray(cut)], { objectMode: false }),
  new CsvParser(),
  async function (source) { for await (const row of source) rows.push(row); },
);
console.log(rows);
Output (identical on Node.js 26, 24 and 22)
[ [ 'title', 'price' ], [ 'Praga', '10€' ], [ 'Dune', '8' ] ]

This parser is deliberately minimal: it does not handle quoted fields, embedded commas or embedded newlines (RFC 4180). For real input use a maintained CSV library, which is built the same way (a Transform with an object-mode readable side) and carries the edge-case handling; the point here is the structure, which carries over to any line-oriented or framed protocol.

A transform that does not need state across chunks needs no class at all; the simplified form is enough:

A stateless Transform with callback(null, data)
import { Transform } from 'node:stream';

const upper = new Transform({
  transform(chunk, _encoding, callback) {
    callback(null, chunk.toString().toUpperCase());
  },
});

upper.end('abc');
upper.on('data', (chunk) => console.log(String(chunk)));
Output (identical on Node.js 26, 24 and 22)
ABC

Two traps are worth remembering. First, a Transform whose readable side nobody consumes eventually pauses its writable side, because the readable buffer fills up: always put a consumer behind it. Second, callback must be called exactly once per chunk; forgetting it stalls the whole pipeline without an error, and calling it twice is a bug.

Readable.from and generators

For many sources a class is more ceremony than needed. Readable.from(iterable[, options]) creates a Readable from any sync or async iterable, and an async generator is the natural way to write one: yield is push(), and the generator is paused whenever the stream’s buffer is full, so backpressure comes for free. Several details are easy to miss:

  • Readable.from() sets objectMode to true by default. Pass { objectMode: false } for a byte stream of Buffer or string chunks.

  • A string or a Buffer passed directly is not iterated (character by character or byte by byte): the whole value becomes one chunk. Wrap it in an array if you want that.

  • Passing null as a value of the iterable emits an 'error' event.

  • An iterable of promises can produce an unhandled rejection if one rejects before it is awaited: prefer an async generator that awaits each value.

  • The generator is closed (its finally blocks run; observed on Node.js 22, 24 and 26 by destroying the stream after the first chunk) when the stream is destroyed, so use try/finally for cleanup inside it.

A generator can also be a pipeline stage. stream.pipeline() accepts async generator functions between its streams: the function receives the previous stage as an async iterable and yields the output, which makes a one-off transform a few lines long. A final stage that is an async function (not a generator) is the sink.

Readable.from as the source, a generator as a transform stage, an async function as the sink
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';

async function* books() {
  yield { title: 'Dune', year: 1965 };
  yield { title: 'Neuromancer', year: 1984 };
  yield { title: 'Hyperion', year: 1989 };
}

const out = [];
await pipeline(
  Readable.from(books()),                                 // objectMode is true by default
  async function* (source) {                              // generator as a transform stage
    for await (const b of source) {
      if (b.year > 1970) yield { ...b, title: b.title.toUpperCase() };
    }
  },
  async function (source) {                               // async function as the last stage (sink)
    for await (const b of source) out.push(b);
  },
);
console.log(out);

// Strings and Buffers are NOT iterated by Readable.from()
console.log(await Readable.from('abc').toArray());
console.log(await Readable.from('abc', { objectMode: false }).toArray());
Output (identical on Node.js 26, 24 and 22)
[
  { title: 'NEUROMANCER', year: 1984 },
  { title: 'HYPERION', year: 1989 }
]
[ 'abc' ]
[ <Buffer 61 62 63> ]

The same CSV parser written as a generator stage is shorter, because the loop and the pending-line variable replace the class. It decodes with TextDecoder in streaming mode ({ stream: true }), which has the same multi-byte guarantee as StringDecoder.

The CSV parser as an async generator pipeline stage
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';

async function* parseCsv(source) {
  let rest = '';
  const decoder = new TextDecoder();
  for await (const chunk of source) {
    rest += decoder.decode(chunk, { stream: true });
    const lines = rest.split(/\r?\n/);
    rest = lines.pop();
    for (const line of lines) if (line) yield line.split(',');
  }
  rest += decoder.decode();
  if (rest) yield rest.split(',');
}

const rows = [];
await pipeline(
  Readable.from([Buffer.from('a,b\n1,'), Buffer.from('2\n3,4')]),
  parseCsv,
  async (source) => { for await (const row of source) rows.push(row); },
);
console.log(rows);
Output (identical on Node.js 26, 24 and 22)
[ [ 'a', 'b' ], [ '1', '2' ], [ '3', '4' ] ]
Table 3. Class, constructor options or generator?
Style Choose it when Cost

Subclass (extends Transform)

The stream has state, a name, several methods, a lifecycle (_construct(), _destroy()), or is a reusable library export.

More ceremony; you manage callback yourself.

Constructor options (new Transform({ transform() {} }))

A small one-off stream, or a test double.

No private fields without closures; harder to extend.

Readable.from() and generator stages

A source or a stateless/lightly stateful transformation expressed as a loop; code reads top to bottom.

The result is a plain async iterable in the middle of a pipeline, not a stream with its own events and options; fine-grained control of highWaterMark, cork() or half-open behaviour requires the class form.

Object mode

By default a stream carries bytes (Buffer, TypedArray, DataView) or strings. With objectMode: true it carries arbitrary JavaScript values (except null, which is reserved for end-of-stream; writing null throws ERR_STREAM_NULL_VALUES). The consequences for implementers:

  • highWaterMark counts objects, not bytes. The default is 16 objects in object mode and 65536 bytes (64 KiB on non-Windows; 16384 on Windows) in byte mode. stream.getDefaultHighWaterMark(objectMode) returns the active default, and setDefaultHighWaterMark() changes it process-wide. An object may be a megabyte large, so size object-mode buffers by item cost, not by habit.

  • Each side of a Duplex or Transform can differ. readableObjectMode and writableObjectMode set one side only (and have no effect when objectMode is true). The CSV parser above has a byte writable side and an object readable side; the opposite shape (a serializer that takes objects and emits bytes) uses writableObjectMode: true.

  • Match the modes at each boundary. A stage that emits objects must feed an object-mode writable side; a byte-mode writable side expects strings, `Buffer`s or typed arrays.

  • Strings are not special. In an object-mode stream a pushed string stays one chunk, and no StringDecoder or encoding applies.

The two default highWaterMark values and object-mode-only constraints
import { Writable, getDefaultHighWaterMark } from 'node:stream';

console.log(getDefaultHighWaterMark(false), getDefaultHighWaterMark(true));

const w = new Writable({ objectMode: true, write(chunk, enc, cb) { cb(); } });
try {
  w.write(null);
} catch (err) {
  console.log(err.code);
}
Output on Linux and macOS (identical on Node.js 26, 24 and 22; the byte-mode default is 16384 on Windows)
65536 16
ERR_STREAM_NULL_VALUES

Errors, cleanup and cancellation

The error contract is the same on every class and is the place where hand-written streams most often go wrong:

  • In _write(), _writev(), _final(), _transform() and _flush(), report failure with callback(err). In _read(), report it with this.destroy(err). Throwing from these methods, or emitting 'error' yourself, is documented as undefined behaviour.

  • destroy(err) makes the stream call your _destroy(err, callback), which is where you release resources (file handles, sockets, timers, database cursors). Call callback(err) at the end, passing along the error you received. With autoDestroy: true (the default since v14.0.0) the stream destroys itself after 'finish'/'end', so _destroy() also runs on success.

  • _construct(callback) runs once after the constructor returns and delays _read(), _write() and _destroy() until you call it: use it for asynchronous initialisation such as opening a file; callback(err) fails the stream before it was used.

  • pipeline() destroys every stage when one fails, so your _destroy() is the single cleanup point. A bare source.pipe(dest) does not destroy the source when the destination fails, which is why implementers and consumers should use pipeline().

  • The signal option accepts an AbortSignal; aborting it behaves like destroy(new AbortError()) on the stream.

Cleanup through destroy, construct and the signal option
import { Readable, Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';

// 1. A failing write destroys the whole pipeline, source included.
const source = Readable.from([1, 2, 3]);
const failing = new Writable({
  objectMode: true,
  write(n, _encoding, callback) {
    if (n === 2) callback(new Error(`bad value ${n}`));
    else callback();
  },
  destroy(err, callback) {
    console.log('destroy called, err =', err?.message);
    callback(err);
  },
});
try {
  await pipeline(source, failing);
} catch (err) {
  console.log('caught:', err.message);
}
console.log('source destroyed:', source.destroyed);

// 2. construct() and destroy() bracket a resource.
const resource = new Readable({
  construct(callback) { console.log('open'); setImmediate(callback); },
  read() { this.push('x'); this.push(null); },
  destroy(err, callback) { console.log('close resource'); callback(err); },
});
console.log(await resource.toArray());

// 3. Cancellation with an AbortSignal.
const ac = new AbortController();
const cancelled = new Readable({ read() {}, signal: ac.signal });
cancelled.on('error', (err) => console.log(err.name, err.code));
ac.abort();
Output (identical on Node.js 26, 24 and 22)
destroy called, err = bad value 2
caught: bad value 2
source destroyed: true
open
close resource
[ <Buffer 78> ]
AbortError ABORT_ERR

The Node.js 26 documentation marks using stream.addAbortSignal() to destroy long-lived stream resources as Stability 0 (Deprecated, documentation-only); prefer the signal constructor option, the signal option of pipeline(), or, for the generators above, the signal you pass to the async calls inside them. Cancellation as a whole is covered in Async Context & Cancellation, and the general error model in Errors & Error Handling.

Testing a custom stream

A custom stream is a pure function of bytes or objects over time, so it is cheap to test with the built-in test runner: feed it with Readable.from(), run it through pipeline(), collect the output, and assert. The cases that matter are chunk boundaries (the stream must give the same result however the input is split), the end-of-stream flush, and failures.

Testing the CSV parser with chunks split mid-row
import test from 'node:test';
import assert from 'node:assert/strict';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { CsvParser } from './csv-parser.js';

test('CsvParser handles rows split across chunks', async () => {
  const rows = [];
  await pipeline(
    Readable.from(['a,b\n1', ',2\n3,4'], { objectMode: false }),
    new CsvParser(),
    async (source) => { for await (const row of source) rows.push(row); },
  );
  assert.deepEqual(rows, [['a', 'b'], ['1', '2'], ['3', '4']]);
});
Output (the ✔/ℹ summary uses the spec reporter, the default on non-TTY output on Node.js 24 and 26; Node.js 22 prints TAP there)
✔ CsvParser handles rows split across chunks
ℹ pass 1
ℹ fail 0

The test runner itself is the subject of Test Runner.

Preview: stream/iter

node:stream/iter (Stability 1, Experimental; added in v25.9.0; enabled with --experimental-stream-iter) is a proposal for streaming without class hierarchies: streams are plain async or sync iterables, and a "transform" is a function or an object with a generator method. No base class, no callback, no event listeners. The model, as documented:

  • All data is Uint8Array; strings are UTF-8 encoded on entry.

  • Each iteration yields a batch (Uint8Array[]) instead of one chunk, to amortise the cost of await.

  • A stateless transform is (chunks, options) ⇒ result, called once per batch; it receives null as the flush signal after the source ends. A stateful transform is { transform(source, options) }, a generator that receives the whole upstream iterable.

  • pull(source, …​transforms) builds a lazy pipeline whose pace is set by the consumer (natural backpressure); push() creates a writer/readable pair with explicit buffering policies (strict, unbounded, drop-oldest, drop-newest); broadcast() and share() split one source across several consumers.

  • Consumers include text(), bytes(), array() and pipeTo(). Classic streams interoperate: with the flag on, a Readable implements the Stream.toAsyncStreamable protocol (added in v26.1.0, Experimental), so from(readable) consumes it with batching.

The next example uses the stateless-transform form from the documentation, and consumes a classic Readable. Run it with node --experimental-stream-iter preview.mjs. It produced the output below on Node.js 26.10.0 and, as observed, on 24.21.0; on Node.js 22 the flag is rejected and node:stream/iter is unavailable (see the table in The stability and version map).

A stateless stream/iter transform and a classic Readable consumed through stream/iter
import { Readable } from 'node:stream';
import { from, pull, text } from 'node:stream/iter';

// Stateless transform: receives Uint8Array[] per batch, and null as the flush signal.
const upper = (chunks) => {
  if (chunks === null) return null;
  return chunks.map((chunk) => new TextEncoder().encode(
    new TextDecoder().decode(chunk).toUpperCase(),
  ));
};

console.log(await text(pull(from('hello bookshelf'), upper)));

// With the flag on, a classic Readable is a valid source.
console.log(await text(from(Readable.from(['a', 'b'], { objectMode: false }))));
Output on Node.js 26 with --experimental-stream-iter
(node:12345) ExperimentalWarning: stream/iter is an experimental feature and might change at any time
HELLO BOOKSHELF
ab

Do not build production code on it yet: names, signatures and buffering policies can change in any release, the module does not exist on the Active LTS (24) and Maintenance (22) lines, and every shipped Node.js stream API (fs, net, http, zlib) is still a classic stream. The classes on this page are the interface that will keep working.

What changed recently

Table 4. Changes relevant to implementing streams
Version Change

v22.0.0

The default highWaterMark of byte streams rose from 16 KiB to 64 KiB (non-Windows). Code that tuned buffers for the old default, or that asserted on chunk sizes, may behave differently.

v22.0.0, v20.13.0

readable.push() accepts any TypedArray or DataView, not only Buffer and Uint8Array.

v24.0.0, v22.17.0

stream.compose() and the fromWeb()/toWeb() interop were marked stable.

v25.9.0

node:stream/iter added (Experimental, behind a flag).

v26.1.0

Readable[Symbol.for('Stream.toAsyncStreamable')]() added (Experimental).

v14.0.0, v15.0.0, v15.5.0

Older, still relevant for tutorials: autoDestroy became the default (14), _construct() was added (15), the signal option was added (15.5).

Where older material is outdated

Tutorials written before Node.js 14 to 16 teach patterns that still run but should not be copied:

  • Prototype inheritance. util.inherits(MyStream, Readable) plus Readable.call(this, options) was the only way before ES2015 classes; today use class extends or the simplified constructor options. The same era had the third-party readable-stream package as a workaround for differences between Node.js versions; it is rarely needed in new code.

  • .pipe() chains without error handling. a.pipe(b).pipe(c) forwards no errors and leaves the other streams open when one fails. Use pipeline() (the stream/promises form with await), which destroys every stage on failure; your _destroy() then runs reliably.

  • Calling callback() or push() inconsistently, or throwing. Old snippets often throw inside _transform(); the supported contract is callback(err).

  • Manual byte decoding. chunk.toString() per chunk breaks multi-byte characters at chunk boundaries; use StringDecoder, TextDecoder with { stream: true }, or set an encoding on the stream.

  • Events instead of async iteration. Collecting data in 'data' handlers and resolving on 'end' is verbose and ignores backpressure and errors; for await and pipeline() replace it.

Of the two reference books for this section, Efficient Node.js (Samer Buna, O’Reilly, 2025) uses the current new Readable, new Transform, Readable.from() and pipeline() forms and is not outdated on this topic; Node.js Projects (Jonathan Wexler, O’Reilly, 2025) does not cover implementing streams. Use the official stream documentation for the underscore-method contracts.

Pitfalls checklist

Table 5. Common mistakes when implementing streams
Mistake Effect and fix

Not calling callback in _write()/_transform() on some code path

The stream stalls silently. Make sure every path, including early returns and catch blocks, ends in callback() or callback(err).

Ignoring the push() return value

The internal buffer grows without limit for a fast source. Stop pushing on false and continue in the next _read().

Pushing after push(null), or after destroy()

Raises an error (ERR_STREAM_PUSH_AFTER_EOF) or is dropped. Track an "ended" flag in async sources.

Overriding write(), end() or destroy()

Breaks stream invariants. Implement the underscore methods.

Using chunk.toString() on partial input

Corrupts multi-byte characters across chunks. Use StringDecoder.

Mixing object and byte mode at a boundary

Type errors or garbled output. Set readableObjectMode and writableObjectMode explicitly per side.

Leaking the resource on failure

File handles and sockets stay open. Release them in _destroy(), and run the stream under pipeline().

Not consuming a Transform’s output

The writable side pauses forever once the readable buffer is full. Always connect a consumer.

Backpressure and the pipeline() helpers are covered in Streams; the same streams in the browser-compatible form are in Web Streams & Interop; gzip and brotli are ready-made Transform streams in Compression with zlib.

References