Piping Streams
Piping connects a readable's output directly to a writable's input, so data flows automatically — and, crucially, with backpressure handled for you. readable.pipe(writable) is the original mechanism and reads beautifully, but it has a serious flaw around error handling that has bitten countless Node programs. This page covers pipe honestly: how it works, where it leaks, and why pipeline superseded it.
The basic pipe
import { createReadStream, createWriteStream } from 'node:fs'
const src = createReadStream('input.txt')
const dst = createWriteStream('output.txt')
src.pipe(dst) // copies input → output, chunk by chunk, with backpressureChaining pipes
pipe returns the destination stream, so you can chain through transforms — this is the elegant, readable form everyone reaches for first:
import { createReadStream, createWriteStream } from 'node:fs'
import { createGzip } from 'node:zlib'
createReadStream('app.log')
.pipe(createGzip()) // returns the gzip transform
.pipe(createWriteStream('app.log.gz'))The fatal flaw: errors don't propagate
What correct error handling with raw pipe looks like (tedious)
const src = createReadStream('input.txt')
const gzip = createGzip()
const dst = createWriteStream('output.gz')
// You must handle errors on EACH stream — miss one and the process can crash:
src.on('error', onError)
gzip.on('error', onError)
dst.on('error', onError)
function onError(err) {
console.error(err)
src.destroy(); gzip.destroy(); dst.destroy() // manual cleanup
}
src.pipe(gzip).pipe(dst)The fix: use pipeline instead
stream.pipeline does everything pipe does plus propagates errors, destroys every stream on failure, and tells you when the whole thing is done. The same task, done safely:
import { createReadStream, createWriteStream } from 'node:fs'
import { createGzip } from 'node:zlib'
import { pipeline } from 'node:stream/promises'
try {
await pipeline(
createReadStream('app.log'),
createGzip(),
createWriteStream('app.log.gz'),
)
console.log('done')
} catch (err) {
console.error('pipeline failed:', err) // ONE place handles all errors
}pipe vs pipeline at a glance
Concern |
|
|
|---|---|---|
Backpressure | Yes | Yes |
Error propagation | No — per-stream handlers | Yes — one place |
Cleanup on failure | No — manual | Yes — destroys all |
Completion signal |
| Promise / callback |
Verdict | Quick scripts only | Default for everything |
Useful pipe options
When you do use pipe, { end: false } keeps the destination open after the source finishes — handy for piping multiple sources into one writable in sequence:
import { createReadStream, createWriteStream } from 'node:fs'
const out = createWriteStream('combined.txt')
createReadStream('a.txt').pipe(out, { end: false }) // don't close yet
// ...later, after 'a.txt' finishes, pipe 'b.txt', then out.end()Unpiping
src.pipe(dst) // Stop the flow without ending the destination: src.unpipe(dst) // Pause the source entirely: src.pause()