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
highWaterMarkis a threshold, not a limit. It decides whenwrite()starts returningfalse. 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
Transformdoesthis.push()in a loop without looking at what it returns, it can pile up in memory too. Inside apipeline(), 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
rssandarrayBuffersin your metrics if you serve files, exports or proxies.- Any
.on('data', …)that writes into another stream without checking whatwrite()returns. - Downloads that last minutes: those are the ones that turn a slow client into a memory problem.