Backpressure is not a stream feature you turn on. It is a contract you either honor or silently bypass. In Node.js, the contract is tiny: when writable.write(chunk) returns false, stop producing and wait for drain. Ignore that boolean and your "streaming" code can still queue the whole payload in memory.
That distinction makes a better backend interview answer than "streams are memory efficient." Let's prove it with a slow consumer and two producers.
What failure are we trying to reproduce?
Imagine an export endpoint reading rows quickly and sending them to a slower destination: a compressed file, an HTTP response, or object storage. The producer can create data faster than the destination can flush it.
Node buffers the excess. A buffer is useful; an unbounded queue is not.
The official Node.js backpressure guide demonstrates the stakes with a roughly 9 GB compression workload. Its normal binary peaked at about 87.8 MB of resident memory. A modified binary that always returned true from write() peaked at about 1.52 GB. That is a controlled demonstration, not a universal benchmark, but it isolates the contract very clearly: the return value limits how far the producer can outrun the consumer.
We can reproduce the shape of that failure without a 9 GB file.
How do we build a deliberately slow writable?
Create a Writable whose _write method waits 1 millisecond before acknowledging each chunk. Set highWaterMark to 16 KiB so the queue reaches its threshold quickly.
import { Writable } from "node:stream";
const HIGH_WATER_MARK = 16 * 1024;
class SlowSink extends Writable {
constructor() {
super({ highWaterMark: HIGH_WATER_MARK });
}
_write(_chunk, _encoding, callback) {
setTimeout(callback, 1);
}
}
highWaterMark is a threshold, not a hard memory ceiling. Once the internal queue reaches it, write() starts returning false. Already accepted chunks remain queued, and one write can take the length beyond the threshold. The caller's job is to stop adding more.
What does the broken producer do?
The broken version records canContinue and then ignores it:
for (let i = 0; i < CHUNK_COUNT; i += 1) {
const canContinue = sink.write(chunk);
peakQueuedBytes = Math.max(peakQueuedBytes, sink.writableLength);
// Broken: canContinue may be false, but production continues.
}
The code is asynchronous in the least useful sense. write() returns quickly, so the loop creates all 800 chunks before the destination has processed more than a few. The event loop stays responsive, but memory becomes the waiting room.
This is the interview trap. "It does not block" does not mean "it applies flow control." Non-blocking production can still overwhelm a non-blocking consumer.
What is the smallest correct fix?
Wait for drain whenever write() returns false:
import { once } from "node:events";
for (let i = 0; i < CHUNK_COUNT; i += 1) {
const canContinue = sink.write(chunk);
peakQueuedBytes = Math.max(peakQueuedBytes, sink.writableLength);
if (!canContinue) {
await once(sink, "drain");
}
}
The flow is a two-state protocol, not a timing guess:
drain means the buffered amount has fallen below the threshold and writing may resume. It does not mean the entire stream is finished. finish is the separate lifecycle event emitted after end() and after all accepted data has flushed.
Can we turn that claim into an executable contract?
Here is the complete dependency-free script. Save it as backpressure.mjs and run it with a current Node.js release.
import assert from "node:assert/strict";
import { once } from "node:events";
import { Writable } from "node:stream";
const HIGH_WATER_MARK = 16 * 1024;
const CHUNK_BYTES = 2 * 1024;
const CHUNK_COUNT = 800;
class SlowSink extends Writable {
constructor() {
super({ highWaterMark: HIGH_WATER_MARK });
}
_write(_chunk, _encoding, callback) {
setTimeout(callback, 1);
}
}
async function run({ respectBackpressure }) {
const sink = new SlowSink();
const chunk = Buffer.alloc(CHUNK_BYTES);
let peakQueuedBytes = 0;
for (let i = 0; i < CHUNK_COUNT; i += 1) {
const canContinue = sink.write(chunk);
peakQueuedBytes = Math.max(peakQueuedBytes, sink.writableLength);
if (respectBackpressure && !canContinue) {
await once(sink, "drain");
}
}
sink.end();
await once(sink, "finish");
return peakQueuedBytes;
}
const carelessPeak = await run({ respectBackpressure: false });
const carefulPeak = await run({ respectBackpressure: true });
console.table({
ignored: { peakQueuedBytes: carelessPeak },
respected: { peakQueuedBytes: carefulPeak },
});
assert.ok(carelessPeak > carefulPeak * 20);
assert.ok(carefulPeak <= HIGH_WATER_MARK);
console.log("backpressure assertions passed");
On this setup, the careless producer queues roughly 1.6 MiB. The careful one stays at or below 16 KiB. The exact timing does not matter; the two assertions describe the behavior we care about.
That is worth emphasizing in an interview: test the queue bound, not how many milliseconds the machine happened to take.
When should you use pipeline() instead?
If you already have a readable source, transforms, and a writable destination, prefer pipeline() from node:stream/promises:
import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
await pipeline(
createReadStream("large.log"),
createGzip(),
createWriteStream("large.log.gz"),
);
pipeline() wires backpressure between stages and propagates errors while cleaning up the chain. Manual write() plus drain is still appropriate when your producer is not itself a Readable: a database cursor, a message consumer, or a loop generating report rows.
The decision rule is simple:
| Situation | Prefer |
|---|---|
| Readable → Transform → Writable | pipeline() |
Manual producer calling write()
|
Check the boolean and await drain
|
| Entire payload must be transformed at once | A buffer may be clearer; enforce a size limit |
Streams are not automatically better. For a 4 KiB configuration file, buffering is simpler. Backpressure matters when payload size is unbounded or meaningfully larger than your acceptable memory budget.
How would I explain this in a backend interview?
Use four sentences:
- "Backpressure is flow control between a faster producer and a slower consumer."
- "In Node.js,
Writable.write()returningfalsetells the producer to pause untildrain." - "Ignoring that signal allows the internal queue and GC workload to grow with the input."
- "I would use
pipeline()for connected streams, or explicitly test a queue bound for a manual producer."
Then offer the trade-off: increasing highWaterMark may improve throughput by batching more work, but it increases per-stream memory. Multiply that value by concurrent requests before changing it.
If you are practicing that explanation aloud, use a follow-up that forces a decision: "Would you raise highWaterMark for 500 concurrent exports? What would you measure first?" A mock interviewer should challenge the memory math, not merely confirm the definition. aceround.app — AI interview assistant is one way to rehearse those follow-ups, but the script above is the artifact that keeps the answer honest.
Quick FAQ
Does write() returning false mean the chunk was rejected?
No. The chunk was accepted into the queue. false means stop adding more until drain.
Is highWaterMark a hard cap?
No. It is the threshold that changes the write() return value. A caller can exceed it by ignoring that value, and an individual chunk may also carry the queue past it.
Does pipe() handle backpressure?
Yes, for a normal readable-to-writable connection. For robust error propagation and cleanup across multiple stages, use pipeline().
What should a production test assert?
Assert a behavior tied to risk: bounded queued bytes, cancellation cleanup, output integrity, and error propagation. Avoid a fragile wall-clock threshold unless latency itself is the contract.
Sources
Disclosure: AI assisted with outlining and copy editing. I verified the API behavior against the Node.js documentation and ran the complete script locally before publication.
Top comments (0)