Understanding Backpressure
Backpressure is the mechanism that keeps a fast data producer from overwhelming a slower consumer. When you read from a quick source (a file on a fast SSD) and write to a slow destination (a network socket or a throttled API), data can arrive faster than it can be drained. Without backpressure, that surplus data piles up in memory until your process balloons and eventually crashes. Node.js streams give you the tools — highWaterMark, the return value of write(), and the drain event — to apply that pressure correctly, and pipe/pipeline wire it up for you automatically.
What backpressure actually is
Every writable stream has an internal buffer. As you push data into it with write(), that data queues up until the underlying resource (disk, socket, etc.) is ready to accept it. The stream tracks how much is buffered and compares it against a threshold called the highWaterMark. When the buffer exceeds that threshold, the stream is telling you: “slow down, I’m full.”
Backpressure is simply the act of listening to that signal and pausing your producer until the consumer catches up. Ignore it, and the buffer grows without bound.
highWaterMark: the threshold
The highWaterMark option sets the buffer size at which a stream is considered “full.” It is not a hard limit — you can always write more — but it is the point at which the stream starts asking you to back off.
| Stream type | Default highWaterMark | Unit |
|---|---|---|
| Readable / Writable (byte mode) | 65536 (64 KiB) | bytes |
| Object mode | 16 | objects |
import { createWriteStream } from "node:fs";
// Buffer up to 16 KiB before signaling backpressure
const out = createWriteStream("out.log", { highWaterMark: 16 * 1024 });
A smaller highWaterMark means more frequent backpressure signals and lower memory use; a larger one trades memory for fewer pauses.
The return value of write()
The single most important — and most ignored — detail in the streams API is that writable.write() returns a boolean.
true— the internal buffer is belowhighWaterMark. Keep writing.false— the buffer is full. Stop writing and wait for thedrainevent before resuming.
The data you wrote when it returned false is still accepted and queued; the false is purely advisory. But honoring it is what gives you flow control.
import { createWriteStream } from "node:fs";
const dest = createWriteStream("big-output.txt");
function writeChunks(stream, total) {
let i = 0;
function next() {
let ok = true;
while (i < total && ok) {
const last = i === total - 1;
const chunk = `line ${i}\n`;
// On the final write, pass the callback to know when it's flushed
ok = last ? stream.write(chunk, "utf8", () => stream.end())
: stream.write(chunk, "utf8");
i++;
}
if (i < total) {
// Backpressure: wait for the buffer to drain, then continue
stream.once("drain", next);
}
}
next();
}
writeChunks(dest, 1_000_000);
Output:
$ node write-with-backpressure.js
$ wc -l big-output.txt
1000000 big-output.txt
Memory stays flat the entire time because we pause whenever write() returns false.
The drain event
When a stream’s buffer empties back below highWaterMark after having been full, it emits a drain event. That is your cue to resume writing. The pattern is always the same: write in a loop until write() returns false, then once("drain", ...) to pick up where you left off.
Use
oncerather thanonfordrain. A persistentonlistener can fire repeatedly and re-enter your write loop, causing tangled control flow. Re-attach a freshoncelistener each time you stall.
How pipe and pipeline handle it automatically
You rarely need to manage drain by hand, because pipe() and stream.pipeline() implement the full backpressure dance for you. When the destination returns false, the source readable is paused; when drain fires, the source resumes.
import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
// Each stage applies backpressure to the one before it — automatically
await pipeline(
createReadStream("huge.log"),
createGzip(),
createWriteStream("huge.log.gz"),
);
console.log("Compressed with constant memory.");
Prefer pipeline over raw .pipe(): pipeline also propagates errors and cleans up every stream in the chain, whereas a bare .pipe() leaks file descriptors on failure.
What happens when you ignore it
If you write in a tight loop and never check the return value, the buffer grows until the data is fully flushed — holding all of it in memory at once.
import { createWriteStream } from "node:fs";
const dest = createWriteStream("/dev/null");
// DON'T DO THIS — ignores backpressure entirely
for (let i = 0; i < 5_000_000; i++) {
dest.write(`line ${i}\n`);
}
dest.end();
Output:
$ node --max-old-space-size=128 ignore-backpressure.js
<--- Last few GCs --->
[reached heap limit] Allocation failed - JavaScript heap out of memory
The same workload written with the drain-aware version above runs in a few megabytes of RSS. That difference is backpressure.
Best Practices
- Always check the return value of
write(); treatfalseas a hard “stop untildrain” signal. - Reach for
pipeline()(fromnode:stream/promises) instead of manualwrite/drainloops whenever you can — it handles backpressure, errors, and cleanup. - Tune
highWaterMarkdeliberately: lower it to cap memory, raise it to reduce pause frequency for high-throughput paths. - Attach
drainwithonce, noton, to avoid re-entrant write loops. - Monitor
writableLengthand process RSS in long-running pipelines to confirm memory stays flat under load. - Never
for-loopwrite()over a large dataset without honoring backpressure — it is the classic cause of out-of-memory crashes.