Node.js streams and backpressure: when the writer is slower than the reader Intermediate
Estimated reading time: 7 min read
The problem
Section titled “The problem”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.
Signal table
Section titled “Signal table”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.
Default highWaterMark
Section titled “Default highWaterMark”| 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().
Buffer fill over time
Section titled “Buffer fill over time”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 }.
chunkThe producer calls write. The chunk lands in the Writable's buffer and is gradually passed on to the sink.
Writing correctly
Section titled “Writing correctly”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.
Real-world example
Section titled “Real-world example”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.
Production checklist
Section titled “Production checklist”- Use
stream/promises.pipelinefor every stream chain; write manually only when the source is not a stream. - Pass an
AbortSignalintopipelineand abort when the client disconnects or the deadline expires. - When writing manually, handle
falsewithdrain, and also listen forerrorandcloseso you never hang forever. - Set
highWaterMarkexplicitly if behavior must not depend on the Node version (the default changed in Node 22). - Limit concurrent uploads/streams and body size: per-stream backpressure does not replace an overall budget.
- Monitor
writableLength(orwritableNeedDrain) of important streams and the process’s RSS. - Treat data as durable only after storage confirms it, not after
finishof a hand-written adapter.
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”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:
npx --prefix examples ts-node examples/src/node-backpressure/backpressure-demo.ts