Node's own guide to backpressure ships a benchmark in which the wrong version looks good: gzipping a ~9 GB file through a .pipe() chain peaks at 87.81 MB of resident memory, while the same code with the return value of write() ignored peaks at 1.52 GB and finishes about four seconds sooner. Neither run errors. highWaterMark is not a cap on that buffer — it is the point at which a stream asks the producer to stop, and nothing in Node makes the producer listen.

Peak RSS, gzipping a 9 GB file through a pipeNode's official backpressure guide, same code both ways; the second variant discards write()'s false return. Hardware and Node version are not stated on the page. · lower is better
Backpressure respected
87.81MB
Backpressure ignored~17x
1 520MB

The documentation is blunt about where that second bar ends up on a longer run. Ignore the signal and the stream "will buffer all written chunks until maximum memory usage occurs, at which point it will abort unconditionally". Not throw. Abort.

Most engineers read highWaterMark as a cap

The working model is a reasonable one: a stream owns a buffer, highWaterMark is its size, and writing into a full buffer either blocks or fails. That model predicts throughput behaviour well enough, and it is why tuning the value feels like tuning a queue depth.

It is wrong about memory, and the stream documentation says so in one sentence: "The highWaterMark option is a threshold, not a limit: it dictates the amount of data that a stream buffers before it stops asking for more data. It does not enforce a strict memory limitation in general."

One comparison decides every write

Everything the mechanism does on the write side comes down to a single line in lib/internal/streams/writable.js:

lib/internal/streams/writable.js, Node 26 main branch
const ret = state.length < state.highWaterMark || state.length === 0;

That boolean is what write() hands back. When it is false Node sets an internal kNeedDrain flag and returns, and the chunk it was given gets buffered regardless. state.length is a plain running counter incremented on every buffered write. No code path consults highWaterMark a second time to refuse anything.

So the buffer has no size. It has a reporting threshold.

What holds the overflow is state[kBufferedValue], an ordinary JavaScript array of { chunk, encoding, callback } objects with a bufferedIndex pointer instead of array-shifting, drained FIFO by clearBuffer() once the underlying resource is ready. If you learned this as a linked list, that was accurate through roughly Node 20.10: Writable moved to an array in 2020, and Readable followed in PR #50341, shipping in v21.2.0 and backported to v20.11.0. Both of those lines are now EOL.

drain is the other half of the protocol, and it is stingier than it looks. In afterWrite() it requires three conditions at once: the kNeedDrain flag was set, the buffered length has returned to state.length === 0, and the stream is neither ending nor destroyed. One event per backpressure episode, not one per freed chunk. This is why the docs insist you only attach once('drain', cb) after write() actually returned false — attach it optimistically and it will never fire, and your producer stalls forever waiting on an event that has no reason to exist.

The read side mirrors it. readable.push() returns false once state.length >= state.highWaterMark, and the stream stops calling the internal _read() until room frees up. Same shape, same advisory nature.

GC pause across the unbounded run
635ms

Node's guide reports pauses growing as the buffer does. Hardware and Node version are not stated.

Wall clock, respected vs ignored
58.8854.48s

The run that eats 1.52 GB is the faster one. Backpressure costs a little time and saves an order of magnitude of memory.

What the buffer looks like in a heap snapshot

A leaking stream has a distinctive shape in a snapshot, and it is not a big buffer. It is a long array of small wrapper objects retained by a WritableState, one entry per pending write, each holding a chunk reference and a callback.

Per-entry overhead is the part that breaks the "highWaterMark bounds my memory" intuition even for code that behaves. In issue #29310, opened by a Node streams maintainer, real usage runs to roughly 1 MB against a nominal 16 KB threshold, purely from those ~64-byte entries accumulating across many small writes. The chunks were being drained correctly. The bookkeeping was the memory.

The second thing to know is where the bytes are counted, because an old line from the Node docs still shapes how people read snapshots. "Buffers live entirely outside the V8 heap" appeared verbatim through roughly the v10 era and is absent from the current buffer.html; Buffer has been a Uint8Array subclass since 2015. The precise version: the wrapper object is on the V8 heap and fully visible to a snapshot, while the backing-store bytes are accounted under external and arrayBuffers in process.memoryUsage() and never under heapUsed.

Which means a flat heapUsed graph is not an all-clear. nodejs/help#2350 documents exactly that reading: RSS and external memory climbing while the heap stayed level, on a stream piping into an HTTP response, traced down into afterWrite and clearBuffer. Watch arrayBuffers and the stream's own writableLength, and the entry count in the snapshot tells you how far past the threshold the producer ran.

pipe() honours backpressure and leaks anyway

.pipe() has a bad reputation it only half deserves. Its internal data handler is the write/drain protocol, implemented correctly:

lib/internal/streams/readable.js — Readable.prototype.pipe
function ondata(chunk) {
  const ret = dest.write(chunk);
  if (ret === false) {
    pause();
  }
}

A paired listener on the destination's 'drain' calls src.resume(). That is the whole contract, and pipeline() wires up the same one. On the backpressure axis specifically, the two are identical.

The gap is cleanup, and the docs state it without euphemism: "if the Readable stream emits an error during processing, the Writable destination is not closed automatically. If an error occurs, it will be necessary to manually close each stream in order to prevent memory leaks."

That sentence has a CVE attached to it. CVE-2025-47935, in Multer before 2.0.0, is precisely this: when the HTTP request stream errors, the internal busboy stream is never closed, unclosed streams accumulate across failures, and memory and file descriptors run out. A denial of service in one of the most widely mounted pieces of Express middleware, produced by the documented behaviour of .pipe() rather than a bug in it.

