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
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
2 Stable |
|
|
2 Stable |
Added in v15.0.0. |
|
2 Stable |
Added in v15.5.0. |
|
2 Stable |
Added in v12.3.0 and v10.17.0; defaults |
|
2 Stable |
Accepted since v22.0.0 and v20.13.0 (a |
|
2 Stable |
Available on all supported lines; |
|
2 Stable |
Added in v19.9.0 and v18.17.0. |
|
1 Experimental, behind |
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. |
|
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.
| Use case | Class | Method(s) to implement |
|---|---|---|
Produce data |
|
|
Consume data |
|
|
Both, independently |
|
|
Compute the output from the input |
|
|
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()ordestroy(), 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 throughdestroy(err)instead. -
When you extend a class, forward only the constructor options you intend to support. If your implementation depends on
autoDestroyoremitClose, 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.
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.truemeans "the buffer has room, keep going";falsemeans "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(), andsizeis 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.
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);
[ 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.
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());
[ '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-rowINSERT). 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.
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);
[ [ 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.
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));
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().
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());
[ '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. Callthis.push(output)zero or more times (one input chunk can yield no, one or many outputs), and callcallback()when the chunk is fully consumed.callback(null, data)is shorthand forthis.push(data); callback(). It is never called in parallel: the next chunk is delivered only aftercallback. -
_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.
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);
[ [ '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:
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)));
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()setsobjectModetotrueby default. Pass{ objectMode: false }for a byte stream ofBufferor string chunks. -
A string or a
Bufferpassed 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
nullas 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
finallyblocks run; observed on Node.js 22, 24 and 26 by destroying the stream after the first chunk) when the stream is destroyed, so usetry/finallyfor 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.
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());
[
{ title: 'NEUROMANCER', year: 1984 },
{ title: 'HYPERION', year: 1989 }
]
[ 'abc' ]
[ <Buffer 61 62 63> ]
Source: Node.js API: stream.Readable.from() and Streams compatibility with async generators and async iterators
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.
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);
[ [ 'a', 'b' ], [ '1', '2' ], [ '3', '4' ] ]
| Style | Choose it when | Cost |
|---|---|---|
Subclass ( |
The stream has state, a name, several methods, a lifecycle ( |
More ceremony; you manage |
Constructor options ( |
A small one-off stream, or a test double. |
No private fields without closures; harder to extend. |
|
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
|
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:
-
highWaterMarkcounts 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, andsetDefaultHighWaterMark()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
DuplexorTransformcan differ.readableObjectModeandwritableObjectModeset one side only (and have no effect whenobjectModeistrue). 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) useswritableObjectMode: 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
StringDecoderor encoding applies.
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);
}
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 withcallback(err). In_read(), report it withthis.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). Callcallback(err)at the end, passing along the error you received. WithautoDestroy: 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 baresource.pipe(dest)does not destroy the source when the destination fails, which is why implementers and consumers should usepipeline(). -
The
signaloption accepts anAbortSignal; aborting it behaves likedestroy(new AbortError())on the stream.
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();
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.
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']]);
});
✔/ℹ 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
Source: Node.js API: Test runner
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 ofawait. -
A stateless transform is
(chunks, options) ⇒ result, called once per batch; it receivesnullas 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()andshare()split one source across several consumers. -
Consumers include
text(),bytes(),array()andpipeTo(). Classic streams interoperate: with the flag on, aReadableimplements theStream.toAsyncStreamableprotocol (added in v26.1.0, Experimental), sofrom(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).
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 }))));
(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
| Version | Change |
|---|---|
v22.0.0 |
The default |
v22.0.0, v20.13.0 |
|
v24.0.0, v22.17.0 |
|
v25.9.0 |
|
v26.1.0 |
|
v14.0.0, v15.0.0, v15.5.0 |
Older, still relevant for tutorials: |
Source: Node.js API: Stream
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)plusReadable.call(this, options)was the only way before ES2015 classes; today useclass extendsor the simplified constructor options. The same era had the third-partyreadable-streampackage 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. Usepipeline()(thestream/promisesform withawait), which destroys every stage on failure; your_destroy()then runs reliably. -
Calling
callback()orpush()inconsistently, or throwing. Old snippets often throw inside_transform(); the supported contract iscallback(err). -
Manual byte decoding.
chunk.toString()per chunk breaks multi-byte characters at chunk boundaries; useStringDecoder,TextDecoderwith{ 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 awaitandpipeline()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
| Mistake | Effect and fix |
|---|---|
Not calling |
The stream stalls silently. Make sure every path, including early returns and |
Ignoring the |
The internal buffer grows without limit for a fast source. Stop pushing on |
Pushing after |
Raises an error ( |
Overriding |
Breaks stream invariants. Implement the underscore methods. |
Using |
Corrupts multi-byte characters across chunks. Use |
Mixing object and byte mode at a boundary |
Type errors or garbled output. Set |
Leaking the resource on failure |
File handles and sockets stay open. Release them in |
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
-
Node.js API: Stream, https://nodejs.org/docs/latest/api/stream.html
-
Node.js API: Stream, API for stream implementers, https://nodejs.org/docs/latest/api/stream.html#api-for-stream-implementers
-
Node.js API: Iterable Streams (stream/iter), https://nodejs.org/docs/latest/api/stream_iter.html
-
Node.js API: String decoder, https://nodejs.org/docs/latest/api/string_decoder.html
-
Node.js API: Test runner, https://nodejs.org/docs/latest/api/test.html
-
Node.js Learn: How to use streams, https://nodejs.org/learn/modules/how-to-use-streams
-
Node.js Learn: Backpressuring in streams, https://nodejs.org/learn/modules/backpressuring-in-streams
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/