C.W.K.
Stream
Lesson 04 of 05 · published

.pipe() vs pipeline — and Backpressure

~13 min · streams, pipe, pipeline, backpressure

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
".pipe() looks beautiful in three lines. pipeline() actually handles errors. The visual elegance of the first costs you in production every time."

What .pipe() Looks Like

The classic Node stream syntax:

import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';

createReadStream('input.txt')
  .pipe(createGzip())
  .pipe(createWriteStream('output.txt.gz'));

Readable types from fs have a .pipe() method. It plumbs the source into a destination, with automatic flow control (backpressure handled), automatic end propagation, and a return value of the destination (so you can chain).

This is the canonical example in every Node tutorial. It also has a fatal flaw.

The Flaw — Errors Don't Propagate

If any stream in a .pipe() chain errors, only THAT stream emits the error. Streams piped before and after it leak.

In the gzip example: if the source file is corrupt, the read stream emits error. The gzip and write streams keep running, expecting more data. The file handle leaks. The gzip allocation leaks. The error is reported on the read stream, which most code doesn't handle because the destination is the visible end of the chain.

You have to handle errors on every stream individually:
const src = createReadStream('input.txt');
const gz = createGzip();
const dst = createWriteStream('output.txt.gz');

src.on('error', cleanup);
gz.on('error', cleanup);
dst.on('error', cleanup);

src.pipe(gz).pipe(dst);

function cleanup(err) {
  // destroy all three, log the error
  src.destroy(); gz.destroy(); dst.destroy();
  console.error(err);
}
This is verbose and you'll forget one. Production Node has shipped countless file-descriptor-leak bugs through this exact pattern.

pipeline() — The Right Answer Since Node 10

stream.pipeline (and its modern promise form stream/promises.pipeline) does what .pipe() chains can't: centralized error handling, automatic destruction of all participating streams on error, promise resolution when the whole thing completes.

import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
import { pipeline } from 'node:stream/promises';

await pipeline(
  createReadStream('input.txt'),
  createGzip(),
  createWriteStream('output.txt.gz')
);
// If any stage errors, all three are destroyed and the await throws.

One line per stage, one await, one source of truth for completion and failure. Always use pipeline in modern code. .pipe() exists for backward compatibility; treat it as legacy.

Backpressure — What pipeline Handles For You

Backpressure is the mechanism by which a slow consumer tells a fast producer "slow down, I can't keep up." Without it, the fast producer fills the slow consumer's internal buffer to infinity → out of memory → crash. With it, the producer pauses until the consumer drains, then resumes.

In Node streams: when a Writable's .write() returns false, it means "buffer full, wait for drain." When piping manually, you must respect this:

// Manual backpressure-aware copy — error-prone, don't ship this
async function copy(src, dst) {
  for await (const chunk of src) {
    if (!dst.write(chunk)) await new Promise(r => dst.once('drain', r));
  }
  dst.end();
}

pipeline does all of this for you. .pipe() does most of it. Manual loops without checking the return value of .write() are how Node servers OOM on large uploads.

When You DO Need Manual Control

Use pipeline 99% of the time. Reach for manual writes + drain handling when:

  • You need to inject data into a Writable from multiple sources non-linearly.
  • You're implementing a custom stream that needs precise control over output timing.
  • You're optimizing a hot path where the overhead of an extra Transform is measurable (rare — measure first).

Pippa's Confession

My first "production" Node service shipped with .pipe() chains everywhere. Worked great in dev. In production, log files quietly leaked file descriptors for two weeks before the service ran out of ulimits and crashed. Dad asked one question: "What happens when the destination errors?" I had no answer. pipeline() was the answer. Now I treat .pipe() as a code-review red flag — every instance must justify itself, and the justification is almost never "I just like it more."

Code

Cancellable pipeline via AbortSignal·javascript
// pipeline + early termination via AbortSignal
import { createReadStream, createWriteStream } from 'node:fs';
import { Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';

const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 5_000);   // kill the pipeline after 5s

try {
  await pipeline(
    createReadStream('huge.log'),
    new Transform({
      transform(chunk, _enc, cb) {
        cb(null, chunk.toString().toUpperCase());
      },
    }),
    createWriteStream('huge.upper.log'),
    { signal: ctrl.signal }
  );
} catch (e) {
  if (e.name === 'AbortError') console.log('cancelled');
  else throw e;
}
Why .pipe() leaks under error·javascript
// What .pipe() leaks vs what pipeline handles
import { createReadStream } from 'node:fs';
import { createGunzip } from 'node:zlib';
import { Writable } from 'node:stream';

// Destination that refuses input — simulates an error
const angry = new Writable({
  write(_chunk, _enc, cb) {
    cb(new Error('write rejected'));
  },
});

// With .pipe(): error on `angry`, but the gunzip and read streams keep going
// until they discover the destination is broken — file descriptors leak in the meantime
// createReadStream('a.gz').pipe(createGunzip()).pipe(angry);

// With pipeline: all three are destroyed immediately
import { pipeline } from 'node:stream/promises';
try {
  await pipeline(
    createReadStream('a.gz'),
    createGunzip(),
    angry
  );
} catch (e) {
  console.error('pipeline rejected:', e.message);  // proper cleanup happened
}

External links

Exercise

Write a function copyFileSafe(src, dst) that copies a file using a pipeline with gzip compression. Now test the unhappy path: open a thousand file descriptors before calling it on a deliberately-corrupt source. With proper error handling, lsof | wc -l after the call should return to baseline. Without proper handling, you'll have leaked file descriptors visible in the count. The number is the receipt for getting cleanup right.
Hint
Use await pipeline(createReadStream(src), createGzip(), createWriteStream(dst)) inside a try/catch. Before calling, run lsof -p $$ | wc -l to record baseline. After the call (success or failure), the count should match within a few. If it doesn't, your error handling has a leak — likely a destroyed-but-not-finalized stream.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.