stream.pipeline() closes that gap structurally. It builds a destroys array of per-stream destroyer closures and a central finishImpl() that keeps the first genuine error while superseding incidental ones like ERR_STREAM_PREMATURE_CLOSE and AbortError, then destroys every stream in the chain no matter where the fault occurred. The promise form in node:stream/promises has existed since v15.0.0 and takes an AbortSignal.

Safer, not free. An early, more aggressive destroy implementation (PR #31940) "caused lots of unnecessary breakage in the ecosystem" and had to be walked back by PR #32968 to destroy only unfinished streams. pipeline() still logs ERR_STREAM_PREMATURE_CLOSE on some deliberate early closes at exit code 0, which Node closed as "not planned" in #52853. And #55010, asking for .pipe() to gain the auto-destroy behaviour, has been open since September 2024 — the gap persists by decision, not by oversight.

The failures cluster at the boundaries

Inside one source-destination pair the protocol works. The production stories are almost all about places where a boundary swallows the signal.

The most common one skips the mechanism entirely:

the shape that bypasses backpressure
src.on('data', (chunk) => {
  dest.write(chunk);   // return value discarded, source never pauses
});

Attaching a 'data' handler puts the readable into flowing mode, and nothing then slows it down. nodejs/node#21996 is this exact relay measured on a 32 GB machine, fixed by replacing it with .pipe().

Resident memory on a 32 GB box
0.12.0%

nodejs/node#21996: a hand-rolled 'data' relay into an HTTP response — roughly 32 MB growing to 640 MB.

Buffered before the slow consumer pushed back
5GB

nodejs/node#16706: one Readable piped to a fast and a slow destination simultaneously.

Multiple destinations are the second boundary. Pipe one readable into two writables and the fast one keeps consuming while the slow one fills, because the source only pauses when a destination says so — #16706 buffered about 5 GB before the slow side asserted itself. Ben Nadel demonstrated the same effect with runnable code back in 2015, including the nastier variant where a PassThrough that nobody drains silently truncates an unrelated HTTP response.

Transform streams add a third. A Duplex carries two independent buffers with two independent highWaterMark values, and the docs flag that a Transform "is paused by default until they are piped or a 'data'/'readable' event handler is added". Construct a zlib.createGzip() in the middle of a chain and never consume its readable side, and the write side fills against a threshold that will never be relieved.

Library boundaries hide it best of all. aws-sdk-js-v3#4257 ended with the conclusion that @aws-sdk/lib-storage needs roughly 500 MB of baseline memory for bulk uploads, because chunks buffer faster than they upload — correct backpressure inside the library, an OOM in a 512 MB container.

What backpressure still does not buy you

Bounded buildup during a transfer is a different promise from memory returned afterwards. nodejs/node#50762 reports memory growing during a transfer and not being released once it completes, reproduced across manual pause/resume, .pipe() and pipeline() alike. It was closed as "not planned". Doing backpressure correctly bounds the queue; it does not commit the allocator to giving pages back on your schedule.

Details of the mechanism also move between releases more than its reputation suggests.

  1. November 2023

    Node v21.2.0, backported to v20.11.0

    PR #50341

    Readable's internal buffer became a plain array. Any explanation drawing a linked list is describing EOL versions.

  2. April 2024

    Node 22.0.0

    PR #52037

    Byte-mode default highWaterMark doubled to 64 KiB on non-Windows platforms.

  3. April 2025

    Node 24.0.0

    PR #55270, semver-major

    Errors thrown synchronously inside dest.write() during a .pipe() are caught and routed to the destination's destroy() path instead of risking an uncaught crash.

  4. May 2026

    Node 26.0.0

    PR #60441

    readable.read() returns buffered chunks one at a time rather than concatenating them, for CPU reasons. The old behaviour is still reachable via readable.read(readable.readableLength).

  5. June 2026

    Node 24.19.0 LTS

    PR #62986

    Writable.toWeb() computed desiredSize with a strategy counting every chunk as size 1 — the Node-to-Web-Streams backpressure bridge was silently wrong for byte streams until this landed.

That last one matters more each year, because the newest I/O surface in Node defaults to the other model. fetch/undici hands back response.body as a Web ReadableStream, whose backpressure is promise-based: a TransformStream returning a pending promise from transform() defers the next call. Classic Node streams are now the thing you opt into, via Readable.fromWeb().

The promise model is not a strict upgrade. The WHATWG Streams spec's own co-editor has called the "backpressure: good in theory, broken in practice" critique credible, tracing the flaw back to Node's early design. On throughput, the sharpest published numbers come from vendors rather than neutral parties — Vercel Labs' fast-webstreams states its method, and Cloudflare's own figures arrive inside a post arguing for a competing API design.

Read loop, classic streams vs Web Streams
26 7283 223MB/s

Vercel Labs' own benchmark: Node 22 on Apple Silicon, 1 KB chunks over 100 MB, no I/O. At the response-body level the same benchmark narrows to 1.02–1.8x, which is closer to what an application sees.

What follows for the code you have

Three things fall out of the mechanism.

Any 'data' handler that relays into a writable is not participating in the protocol, whatever else the surrounding code does. The boolean is being returned; the code is discarding it. That is the shape to grep for, and it is the one behind the loudest public incidents.

highWaterMark is a throughput knob and not a memory bound, so bound the thing upstream of it instead — the concurrency producing chunks, the size of what you push in object mode, the number of destinations sharing one source. Setting it lower narrows the window between false and drain; it does not stop a producer that never checks.

And the choice between pipe() and pipeline() has nothing to do with backpressure. Both wire up the same write()/drain pair. The difference is entirely the error path — the destroys array that runs when a chain faults, and whose absence has its own CVE.