Stream Backpressure
Backpressure is what happens when a fast producer outpaces a slow consumer — and the mechanism that stops that mismatch from exhausting your memory. It's the single most important concept for using streams correctly. Get it right (usually by letting pipe/pipeline handle it) and memory stays flat under any load; get it wrong and a "streaming" program quietly buffers gigabytes and crashes.
The problem, concretely
Imagine reading from a fast SSD (gigabytes per second) and writing to a slow network socket (megabytes per second). Without coordination, chunks arrive far faster than they can leave — and the unsent ones have to go somewhere: an ever-growing in-memory queue.
import { createReadStream, createWriteStream } from 'node:fs'
const fast = createReadStream('huge.dat') // produces quickly
const slow = createWriteStream('/mnt/slow/out')// consumes slowly
// ✗ DANGER: ignores write()'s return value
fast.on('data', (chunk) => {
slow.write(chunk) // returns false when full — but we ignore it!
})
// The read stream keeps firing 'data'; unwritten chunks pile up in RAM
// until the process runs out of memory.How backpressure works
The signal is the boolean returned by writable.write(), in concert with the writable's internal buffer threshold, highWaterMark:
write(chunk)returnstruewhile the internal buffer is belowhighWaterMark— "keep them coming."write(chunk)returnsfalseonce the buffer exceedshighWaterMark— "I am full; stop."When you stop and the buffer drains below the threshold, the writable emits
drain— "resume."On the source side, pausing the readable stops it pulling from the underlying resource, propagating the pressure all the way back.
The correct manual pattern
If you must wire a readable to a writable by hand, the loop is: write until false, pause, wait for drain, resume:
fast.on('data', (chunk) => {
const ok = slow.write(chunk)
if (!ok) {
fast.pause() // stop reading — apply backpressure
slow.once('drain', () => fast.resume()) // resume when sink catches up
}
})
fast.on('end', () => slow.end())The real solution: don't do it manually
import { pipeline } from 'node:stream/promises'
// Backpressure is handled correctly and automatically:
await pipeline(fast, slow)Async-iterator pipelines preserve it for free
import { pipeline } from 'node:stream/promises'
import { createReadStream, createWriteStream } from 'node:fs'
await pipeline(
createReadStream('huge.dat'),
async function* (source) {
for await (const chunk of source) { // pauses source while we work
yield await transform(chunk) // awaiting here throttles input
}
},
createWriteStream('out.dat'),
)Symptoms of broken backpressure
Symptom | Likely cause |
|---|---|
Memory climbs steadily under load | Writing without checking |
Process killed (OOM) on large inputs | Buffering an unbounded stream |
Works on small files, dies on big ones | No backpressure — masked at small scale |
Fixed by | You were piping manually and ignoring the signal |
Key takeaways
Backpressure = the consumer telling the producer "slow down" so memory stays bounded.
The signals are
write()returningfalseand thedrainevent.Never ignore the return value of
write()in a manual loop.Almost always: use
pipeline(orfor await) and never touch the signals yourself.