# Node's backpressure is a request, not a limit

> highWaterMark gates one boolean and buffers the chunk anyway. What Node's write buffer really is, why drain fires once, and where pipe() leaks.

- Source: https://ivankarpinskyi.dev/blog/node-streams-backpressure
- Published: 2026-09-09
- Type: Mechanism
- Tags: nodejs, streams, backpressure, memory, performance
- Reading time: 10 min

Node's own [guide to backpressure](https://nodejs.org/learn/modules/backpressuring-in-streams) 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 pipe**

| | Value, MB | Note |
| - | -: | - |
| Backpressure respected | 87.81 | |
| **Backpressure ignored** | 1520 | \~17x |

_Node'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._

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](https://nodejs.org/api/stream.html) 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."

> **Warning · The number you remember is two versions stale**
>
> The byte-mode default doubled from 16 KiB to 64 KiB in Node 22.0.0, via [PR #52037](https://github.com/nodejs/node/pull/52037) in April 2024, on the grounds that "the old limit is a decade old". Every supported line — 22, 24 and 26 — defaults to 64 KiB on non-Windows platforms. Object mode is unchanged and counts _objects_, not bytes: the default of 16 against 16 KB objects is 256 MB of buffer, which is the [archived footgun](https://github.com/nodejs/node-v0.x-archive/issues/8574) that still catches people streaming wide CSV rows.

## 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`:

```js title="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](https://github.com/nodejs/node/pull/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**: 6 → 35 ms. Node's guide reports pauses growing as the buffer does. Hardware and Node version are not stated.
- **Wall clock, respected vs ignored**: 58.88 → 54.48 s. 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](https://github.com/nodejs/node/issues/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](https://github.com/nodejs/help/issues/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:

```js title="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](https://github.com/advisories/GHSA-44fp-w29j-9vj5), 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](https://github.com/nodejs/node/pull/31940)) "caused lots of unnecessary breakage in the ecosystem" and had to be walked back by [PR #32968](https://github.com/nodejs/node/pull/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](https://github.com/nodejs/node/issues/52853). And [#55010](https://github.com/nodejs/node/issues/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:

```js title="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](https://github.com/nodejs/node/issues/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.1 → 2.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**: 5 GB. 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](https://github.com/nodejs/node/issues/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](https://github.com/aws/aws-sdk-js-v3/issues/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](https://github.com/nodejs/node/issues/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.

- **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.
- **April 2024 — Node 22.0.0** (PR #52037)

  Byte-mode default `highWaterMark` doubled to 64 KiB on non-Windows platforms.
- **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.
- **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)`.
- **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`](https://github.com/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**: 26728 → 3223 MB/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.

> **Warning · Stream benchmarks in one process lie**
>
> [nodejs/node#52805](https://github.com/nodejs/node/issues/52805) looked like a regression from the `highWaterMark` bump, with numbers: 8.4 s and 997 chunks on v22 against 8.1 s and 782 on v20. Its author, Matteo Collina, retracted it after another maintainer reproduced it across separate processes and found no meaningful difference — "sorry for the false alarm". Same-process A/B of stream code shares a heap and a GC between the variants.

## 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.

> **The point**
>
> `write()` returning `false` is the only enforcement Node has. Every guarantee people attribute to `highWaterMark` — a bounded buffer, a capped process, an error when the queue overflows — is actually a guarantee about a caller that stops when told.

> **Note**
>
> Current as of September 2026. Node 26 is Current, Node 24 is Active LTS at v24.19.0, Node 22 is in maintenance; the byte-mode `highWaterMark` default is 64 KiB across all three on non-Windows. `pipeline()` and `finished()` have had no call-signature or behavioural change across two major versions beyond AsyncLocalStorage plumbing, and the legacy `_stream_readable`/`_stream_writable` modules were removed in Node 26.0.0.

## Sources

- [guide to backpressure](https://nodejs.org/learn/modules/backpressuring-in-streams), nodejs.org
- [stream documentation](https://nodejs.org/api/stream.html), nodejs.org
- [PR #52037](https://github.com/nodejs/node/pull/52037), github.com
- [archived footgun](https://github.com/nodejs/node-v0.x-archive/issues/8574), github.com
- [PR #50341](https://github.com/nodejs/node/pull/50341), github.com
- [issue #29310](https://github.com/nodejs/node/issues/29310), github.com
- [nodejs/help#2350](https://github.com/nodejs/help/issues/2350), github.com
- [CVE-2025-47935](https://github.com/advisories/GHSA-44fp-w29j-9vj5), github.com
- [PR #31940](https://github.com/nodejs/node/pull/31940), github.com
- [PR #32968](https://github.com/nodejs/node/pull/32968), github.com
- [#52853](https://github.com/nodejs/node/issues/52853), github.com
- [#55010](https://github.com/nodejs/node/issues/55010), github.com
- [nodejs/node#21996](https://github.com/nodejs/node/issues/21996), github.com
- [#16706](https://github.com/nodejs/node/issues/16706), github.com
- [aws-sdk-js-v3#4257](https://github.com/aws/aws-sdk-js-v3/issues/4257), github.com
- [nodejs/node#50762](https://github.com/nodejs/node/issues/50762), github.com
- [Vercel Labs' fast-webstreams](https://github.com/vercel-labs/fast-webstreams), github.com
- [nodejs/node#52805](https://github.com/nodejs/node/issues/52805), github.com
