Backpressure
The problem backpressure solves
Section titled “The problem backpressure solves”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.
How backpressure works in Node
Section titled “How backpressure works in Node”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.createReadStreamuses 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 thedrainevent 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.
Manual backpressure
Section titled “Manual backpressure”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:
- Forward each chunk from the readable to the writable.
- Pause the readable when
write()returnsfalse. - Resume the readable on the
drainevent.
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:
Output (verified on Node 24)
ignoring backpressure -- peak buffered chunks: 2000
using pipe() -- peak buffered chunks: 1Ignoring 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.
Output (verified on Node 24)
sink writing: a
sink writing: b
sink writing: c
sink writing: d
sink writing: e
done — pipeline handled backpressure automaticallyMental Model
Section titled “Mental Model”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
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Ignoring write()’s return value | Simplest possible code | Unbounded memory growth the moment the source outpaces the sink — an eventual OOM crash under real load |
Manual pause/resume on drain | Full control over exactly when to slow down | Verbose, and every additional stream in a chain multiplies the chances of missing a case |
.pipe() / pipeline() | Backpressure handled correctly with zero manual bookkeeping | Less visibility into exactly when pausing happens — you trust the implementation |
Common Misconceptions
Section titled “Common Misconceptions”- “A
datalistener is safe as long as I don’tconsole.logtoo 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.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a
datalistener 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 adrainlistener anywhere? - Is there a comment claiming “handles backpressure” next to code that never calls
.pause(), checkswrite()’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.