Ch. 6 · Node.js

Node.js Streams and Backpressure

What write() returning false means, how ignoring it buffered 193 MB for one slow client, and why awaiting drain alone hangs on a disconnect.

~6 min readbeginnerupdated Oct 7, 2026

A stream connects something that produces data with something that consumes it, and they almost never run at the same speed. A database cursor can produce rows far faster than a phone on a train can download them. Backpressure is the signal that flows the other way: the consumer telling the producer to wait. If the producer doesn’t listen, the difference between the two speeds has to live somewhere, and in Node that somewhere is memory.

This is one of the first stream questions in Node interviews, and the follow-ups (“what happens if the client disconnects while you’re waiting?”) are where most answers fall apart.

What write() is telling you

Every writable stream has an internal buffer and a threshold called highWaterMark. For byte streams the default is 64 KiB (65,536 bytes); for object-mode streams it’s 16 objects. write() adds your chunk to the buffer and returns true while the buffered amount is below the threshold, and false once it reaches it.

A writable with a 100-byte threshold and a consumer that takes 10 ms per chunk:

import { Writable } from 'node:stream';

const sink = new Writable({ highWaterMark: 100, write(chunk, enc, cb) { setTimeout(cb, 10); } });
console.log(sink.write('a'.repeat(60)), sink.writableLength);
console.log(sink.write('b'.repeat(60)), sink.writableLength, 'needDrain =', sink.writableNeedDrain);
console.log(sink.write('c'.repeat(60)), sink.writableLength);
sink.on('drain', () => console.log('drain, writableLength =', sink.writableLength));
JavaScript
true 60
false 120 needDrain = true
false 180
drain, writableLength = 0
Text

Read that third line carefully, because it’s the misconception interviewers look for. The stream returned false and then accepted another 60 bytes anyway. false doesn’t mean “rejected”; it’s advisory. Node will keep buffering for as long as you keep calling write(), and it won’t throw or warn. The 'drain' event fires once the buffer has emptied, and that’s your cue to continue.

The cost of ignoring it

Here’s the same idea at a realistic size: a consumer that takes one 64 KB chunk every 10 ms (about 6.4 MB/s) and a threshold of 256 KB. One producer ignores the return value and writes 100 MB. The other waits for 'drain' whenever write() returns false:

import { once } from 'node:events';

async function respecting() {
  const sink = slowSink();
  let peak = 0, waits = 0;
  for (let i = 0; i < 200; i++) {
    if (!sink.write(Buffer.alloc(64 * 1024, i))) { waits++; await once(sink, 'drain'); }
    peak = Math.max(peak, sink.writableLength);
  }
  sink.end();
  await once(sink, 'finish');
}

function ignoring() {
  const sink = slowSink();
  for (let i = 0; i < 1600; i++) sink.write(Buffer.alloc(64 * 1024, i)); // 100 MB
}
JavaScript
respecting: peak queued = 192 KB, waited for drain 50 times, 12.5 MB in 2204 ms
ignoring:  queued in sink = 100.0 MB, rss = 155.9 MB
Text

The respectful producer never had more than 192 KB queued, and it ran at exactly the consumer’s speed. The other one finished its loop instantly and left 100 MB sitting in the stream’s buffer. That’s per stream. Multiply by the number of concurrent slow clients and you get the pattern described in JavaScript heap out of memory: memory climbs with traffic, and the process dies under a load that the CPU could handle easily.

A slow client on a real HTTP response

An HTTP response is a writable stream, and its consumer is a TCP socket whose speed depends on the client’s network. To test it, a server generates a 200 MB CSV report of 200,000 one-kilobyte rows. The client reads a little, pauses 50 ms after each chunk, and gives up after 1.6 seconds, like a user who closes the tab. The server logs its progress every 500 ms.

Ignoring backpressure:

for (let i = 0; i < ROWS; i++) { res.write(row(i)); produced++; }
res.end();
JavaScript
  ignore: rows produced 200000, buffered 193 MB, rss 556 MB
  ignore: rows produced 200000, buffered 193 MB, rss 405 MB
  ignore: rows produced 200000, buffered 193 MB, rss 297 MB
  ignore: response closed after 200000 rows
Text

The whole report was generated in the first half-second and parked in memory, pushing the process RSS to 556 MB for a single client. The work was also wasted: the client left after reading less than a megabyte, but every row had already been produced.

Waiting for drain, and the disconnect trap

The textbook fix is to await 'drain' whenever write() returns false:

for (let i = 0; i < ROWS; i++) {
  if (!res.write(row(i))) await once(res, 'drain');
  produced++;
}
res.end();
console.log('  drain: handler finished');
JavaScript
  drain: rows produced 844, buffered 0 MB, rss 64 MB
  drain: rows produced 844, buffered 0 MB, rss 64 MB
  drain: rows produced 844, buffered 0 MB, rss 64 MB
  drain: response closed after 844 rows
