Transform & Duplex
Transform streams sit in the middle of pipelines to parse, compress, or filter - Duplex streams model two-way channels like sockets with independent read and write sides.
Search across all documentation pages
Transform streams sit in the middle of pipelines to parse, compress, or filter - Duplex streams model two-way channels like sockets with independent read and write sides.
import { Transform, PassThrough } from 'node:stream';
import { pipeline } from 'node:stream/promises';
const tap = new PassThrough();
tap.on('data', (chunk) => metrics.bytes(chunk.length));
await pipeline(source, tap, destination);When to reach for this:
import { Transform, PassThrough, Duplex } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
const splitLines = new Transform({
transform(chunk, _enc, cb) {
const parts = String(chunk).split('\n');
const tail = parts.pop() ?? '';
for (const line of parts) if (line) this.push(line);
cb(null, tail); // buffer incomplete line in internal state via passing as chunk to next
},
flush(cb) {
cb();
},
});
function tee<T extends NodeJS.ReadableStream>(source: T): [T, PassThrough] {
const branch = new PassThrough();
source.pipe(branch);
return [source, branch];
}
// Duplex conceptual - net.Socket extends Duplex
const socketLike = new Duplex({
read() { /* pull from underlying resource */ },
write(chunk, _enc, cb) { cb(); /* send to peer */ },
});import { createGzip } from 'node:zlib';
await pipeline(
Readable.from(['{"a":1}\n', '{"b":2}\n']),
splitLines,
createGzip(),
process.stdout,
);What this demonstrates:
PassThrough is identity Transform - ideal for tap/tee side channelszlib.createGzip() is a Transform under the hoodread/write are independent - unlike Transform's linked flow_transform per chunk and optional _flush at end._read and _write separately - TCP, TLS sockets.| Need | Type |
|---|---|
| gzip, cipher | Transform |
| TCP socket | Duplex |
| tap metrics | PassThrough |
| parse lines | Transform |
import { Transform, type TransformCallback } from 'node:stream';
function createJsonParser(): Transform {
let buffer = '';
return new Transform({
objectMode: true,
transform(chunk: Buffer, _enc: BufferEncoding, cb: TransformCallback) {
buffer += chunk.toString('utf8');
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
try {
for (const line of lines) if (line) this.push(JSON.parse(line));
cb();
} catch (err) {
cb(err as Error);
}
},
});
}cb(err) once._flush pushes remainder.| Alternative | Use When | Don't Use When |
|---|---|---|
readline | Line-based text files | Binary frames |
| Async generator map | Small in-memory transforms | Need backpressure |
| Dedicated worker thread | CPU-heavy per-chunk work | Light string ops |
| Message queue fanout | Multiple slow consumers | In-process only |
Transform output is derived from input in one pipeline. Duplex read/write are independent channels.
Monitoring, tee branches, connecting Web and Node streams with Duplex.fromWeb.
Two PassThrough branches or multicast pattern - watch backpressure on both.
Use async generator via Readable.from or call cb after await in _transform carefully once.
Yes - createGzip, createGunzip are Transform streams.
pipeline(Readable.from([input]), transform, collectWritable) assert output chunks.
Runs when upstream ends - emit trailing buffered bytes/objects.
Yes via pipeline - order is source → parse → compress → sink.
crypto.createCipheriv returns Transform - same error/pipeline rules.
Readable.fromWeb / Writable.toWeb bridge fetch bodies and Node pipelines.
Use byte Transform before/after to convert - e.g., bytes → JSON objects → bytes.
Avoid per-chunk toString on huge buffers - operate on Buffer slices where possible.
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.
Reviewed by Chris St. John·Last updated Jul 16, 2026