NodeJSPiping Streams

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

JS
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 backpressure
`pipe` automatically manages flow and backpressure
`pipe` attaches a `data` listener to the source (putting it in flowing mode), calls `dst.write()` for each chunk, and — the important part — pauses the source when `write()` returns `false`, resuming on `drain`. It also calls `dst.end()` when the source emits `end`. This is exactly the manual dance from the writable-streams page, done for you.
Chaining pipes

pipe returns the destination stream, so you can chain through transforms — this is the elegant, readable form everyone reaches for first:

JS
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
`pipe` does NOT forward errors or clean up on failure
If any stream in a `pipe` chain emits `error`, `pipe` does **not** propagate it to the others and does **not** destroy them. The failed stream is left dangling — its file descriptor stays open (a resource leak), and an unhandled `error` event **crashes your process**. You must attach an `error` handler to *every* stream in the chain yourself, and manually destroy the rest. Almost nobody does this correctly, which is why `pipe` is now considered a footgun for anything beyond throwaway scripts.

What correct error handling with raw pipe looks like (tedious)

JS
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:

JS
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
}
`pipeline` is the modern default
Reach for `pipeline` (promise form from `node:stream/promises`, or callback form from `node:stream`) for essentially all real piping. It's covered in depth on its own page: [stream.pipeline](/nodejs/stream-pipeline). Keep `.pipe()` in mind only for the simplest cases or when reading older code.
pipe vs pipeline at a glance

Concern

.pipe()

pipeline()

Backpressure

Yes

Yes

Error propagation

No — per-stream handlers

Yes — one place

Cleanup on failure

No — manual destroy

Yes — destroys all

Completion signal

finish/end events

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:

JS
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()
With `{ end: false }` you own closing the destination
Normally `pipe` calls `dst.end()` for you when the source ends. Disable that with `{ end: false }` and the writable stays open — you must call `out.end()` yourself once all sources are done, or the file/response never completes. Easy to forget.
Unpiping

JS
src.pipe(dst)
// Stop the flow without ending the destination:
src.unpipe(dst)
// Pause the source entirely:
src.pause()
Next
The reason pipes can't be a tight loop — and how to handle a fast source feeding a slow sink: [Stream Backpressure](/nodejs/stream-backpressure).