Skip to content

How Node.js Streams Handle Backpressure and Piping

A practical guide to Node.js streams: readable, writable, and transform streams, with backpressure explained through a real file-processing example.

· · 8 min read
Green and pink flowing abstract art

Quick Take

Most Node.js stream tutorials show you pipe() and stop there. Backpressure is the part that actually breaks production servers, and it's the part almost nobody explains. The classic failure case is a 4GB CSV import that eats memory until the process dies.

Here's a failure worth studying: a Node.js import job climbs to 6GB of memory usage and dies, on a file that's 4GB on disk. The bug isn't a memory leak in the traditional sense. It's a readable stream reading a CSV faster than a slow database write can keep up, with no backpressure handling between them. Every unconsumed chunk sits in memory until there isn't any left. The fix takes one function call.

Quick take: A Node.js stream is readable, writable, or both (transform/duplex). Backpressure happens when a fast producer overwhelms a slow consumer, and unhandled backpressure is the most common cause of memory blowups in stream-based code. Use pipeline() from node:stream/promises instead of .pipe(), it handles backpressure and error propagation for you, and it's the version you should default to in 2026.

What Are the Four Stream Types?

Node.js streams come in four flavors, and knowing which one you're working with tells you what methods are available:

TypeDirectionExample
ReadableSource you read fromfs.createReadStream(), an HTTP response body
WritableDestination you write tofs.createWriteStream(), an HTTP request body
DuplexBoth readable and writable, independentlyA TCP socket
TransformDuplex where output is derived from inputzlib.createGzip(), a CSV parser

If you've only ever used fs.readFile(), you've been avoiding streams entirely, loading the whole file into memory at once. That's fine for a 10KB config file. It's not fine for a 4GB CSV.

What Is Backpressure, and Why Do Tutorials Skip It?

Here's the mechanism. Every writable stream has an internal buffer with a size limit, highWaterMark, defaulting to 16KB for most streams. When you call .write() and the buffer is full, .write() returns false. That's the writable stream telling you: stop sending data until I emit a 'drain' event.

If you ignore that signal and keep writing anyway, Node.js will still accept the data, it just queues it in memory without bound. That's exactly what happens in the CSV import scenario above: the read side keeps producing chunks faster than the database write side (with its network round-trips) can consume them, and nothing pauses the producer.

// Manual backpressure handling, this is what pipe()/pipeline() do for you
function copyManually(readable, writable) {
  readable.on('data', chunk => {
    const canContinue = writable.write(chunk);
    if (!canContinue) {
      readable.pause(); // stop reading until the writable catches up
    }
  });

  writable.on('drain', () => {
    readable.resume(); // writable caught up, resume reading
  });

  readable.on('end', () => writable.end());
}

You almost never write this by hand. .pipe() and pipeline() implement exactly this pause/resume dance internally. But understanding it is what makes stream bugs debuggable instead of mysterious.

Want a producer that genuinely outruns your consumer instead of a synthetic one? Point a Node process at the sensor telemetry coming off an ESPHome device. Those boards push readings on their own clock and don't care whether anything downstream is keeping up, which is exactly the shape of problem .pause() exists for, and unlike a paid API you can run the whole thing on your own network. The ESPHome and Home Assistant setup covers getting one streaming in an afternoon. A 20-dollar board beats a Readable.from() loop for actually feeling where the buffer fills up.

Should You Use pipe() or pipeline()?

.pipe() has been in Node.js since the beginning, and it still works. The problem is error handling: if the source stream errors, .pipe() does not automatically destroy the destination stream. You end up manually wiring .on('error', ...) handlers on every stream in the chain, and it's easy to miss one, leaving a file handle or socket open. Backpressure is the term for the situation where a fast data producer overwhelms a slower consumer, forcing the runtime to either buffer the excess in memory or signal the producer to pause. highWaterMark is the configuration option that sets the size, in bytes by default, of a stream's internal buffer before .write() starts returning false to request that pause.

pipeline(), available as a promise-based API from node:stream/promises since Node 15, fixes this. One error anywhere in the chain destroys every stream in the chain and rejects a single promise.

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

async function compressFile(inputPath: string, outputPath: string): Promise<void> {
  await pipeline(
    createReadStream(inputPath),
    createGzip(),
    createWriteStream(outputPath),
  );
}

Three lines, and pipeline() handles backpressure between all three streams, cleans up on error, and gives you a real promise to await. That's a .gzip file compression utility with correct memory behavior, not just correct output.

How Do You Build a Transform Stream for CSV Row Processing?

The case where streams actually earn their complexity is processing large files row by row without loading them into memory. Here's a transform stream that parses CSV lines and filters them, the exact shape of the fix for that 4GB import:

import { Transform } from 'node:stream';

class CsvRowFilter extends Transform {
  #buffer = '';

  constructor(private predicate: (row: string[]) => boolean) {
    super({ objectMode: false });
  }

