Backpressure and custom streams

Node.js · Advanced · 16 min

Ignoring backpressure is how a fast producer and slow consumer turn into an out-of-memory crash.

Every stream has an internal buffer whose target size is the highWaterMark (64 KiB for byte streams in Node 22, 16 objects in object mode). It is a threshold, not a hard limit, and it is how backpressure works: the mechanism by which a slow consumer tells a fast producer to wait.

On the writable side, writable.write(chunk) returns a boolean. false means "my buffer is above the highWaterMark — stop until I emit 'drain'". The data is still accepted; nothing enforces the pause. Code that ignores the return value keeps buffering, and memory grows until the process dies.

if (!out.write(chunk)) await once(out, "drain");

On the readable side, a stream reads ahead only until its buffer reaches the highWaterMark, then waits for the consumer. In a custom Readable, push() returning false is your signal to stop producing until _read() is called again.

The good news: `pipeline()` and `for await` handle all of this for you. pipeline pauses sources when destinations fill up; async iteration only pulls the next chunk when your loop body finishes. Manual on("data") handlers are where backpressure gets lost, because 'data' keeps firing however slowly you process it.

When writing a Transform, call the callback only when you have finished with the chunk, push output with this.push(), and use flush(callback) to emit anything buffered at the end. Errors go to callback(err), which destroys the stream and propagates through pipeline.

Tune highWaterMark deliberately: bigger buffers improve throughput for large sequential copies, smaller ones reduce memory per connection. Measure before changing it.

Example

import { Transform, Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs";

// Transform: object rows -> CSV lines, with a header emitted lazily
class ToCsv extends Transform {
  constructor() { super({ writableObjectMode: true }); this.header = false; }
  _transform(row, _enc, callback) {
    if (!this.header) { this.push(Object.keys(row).join(",") + "\n"); this.header = true; }
    callback(null, Object.values(row).join(",") + "\n");
  }
  _flush(callback) { this.push("# end\n"); callback(); }
}

// A large lazy source: rows are produced only as fast as the file accepts them
function* rows(n) { for (let i = 0; i < n; i++) yield { id: i, square: i * i }; }

await pipeline(Readable.from(rows(1_000_000)), new ToCsv(), createWriteStream("squares.csv"));
console.log("rss MB:", Math.round(process.memoryUsage().rss / 1e6));  // bounded, whatever n is

Remember

  • write() returning false means wait for 'drain'
  • pipeline and for await apply backpressure automatically
  • In Transforms, call the callback when done and use flush for trailing output

Watch out for

  • Writing in a loop and ignoring write()'s return value, buffering unbounded data.
  • Consuming with on('data') and slow async work, which defeats backpressure.

Check yourself

  1. writable.write(chunk) returns false. What does that mean?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: nodejs.org/learn/modules/backpressuring-in-streams

Builds on

Unlocks

Nothing builds on this yet.