Text

Memory is flat at 64 MB, and the server produced only 844 rows, as many as the client actually read. But look for the line that isn’t there: handler finished never printed. When the client disconnected, the response was destroyed, and a destroyed stream never emits 'drain'. events.once rejects on 'error', but a client closing the connection emits 'close', not 'error'. So the handler is stuck on that await forever, holding its closure, its row generator and whatever database cursor sits behind it. Each disconnected download leaks one of these.

If you write the loop by hand, the wait has to end on either event:

function writeOrWait(res, data) {
  if (res.destroyed) return Promise.reject(new Error('client disconnected'));
  if (res.write(data)) return Promise.resolve();
  return new Promise((resolve, reject) => {
    const done = (err) => { res.off('drain', onDrain); res.off('close', onClose); err ? reject(err) : resolve(); };
    const onDrain = () => done();
    const onClose = () => done(new Error('client disconnected'));
    res.on('drain', onDrain);
    res.on('close', onClose);
  });
}

for (; i < 200_000; i++) await writeOrWait(res, row(i));
JavaScript
stopped after 845 rows: client disconnected
Text

Removing both listeners in done matters too. Leaving a 'close' listener behind on each wait would add hundreds of listeners to one response and trigger a MaxListenersExceededWarning.

Let pipeline do it

You rarely need that helper. If the producer can be expressed as a readable stream, pipeline handles the waiting, the disconnect and the cleanup. A generator function is the easiest way to get a readable:

import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';

const rows = Readable.from((function* () {
  for (let i = 0; i < ROWS; i++) { produced++; yield row(i); }
})());
await pipeline(rows, res).catch((e) => console.log(`  pipeline rejected ${e.code}`));
JavaScript
  pipeline: rows produced 846, buffered 0 MB, rss 64 MB
  pipeline: rows produced 846, buffered 0 MB, rss 59 MB
  pipeline: rows produced 846, buffered 0 MB, rss 57 MB
  pipeline: response closed after 846 rows
  pipeline: pipeline rejected ERR_STREAM_PREMATURE_CLOSE
Text

Same memory profile as the drain loop, and the handler finishes: pipeline rejects with ERR_STREAM_PREMATURE_CLOSE when the response closes early, and destroys the source. The generator only runs when the stream pulls the next value, so production is driven by the consumer. Async generators work the same way, which is how you’d wrap a database cursor: for await (const row of cursor) yield toCsv(row). The pipeline errors note covers what to do in that catch.

The readable side has its own version of the signal. Inside a custom Readable, this.push(chunk) returns false when the readable buffer is full, and you should stop pushing until _read() is called again. Older code that calls push from an event handler without checking it has the same unbounded-buffer problem.

A last misconception: raising highWaterMark doesn’t fix a slow consumer. It only changes how much each stream buffers before signalling. A bigger buffer can improve throughput for fast local I/O, but with a slow client it just means more memory per connection before the producer pauses.

Interview practice

An endpoint exports orders as newline-delimited JSON. It runs const cursor = db.query('SELECT * FROM orders').stream() and then cursor.on('data', (row) => res.write(JSON.stringify(row) + '\n')), with cursor.on('end', () => res.end()). Under normal load it’s fine. When a large customer runs several exports over a slow VPN, the service’s memory spikes and the database reports long-running queries even after the downloads were cancelled. Explain both symptoms and fix them.

Answer

The 'data' handler ignores res.write()’s return value, so rows are read from the database as fast as the database sends them and buffered in the response while the slow VPN link drains slowly. That’s the memory spike: one response buffer per export, each growing towards the full result set. The long-running queries come from the missing disconnect handling. When the user cancels, the response closes, but nothing tells the cursor, so it keeps reading rows into a response that can’t send them, and the query holds its connection until the result set is exhausted.

The fix is to connect them with pipeline: await pipeline(cursor, async function* (rows) { for await (const row of rows) yield JSON.stringify(row) + '\n'; }, res). The cursor stream is only read as fast as the response drains, so memory stays near the high-water marks. When the client disconnects, pipeline destroys the cursor, which in most drivers (pg-query-stream, for example) closes the query and releases the connection, and the promise rejects with ERR_STREAM_PREMATURE_CLOSE, which I’d log as a cancelled export rather than an error. To verify, I’d run a test client that reads slowly and disconnects, and assert both a bounded res.writableLength and that the cursor’s close event fires.

Further reading

More in Node.js

read ✓Node.js · mid

Node.js HTTP Security Headers

Add CSP, nosniff, Referrer-Policy and HSTS to every Node.js response from one function, error pages included, and roll out CSP safely.

~7 min readread →
read ✓Node.js · mid

Node.js Response Compression with zlib

Stream gzip or Brotli responses with node:zlib, negotiate Accept-Encoding, send Vary, and skip tiny bodies and files already compressed.

~7 min readread →
esc