Skip to content

Backpressure

Imagine a fire hose attached to a garden sprinkler. If water arrives faster than the sprinkler can spray it, the hose eventually bursts. The same thing happens in Node streams: if a fast Readable source produces data faster than a slow Writable can consume it, buffered chunks pile up in memory until the process runs out of RAM or crashes.

Backpressure is the mechanism by which a slow consumer tells a fast producer to slow down. It is the stream equivalent of flow control in TCP.

The Writable stream’s write() method is the key signal:

  • Returns true — the internal buffer is below the high-water mark (default on Node 24: 65536 bytes (64 KiB) for byte-mode streams — both Readable and Writable — or 16 objects for object-mode streams; fs.createReadStream uses this same default, there is no separate exception). The producer can keep sending.
  • Returns false — the buffer is at or above the high-water mark. The producer must pause and wait for the drain event before writing more.

The high-water mark is not a hard limit; it is a soft advisory. Node buffers chunks beyond it, but doing so costs memory. Respecting the signal keeps memory usage flat.

Before pipe and pipeline, you had to handle this yourself:

function pumpWithBackpressure(readable, writable) {
readable.on('data', function(chunk) {
var ok = writable.write(chunk);
if (!ok) {
readable.pause(); // stop the source
writable.once('drain', function() {
readable.resume(); // resume when ready
});
}
});
readable.on('end', function() {
writable.end();
});
}

This pattern is verbose and error-prone. One forgotten readable.pause() call causes the buffer to grow without bound.

How pipe and pipeline handle it automatically

Section titled “How pipe and pipeline handle it automatically”

readable.pipe(writable) and stream.pipeline() implement the above pattern internally. They:

  1. Forward each chunk from the readable to the writable.
  2. Pause the readable when write() returns false.
  3. Resume the readable on the drain event.

You never need to manage pausing and resuming manually when using pipe or pipeline. This is one of the primary reasons to prefer them over raw event listeners.

Measuring what ignoring backpressure actually costs

Section titled “Measuring what ignoring backpressure actually costs”

Talking about “unbounded memory growth” is abstract. This demo makes it concrete: the same 2,000-chunk source is drained twice into the same slow sink — once through a data listener that ignores write()’s return value, and once through .pipe(), which respects it. Both runs track the peak number of chunks buffered in the sink at once:

Node.js
Output (verified on Node 24)
ignoring backpressure -- peak buffered chunks: 2000
using pipe() -- peak buffered chunks: 1

Ignoring the source entirely (never calling .pause()) lets every one of the 2,000 chunks queue up in the sink at once — the source never gets the signal to slow down. .pipe() keeps the sink’s buffer at its high-water mark of 1 the entire time, because it pauses the source the instant write() returns false.

Visualising backpressure with custom high-water marks

Section titled “Visualising backpressure with custom high-water marks”

The demo below creates a Readable and a Writable with a very small high-water mark (1 byte) to make backpressure trigger on every chunk. Watch how the readable is automatically paused and resumed.

Node.js
Output (verified on Node 24)
sink writing: a
sink writing: b
sink writing: c
sink writing: d
sink writing: e
done — pipeline handled backpressure automatically
flowchart TB
  Fast["fast Readable source"] -->|"data listener, no pause()"| Ignored["chunks queue up in the sink without limit"]
  Fast -->|".pipe() / pipeline()"| Managed["source is paused the instant write() returns false"]
  Managed -->|"sink emits 'drain'"| Resume["source is resumed"]
  Resume --> Managed
ignoring write()'s signal lets a fast source flood a slow sink; pipe()/pipeline() pause the source until drain fires
ChoiceBenefitCost
Ignoring write()’s return valueSimplest possible codeUnbounded memory growth the moment the source outpaces the sink — an eventual OOM crash under real load
Manual pause/resume on drainFull control over exactly when to slow downVerbose, and every additional stream in a chain multiplies the chances of missing a case
.pipe() / pipeline()Backpressure handled correctly with zero manual bookkeepingLess visibility into exactly when pausing happens — you trust the implementation
  • “A data listener is safe as long as I don’t console.log too much” — fact: the danger isn’t logging, it’s writing chunks to a slower destination (a socket, a file, another API) without checking whether it can keep up — that’s what actually accumulates in memory.
  • “Backpressure is a Node-specific concept” — fact: it’s the general problem of a fast producer overwhelming a slow consumer — the same idea as TCP flow control, and Node streams solve it the same way (a signal to pause, and a signal to resume).
  • “A small high-water mark means small memory usage overall” — fact: it bounds how much one stream buffers, but if you ignore its write() signal entirely (as the demo above shows), the high-water mark is irrelevant — the caller keeps writing regardless.
  • Does a data listener write to (or otherwise hand off to) something that could be slow — a socket, another stream, an external API call — without ever checking that destination’s own backpressure signal?
  • Does code implement a custom pump between two streams (rather than using .pipe()/pipeline()) without a drain listener anywhere?
  • Is there a comment claiming “handles backpressure” next to code that never calls .pause(), checks write()’s return value, or delegates to .pipe()/pipeline()?

🐛 Spot the bug

const { Readable } = require('node:stream');
const net = require('node:net');
function forwardToSocket(readable, socket) {
readable.on('data', function(chunk) {
socket.write(chunk); // return value never checked, readable never paused
});
}
Show the answer

forwardToSocket never checks socket.write(chunk)’s return value and never calls readable.pause(). If the network socket is slower than the readable’s data rate (a common situation over a real network, versus a fast in-process source), Node keeps buffering unsent chunks in the socket’s internal write buffer without limit, and memory grows until the process runs out or the connection is dropped. The fix is either the manual pause/drain pattern from earlier in this lesson, or simply readable.pipe(socket), which applies that exact pattern automatically.

What does it mean when writable.write() returns false?
What event should you listen for to know when it is safe to resume writing after backpressure?
How do readable.pipe() and stream.pipeline() help with backpressure?
A 2,000-chunk source is drained into a slow sink two ways: (1) a data listener that calls sink.write() without checking its return value, and (2) source.pipe(sink). What is the expected peak number of chunks buffered in the sink for each?
Why is a small default high-water mark (like 16 objects in object mode) not automatically enough to prevent memory problems?