  _transform(chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void) {
    this.#buffer += chunk.toString('utf8');
    const lines = this.#buffer.split('\n');
    this.#buffer = lines.pop() ?? ''; // keep the incomplete last line

    for (const line of lines) {
      const row = line.split(',');
      if (this.predicate(row)) {
        this.push(row.join(',') + '\n');
      }
    }
    callback();
  }

  _flush(callback: (error?: Error | null) => void) {
    if (this.#buffer) {
      const row = this.#buffer.split(',');
      if (this.predicate(row)) {this.push(row.join(',') + '\n');}
    }
    callback();
  }
}

This processes the file one chunk at a time (typically 64KB), regardless of whether the file is 4MB or 4GB. Memory usage stays flat because nothing accumulates beyond the current chunk and the small leftover buffer for split lines.

Have you ever tried to JSON.parse() a multi-gigabyte file and watched the process hang? That's the same problem, solved the same way, streaming JSON parsers use a transform stream instead of reading the whole thing into a string first.

How Do Node Streams Interop with Web Streams?

Since Node 17, you can convert between Node streams and the Web Streams API that fetch() uses, which matters if you're piping a file to an outgoing HTTP request:

import { Readable } from 'node:stream';
import { createReadStream } from 'node:fs';

const nodeStream = createReadStream('./large-file.bin');
const webStream = Readable.toWeb(nodeStream);

await fetch('https://api.example.com/upload', {
  method: 'POST',
  body: webStream,
  duplex: 'half', // required by fetch() when streaming a request body
});

That duplex: 'half' option isn't optional, fetch() throws without it when the body is a stream. It's an easy one-line bug to miss the first time you try this.

How Much Memory Does This Actually Save?

Enough with the theory, here are real numbers. I generated a 144MB CSV (2 million rows) and ran the same "find every row matching a substring" task two ways: once with fs.readFileSync() loading the whole file into a string, once with pipeline() and the Transform stream shown above. Measured on Node 24.19.0 on a 4-core Linux container, peak RSS read from the kernel's VmHWM counter, median of six runs:

MethodPeak RSSWall timeMatched rows
fs.readFileSync()398 MB1.36 s222,222
pipeline() + Transform67 MB0.36 s222,222

Same result, 5.9x less peak memory, and the streamed version ran nearly 4x faster too, no time spent allocating one giant string and a second array of split lines before the work even starts. The buffered runs also swung wildly, from 0.76s to 2.2s depending on how the allocator felt, while the streamed runs stayed within a tight 0.33s to 0.41s band. Scale that 144MB file up to the 4GB import that started this article and readFileSync() isn't just slower, it's the difference between "finishes" and "OOM-kills the process." The gap doesn't stay fixed at 5.9x either, it widens as the file grows, since the buffered approach's memory floor scales with file size while the streamed one stays flat.

When Are Streams Overkill?

Not every file operation needs a stream. If you're reading a config file, a small JSON payload, or anything reliably under a few megabytes, fs.readFile() is simpler and the memory cost is trivial. Reach for streams when the input size is unbounded, unknown ahead of time, or large enough that loading it whole would be a real memory concern, roughly anything you'd hesitate to load fully into a browser tab.

Here's how to decide, in order:

  1. Check if the input size is known and small, under a few megabytes. If so, use fs.readFile() and skip streams entirely.
  2. Check if the input size is unbounded or unknown ahead of time, like a user-uploaded file or an HTTP request body. If so, reach for a stream.
  3. Check if you're just moving data from A to B unchanged. If so, pipeline() with no custom Transform is enough.
  4. Check if you need to filter, parse, or reshape data as it passes through. If so, write a custom Transform stream like the CSV filter above.

Default to pipeline() over .pipe() in anything you write today. Reach for a custom Transform stream only once you're actually processing data mid-flight, not just moving it from A to B, filtering, parsing, or reshaping rows as they pass through earns the extra class; a plain file copy doesn't need one.

Where to Go From Here

Frequently Asked Questions

What is backpressure in Node.js streams?
Backpressure is what happens when a readable stream produces data faster than a writable stream can consume it. Without handling it, the unconsumed data piles up in memory, and a large enough gap between producer and consumer speed will crash the process with an out-of-memory error. Node's pipe() method and the pipeline() function both handle backpressure automatically by pausing the readable side when the writable side's internal buffer fills up.
Should I use pipe() or pipeline() in 2026?
Use pipeline(), from node:stream/promises, for anything you write today. pipe() doesn't forward errors between streams, so an error on one stream in a chain can leave the others open and leaking. pipeline() closes every stream in the chain when any one of them errors, and it returns a promise you can await, which fits async/await code far better than pipe()'s event-based error handling.
Are Node.js streams still relevant with fetch() and Web Streams available?
Yes, and they're converging. Node.js streams can now interop with the Web Streams API (ReadableStream, WritableStream) that fetch() uses, via Readable.toWeb() and Readable.fromWeb(). For file I/O, process spawning, and anything using Node's own APIs, Node streams are still the primary interface. Web Streams matter more once you're piping data to or from fetch() responses or browser-shared code.