Skip to content
Knowledge Hub

Node.js streams and backpressure: when the writer is slower than the reader Intermediate

Estimated reading time: 7 min read

An API receives file uploads and writes each chunk to object storage. The client sends faster than storage writes. If the code keeps calling write() without reading the return value, chunks pile up in the process’s memory.

A small file still passes tests; 200 concurrent 500 MB uploads make RSS grow, GC run constantly, latency rise, and finally OOM. Adding RAM only delays the failure.

Backpressure is the consumer’s signal saying “do not push faster than I can process”. For a Writable, that signal is write() returning false plus the drain event. This article uses a deliberately slow Writable to make the signal visible, then shows how to write correctly.

One table for every Writable signal:

Signal Means Does not mean
write() returns true After accepting the chunk, the buffer is still below highWaterMark The chunk has been durably written
write() returns false The chunk was accepted; do not write the next chunk until drain The chunk was rejected and must be rewritten
writableNeedDrain true when the buffer is full and the stream will emit drain A memory metric
drain The buffer has been fully consumed; you can write again The whole pipeline is done
finish After end(), all data has been flushed to the underlying system Remote storage has committed
close The stream and its underlying resource (for example a file descriptor) are closed; no more events follow The write succeeded (it is also emitted on destroy)
error An error occurred; the stream is usually destroyed right after

highWaterMark is the threshold where the signal flips, not a hard memory limit: if you keep calling write() despite false, Node keeps buffering.

Stream type Default Notes
Byte streams (Writable, Readable, Transform) 64 KiB since Node 22 16 KiB before Node 22
Object mode 16 objects Counted in objects, not bytes
fs.createReadStream / createWriteStream 64 KiB Measured on Node 24.17

Measured on Node 24.17.0: new Writable().writableHighWaterMark is 65536, object mode is 16, stream.getDefaultHighWaterMark(false) is 65536 and (true) is 16. Node 22.0.0 lists “stream: bump default highWaterMark” among its semver-major changes.

If the service runs on several Node versions, do not rely on the default: set highWaterMark explicitly or read stream.getDefaultHighWaterMark().

The demo writes 8-byte chunks into a Writable with highWaterMark: 16 and a sink that takes 15 ms per chunk. Actual trace (Node 24.17):

Time Event writableLength writableNeedDrain
+0 ms write #1 returns true 8 / 16 false
+0 ms write #2 returns false 16 / 16 true
+32 ms drain, the producer resumes 0 false
+32 ms write #3 returns true, write #4 returns false 16 / 16 true
+64 ms drain 0 false

Each cycle the producer pauses about 32 ms, exactly the time the sink needs for two chunks. That is backpressure: the producer’s speed is pulled down to the consumer’s. The full 10-chunk demo prints { chunks: 10, paused: 5 }.

The producer pauses when the Writable buffer reaches the threshold and resumes after drain
The producer pauses when the Writable buffer reaches the threshold and resumes after drainStep 1/4: The producer calls write. The chunk lands in the Writable's buffer and is gradually passed on to the sink.ProducerWritable bufferSlow sinkchunk
Producer → Writable bufferchunk
Step 1/4

The producer calls write. The chunk lands in the Writable's buffer and is gradually passed on to the sink.

Option 1: use pipeline (the default choice). stream/promises.pipeline wires up backpressure, propagates errors and cleans up resources for the whole chain:

import { pipeline } from 'node:stream/promises';
const ac = new AbortController();
res.on('close', () => { if (!res.writableFinished) ac.abort(); }); // client disconnected
await pipeline(req, gzip, storageWritable, { signal: ac.signal });

When the signal is aborted, pipeline calls destroy with an AbortError on the whole chain (per the Node docs). If one stream is destroyed midway, pipeline rejects with ERR_STREAM_PREMATURE_CLOSE (verified on Node 24.17).

Option 2: write manually in a loop. Needed when the source is not a stream.

The commonly missed point: await once(sink, 'drain') rejects automatically if the sink emits error while waiting, but if the sink is destroy()ed without an error, neither drain nor error is ever emitted and the loop hangs forever (measured: still hanging after 300 ms).

You must also wait on close:

import { once } from 'node:events';
async function writeAll(sink: Writable, chunks: Iterable<Buffer>) {
for (const chunk of chunks) {
if (sink.write(chunk)) continue;
const ac = new AbortController();
try {
await Promise.race([
once(sink, 'drain', { signal: ac.signal }),
once(sink, 'close', { signal: ac.signal }).then(() => {
throw new Error('sink closed early');
}),
]);
} finally {
ac.abort(); // remove the remaining listener
}
}
sink.end();
await once(sink, 'finish');
}

With a sink destroyed midway, this function throws sink closed early; with a normal sink it completes. Both were verified on Node 24.17.

Two points about the reading side: for await (const chunk of readable) followed by writable.write(chunk) still has to handle false or use pipeline; a read loop does not yield to an arbitrary Writable by itself.

With res.write() in HTTP, a slow-reading client produces false in exactly the same way, and a route generating unbounded data without waiting for drain grows memory from a single client.

  1. Use stream/promises.pipeline for every stream chain; write manually only when the source is not a stream.
  2. Pass an AbortSignal into pipeline and abort when the client disconnects or the deadline expires.
  3. When writing manually, handle false with drain, and also listen for error and close so you never hang forever.
  4. Set highWaterMark explicitly if behavior must not depend on the Node version (the default changed in Node 22).
  5. Limit concurrent uploads/streams and body size: per-stream backpressure does not replace an overall budget.
  6. Monitor writableLength (or writableNeedDrain) of important streams and the process’s RSS.
  7. Treat data as durable only after storage confirms it, not after finish of a hand-written adapter.

Self-check: (1) After write() returns false, do you rewrite that chunk? (2) When does await once(sink, 'drain') hang forever? (3) How do finish and close differ when the stream is destroy()ed?

Run the demo:

Terminal window
npx --prefix examples ts-node examples/src/node-backpressure/backpressure-demo.ts