Backpressure, pipeline and why you should stop using .pipe()
Streams process data without holding it in memory. They are also where Node applications leak, hang and lose errors — almost always for the same three reasons.
| Type | Does | Example |
|---|---|---|
Readable |
Produces data | fs.createReadStream, an HTTP request |
Writable |
Consumes data | fs.createWriteStream, an HTTP response |
Duplex |
Both, independently | A TCP socket |
Transform |
Both, output derived from input | zlib.createGzip |
| Pattern | Note |
|---|---|
await pipeline(src, transform, dest) |
The right default — propagates errors and cleans up |
src.pipe(dest) |
Does NOT forward errors and leaks on failure |
for await (const chunk of readable) |
Async iteration; honours backpressure automatically |
Readable.from(asyncGenerator()) |
Turn any async generator into a stream |
stream.destroy(err) |
Tear down and propagate |
The composition that propagates errors and cleans up. .pipe() does neither.
import { pipeline } from 'node:stream/promises';
import { Transform } from 'node:stream';
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
const upper = new Transform({
transform(chunk, _enc, cb) {
cb(null, chunk.toString().toUpperCase());
},
});
try {
await pipeline(
createReadStream('in.txt'),
upper,
createGzip(),
createWriteStream('out.txt.gz'),
);
console.log('done');
} catch (err) {
console.error('stream failed:', err); // reachable, unlike with .pipe()
}
It does not forward errors and does not clean up on failure, so a broken destination leaks the source handle and your catch block never runs. pipeline() fixes both.
write() returning false means the buffer is full. Ignoring it means the unread data piles up in memory. pipeline() and for-await both handle it; a hand-rolled loop must wait for "drain".