Back to notes

Node.js Streams: When Backpressure Bites Your Server Memory

17 October 2026·
nodejsstreamsmemorybackpressure

Note revised and rewritten on 18 October 2026: the original version included an invented anecdote and code that measured the wrong thing.

If you write to a stream faster than the other end can read, Node.js doesn't drop anything: it keeps it in memory. With an HTTP response and a slow client, that means one download can make your process grow by gigabytes. The fix is one line, but first it's worth seeing the problem happen, because the metric most people check doesn't show it.

The signal you're ignoring

writable.write(chunk) returns a boolean. false means the stream's internal buffer has gone past its highWaterMark, and you should stop writing until it emits 'drain'. It's advisory: if you keep calling write(), Node keeps accepting chunks and keeps them in memory. That is backpressure, and when the producer ignores it, memory piles up.

The reproduction, measured

A server that produces 2,000 chunks of 1 MB (2 GB in total), with two routes: one that ignores write() and one that uses pipeline():

// server.mjs
import http from 'node:http';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';

const MB = 1024 * 1024;
const mem = () => {
  const { heapUsed, rss, arrayBuffers } = process.memoryUsage();
  return `heapUsed=${Math.round(heapUsed / MB)}MB rss=${Math.round(rss / MB)}MB buffers=${Math.round(arrayBuffers / MB)}MB`;
};

function producer() {
  let i = 0;
  return new Readable({
    read() {
      this.push(i++ < 2000 ? Buffer.alloc(MB, i % 256) : null);
    },
  });
}

http.createServer(async (req, res) => {
  const source = producer();
  if (req.url === '/leak') {
    // Wrong: the return value of res.write() is ignored
    source.on('data', (chunk) => res.write(chunk));
    source.on('end', () => res.end());
    res.on('close', () => source.destroy());
  } else {
    // Right: pipeline pauses the producer when res.write() returns false
    try {
      await pipeline(source, res);
    } catch (err) {
      if (err.code !== 'ERR_STREAM_PREMATURE_CLOSE') console.error(err);
    }
  }
}).listen(3456);

setInterval(() => console.log(mem()), 1000).unref();

And a slow client that reads one chunk every 50 ms and hangs up after 6 seconds:

// slow-client.mjs
import http from 'node:http';

const req = http.get(`http://localhost:3456${process.argv[2]}`, (res) => {
  res.pause();
  const timer = setInterval(() => res.read(), 50);
  setTimeout(() => { clearInterval(timer); req.destroy(); }, 6000);
});
req.on('error', () => {});

Running node slow-client.mjs /leak and then /fixed against the server, with Node 22, this is what the server printed:

/leak   heapUsed=6MB  rss=2052MB  buffers=2000MB
/fixed               rss=57MB    buffers=4MB

With the slow client, the /leak route ends up holding the 2 GB the producer generated, because nothing told it to wait. With pipeline(), memory stays around 55 MB for the whole download.

The trap: heapUsed doesn't move

Look at the first line: heapUsed stays at 6 MB while the process takes up 2 GB. Buffer memory lives outside the V8 heap. It shows up in arrayBuffers and external from process.memoryUsage(), and in rss, but not in heapUsed. If your monitoring only graphs the heap, this kind of leak doesn't appear until the system kills the process. When you're working with streams and binary data, watch rss and arrayBuffers.

The fix: pipeline()

pipeline() (in its promise version, from node:stream/promises) connects the streams, respects backpressure on its own (it pauses the source when the destination asks it to and resumes on 'drain'), propagates errors, and destroys every stream in the chain if one fails or the client disconnects. That last part matters too: with .pipe(), an error doesn't close the other streams, and you have to add the listeners by hand.

If for some reason you write manually, the pattern is:

source.on('data', (chunk) => {
  if (!res.write(chunk)) {
    source.pause();
    res.once('drain', () => source.resume());
  }
});

But pipeline() already does this, and it also handles errors and cleanup.

Gotchas

  • highWaterMark is a threshold, not a limit. It decides when write() starts returning false. Since Node.js 22, the default for byte streams is 64 KiB (it used to be 16 KiB). In object mode it's 16 objects, whatever their size.
  • Your own Transforms count. If a Transform does this.push() in a loop without looking at what it returns, it can pile up in memory too. Inside a pipeline(), the chain only respects backpressure if every link does.
  • Async iterators. for await (const chunk of readable) also respects backpressure on the reading side: it doesn't ask for more until you finish processing the current chunk. The problem is always on the writing side.

What to keep an eye on

  • rss and arrayBuffers in your metrics if you serve files, exports or proxies.
  • Any .on('data', …) that writes into another stream without checking what write() returns.
  • Downloads that last minutes: those are the ones that turn a slow client into a memory problem.

Sources