NodeJSBackpressure

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.

JS
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.
Ignoring `write()`'s return value is the canonical memory-leak bug
In the code above, `slow.write()` returns `false` the moment its buffer fills, but the `data` handler keeps pushing more. Node has no choice but to buffer the backlog in memory. On a large enough input the process's memory climbs until it's killed (OOM). The stream *looked* like it was streaming, but it was secretly accumulating everything.
How backpressure works

The signal is the boolean returned by writable.write(), in concert with the writable's internal buffer threshold, highWaterMark:

  • write(chunk) returns true while the internal buffer is below highWaterMark — "keep them coming."

  • write(chunk) returns false once the buffer exceeds highWaterMark — "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.

`highWaterMark` is the threshold, not a hard cap
`highWaterMark` (default 64 KB for byte streams, 16 objects in object mode) is the level at which `write()` starts returning `false`. The buffer *can* exceed it — if you keep writing after `false`, Node keeps queueing — so it's an advisory "you should stop now," not a wall that blocks you. Respecting the signal is what bounds memory; ignoring it is what blows it up.
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:

JS
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())
This is exactly what `pipe` does internally
The pause/drain/resume loop above *is* the body of `readable.pipe(writable)`. That's the whole point: `pipe` and `pipeline` exist so you never write this by hand. The lesson isn't "memorize the loop" — it's "let the built-ins own it."
The real solution: don't do it manually

JS
import { pipeline } from 'node:stream/promises'

// Backpressure is handled correctly and automatically:
await pipeline(fast, slow)
`for await` applies backpressure; a bare `data` listener does not
Consuming a readable with `for await (const chunk of stream)` naturally pauses the source while your loop body (including any `await`) runs — so if the body writes to a slow sink and awaits its `drain`/promise, backpressure is preserved. A plain `.on('data', …)` callback, by contrast, fires regardless of how fast you're keeping up. Prefer async iteration or `pipeline` whenever a slow consumer is involved.
Async-iterator pipelines preserve it for free

JS
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'),
)
Awaiting inside a generator stage throttles the whole pipeline
Because `pipeline` drives the generator by pulling one value at a time and respecting the downstream writable's readiness, an `await` inside the loop slows intake to match. You get end-to-end backpressure across a multi-stage pipeline without thinking about `drain` or `highWaterMark` at all.
Symptoms of broken backpressure

Symptom

Likely cause

Memory climbs steadily under load

Writing without checking write()/awaiting

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 pipeline

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() returning false and the drain event.

  • Never ignore the return value of write() in a manual loop.

  • Almost always: use pipeline (or for await) and never touch the signals yourself.

Next
The tool that handles all of this — errors, cleanup, and backpressure — in one call: [stream.pipeline](/nodejs/stream-pipeline).