Readable & Writable Patterns
Readable and Writable streams share modes and flow control primitives - choose byte vs object mode up front and match producer chunk sizes to consumer throughput.
Recipe
import { Readable, Writable } from 'node:stream';
// Byte mode (default)
const byteStream = Readable.from([Buffer.from('ab'), Buffer.from('cd')]);
// Object mode
const objectStream = Readable.from([{ id: 1 }, { id: 2 }], { objectMode: true });When to reach for this:
- Parsing CSV/NDJSON line objects from disk
- Writing aggregated metrics objects to a batch writer
- Tuning
highWaterMarkfor memory vs latency - Implementing custom
Readablefrom async data source
Working Example
import { Readable, Writable } from 'node:stream';
class CounterSource extends Readable {
private i = 0;
constructor(private max: number, opts?: ConstructorParameters<typeof Readable>[0]) {
super({ objectMode: true, ...opts });
}
_read(): void {
if (this.i >= this.max) {
this.push(null);
return;
}
this.push({ n: this.i++ });
}
}
const batchWriter = new Writable({
objectMode: true,
write(obj: { n: number }, _enc, cb) {
// simulate DB batch insert
setImmediate(cb);
},
});
const source = new CounterSource(1000);
source.pipe(batchWriter);// Paused mode - explicit read()
const r = Readable.from(['a', 'b', 'c']);
r.on('readable', () => {
let chunk;
while ((chunk = r.read()) !== null) {
console.log(chunk);
}
});What this demonstrates:
- Custom
Readableimplements_readand pushes untilnullends stream - Object mode
Writablereceives typed objects - batch in_writeor accumulate for bulk insert - Paused mode uses
readableevent +read()- useful for parsing framing - Flowing mode (
dataevent orpipe) is default for most I/O
Deep Dive
How It Works
- Flowing mode - data auto-emitted via
dataevents orpipe. - Paused mode - consumer calls
read(n)to pull chunks. highWaterMark- internal buffer threshold; exceeding triggers backpressure (falsefromwrite).cork/uncorkon Writable batches small writes into fewer syscalls.
Mode Matrix
| Mode | Chunk type | highWaterMark unit | Typical use |
|---|---|---|---|
| Byte (default) | Buffer/string | bytes | Files, HTTP, gzip |
| objectMode | any JS value | object count | Records, events |
TypeScript Notes
import { Readable } from 'node:stream';
function linesFromFile(path: string): Readable {
return Readable.from(
(async function* () {
const { createReadStream } = await import('node:fs');
const { createInterface } = await import('node:readline');
const rl = createInterface({ input: createReadStream(path) });
for await (const line of rl) yield line;
})(),
);
}Gotchas
- Mixing object and byte streams in one pipe - type confusion. Fix: explicit Transform to convert.
- Pushing after
null- throws. Fix: track ended state in custom Readable. - Ignoring
writereturn false - memory blowup. Fix: wait fordrainevent. - Tiny
highWaterMarkon high-latency I/O - syscall overhead. Fix: benchmark 16-64 KB for files. - Object mode with huge objects - each object may be MB. Fix: stay byte mode or stream references.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
for await on async iterable | Simple consumption | Need backpressure to slow disk |
readline interface | Line-based text | Binary protocols |
| EventEmitter chunks | Legacy code | Greenfield pipelines |
| Web ReadableStream | fetch body interop | Pure node:fs path |
FAQs
What is default highWaterMark?
16 KiB for byte streams, 16 objects for object mode - adjust per workload.
When use paused mode?
When you need precise control over consumption rate or partial reads for framing.
Can Writable sync write block?
Yes - long sync _write blocks event loop - offload or use worker threads.
What is _final?
Writable hook after last chunk - flush buffers, complete async work before finish event.
How does objectMode affect pipe?
Both sides must use objectMode or Transform between byte and object worlds.
Readable.from vs new Readable?
from for iterables; subclass when pull source needs stateful _read logic.
Does encoding option on Readable help?
Produces string chunks instead of Buffers - match downstream expectations.
What is destroy()?
Abrupt teardown with optional error - pipeline calls on failure paths.
How do Duplex streams differ?
Independent read/write sides - TCP sockets - see Transform & Duplex.
Can I pipe multiple writables?
Use tee pattern or PassThrough branches - never double-pipe one readable without split.
Are async _write callbacks required?
Call cb() when done - or return Promise from _write in modern stream API variants.
How does this relate to fetch body?
response.body is Web ReadableStream - convert with Readable.fromWeb when needed.
Related
- Streams Basics - four stream types
- Backpressure - pause/drain details
- Transform & Duplex - middle streams
- fs and fs/promises - createReadStream
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.