Duplex & Transform Streams
The two remaining stream types combine reading and writing. A Duplex stream is readable and writable with two independent channels — a TCP socket is the classic example. A Transform is a special duplex where what you write comes back out modified — gzip, encryption, and parsers are all transforms. These are the building blocks of stream pipelines.
Duplex: two independent channels
A duplex's readable and writable sides are separate. Writing to it does not feed its readable side — they're like two pipes bundled together. A network socket is the perfect mental model: what you write() goes out to the peer; what you read in is what the peer sent — totally unrelated streams of bytes.
import { connect } from 'node:net'
const socket = connect(80, 'example.com') // a Duplex stream
// Writable side — send to the server:
socket.write('GET / HTTP/1.0\r\n\r\n')
// Readable side — receive from the server (independent):
for await (const chunk of socket) {
process.stdout.write(chunk)
}Transform: write in, modified data out
A Transform stream sits in the middle of a pipeline: data flows in the writable side, your transform function processes it, and the result flows out the readable side. This is the most common custom stream you'll write.
import { Transform } from 'node:stream'
const upperCase = new Transform({
transform(chunk, _encoding, callback) {
// push the modified chunk downstream...
this.push(chunk.toString().toUpperCase())
callback() // ...then signal "ready for next chunk"
},
})
process.stdin.pipe(upperCase).pipe(process.stdout)transform() vs flush()
transform runs per chunk; the optional flush runs once after the last chunk, before the stream ends — the place to emit anything you've been buffering (a final delimiter, a checksum, a remaining partial record):
import { Transform } from 'node:stream'
// Buffer partial lines across chunks, emit complete lines:
const splitLines = new Transform({
readableObjectMode: true,
transform(chunk, _enc, cb) {
this._buf = (this._buf || '') + chunk.toString()
const parts = this._buf.split('\n')
this._buf = parts.pop() // keep the trailing partial line
for (const line of parts) this.push(line)
cb()
},
flush(cb) {
if (this._buf) this.push(this._buf) // emit the final partial line
cb()
},
})Asymmetric object modes
A transform can have different modes on each side — bytes in, objects out, or vice versa. This is how parsers work: consume a byte stream, emit structured records.
import { Transform } from 'node:stream'
// Bytes in (writable side), objects out (readable side):
const parseJsonLines = new Transform({
writableObjectMode: false, // accept Buffer/string chunks
readableObjectMode: true, // emit parsed objects
transform(chunk, _enc, cb) {
for (const line of chunk.toString().split('\n')) {
if (line.trim()) this.push(JSON.parse(line))
}
cb()
},
})Option | Effect |
|---|---|
| Both sides handle objects |
| Input side accepts objects |
| Output side emits objects |
Built-in transforms you already use
You rarely need a custom transform for common jobs — Node ships fast, battle-tested ones. They slot straight into a pipeline:
import { createReadStream, createWriteStream } from 'node:fs'
import { createGzip } from 'node:zlib'
import { pipeline } from 'node:stream/promises'
// zlib.createGzip() is a Transform: bytes in → compressed bytes out
await pipeline(
createReadStream('app.log'),
createGzip(), // the transform in the middle
createWriteStream('app.log.gz'),
)Module | Transforms |
|---|---|
|
|
|
|
|
|
The simplest way: a generator transform
Modern Node lets you drop an async generator directly into pipeline as a transform stage — no Transform subclass, no callbacks, just yield:
import { pipeline } from 'node:stream/promises'
import { createReadStream, createWriteStream } from 'node:fs'
await pipeline(
createReadStream('in.txt', 'utf8'),
async function* (source) { // a transform, as a generator
for await (const chunk of source) {
yield chunk.toUpperCase() // each yield is an output chunk
}
},
createWriteStream('out.txt'),
)Quick reference
Need | Use |
|---|---|
Independent read + write (socket-like) |
|
Modify data in flight |
|
Emit trailing/buffered data |
|
Parse bytes → records | Asymmetric object mode |
Observe without changing |
|
Compress / encrypt |
|