gap·map

Streams, buffers & memory

[ middle depth ]

The four stream types

A stream moves data in chunks instead of holding all of it at once. There are four kinds. A Readable is a source you pull from (fs.createReadStream, an HTTP request, process.stdin). A Writable is a sink you push to (fs.createWriteStream, an HTTP response, process.stdout). A Duplex is both with independent sides (a TCP socket: you read what the peer sent and write what you send). A Transform is a Duplex where the output is a function of the input (zlib.createGzip, a cipher, a CSV parser). Everything else is a combination of these.

How backpressure works

Backpressure is the writable telling the readable to slow down. A slow sink cannot accept bytes as fast as a fast source produces them, and if nobody slows the source, the unsent bytes pile up in the writable's internal buffer until the process runs out of memory.

The signal is the return value of write(). When the buffer passes highWaterMark (16 KB by default for byte streams, 16 for objectMode), write() returns false. That is your cue to stop and wait for the 'drain' event before writing more.

// WRONG: ignores the false return, buffer grows unbounded
src.on("data", (chunk) => dst.write(chunk));

// RIGHT: let pipe/pipeline run the pause/resume handshake
const { pipeline } = require("node:stream/promises");
await pipeline(src, dst); // pauses src on false, resumes on 'drain'

Wrong "streams handle memory automatically, so this can't leak." The pause/resume protocol is cooperative. pipe and pipeline respect the false return for you, but a hand-rolled on('data') loop that ignores it will still overflow. Prefer pipeline over .pipe(): it forwards errors and destroys every stream in the chain when one fails, so an error mid-copy does not leak an open file descriptor.

Why heapUsed stays flat while memory grows

A Buffer holds raw bytes outside the V8 JavaScript heap. Allocate a 50 MB Buffer and process.memoryUsage().heapUsed barely moves, while external and rss jump by ~50 MB. So when un-drained stream chunks pile up, you see it as rising RSS with a flat heap, which is the opposite of where most people look first. Reading a 2 GB file with fs.readFile needs 2 GB resident at once; streaming it holds one chunk at a time and finishes in bounded memory.

[ senior depth ]

What backpressure actually guarantees

highWaterMark is a threshold, not a ceiling. When the writable's queued bytes cross it, write() returns false, and that is the whole of the mechanism: an advisory. The data you passed is still accepted and queued; nothing is rejected or dropped. So backpressure only works if both ends cooperate. pipe and pipeline cooperate by pausing the readable when they see false and resuming on 'drain'. A source that has no pause (a raw EventEmitter you bridged into a handler, a subscription that fires regardless) will overrun a slow sink no matter what the sink returns.

Transform streams carry this in both directions. The readable side backpressures its consumer, and its own writable side backpressures whatever feeds it, so a slow gzip in the middle of a pipeline throttles the disk read at the front. objectMode changes what the threshold counts: 16 objects instead of 16384 bytes, and one "object" can be a megabyte, so the byte footprint of a full buffer is unbounded in objectMode.

RSS vs heapUsed vs external

process.memoryUsage() returns four numbers that people conflate. heapUsed/heapTotal are the V8 JS heap (your objects, closures, strings). external is memory held by C++ objects bound to JS, including Buffer backing stores. arrayBuffers is the subset of external backing ArrayBuffers and Buffers. rss is the physical RAM the OS maps for the whole process, which is a superset of all of the above plus code, stacks, and native allocations.

Two consequences interviewers probe. First, a Buffer leak grows external/arrayBuffers and rss while heapUsed stays flat. Second, --max-old-space-size bounds V8's old space only, so a Buffer or un-drained-stream leak can OOM the process without ever tripping the heap limit. Small buffers add a wrinkle: Buffer.allocUnsafe draws from an 8 KB pool (Buffer.poolSize), so a handful of live 100-byte buffers can each pin a whole 8 KB slab and inflate external well past the bytes you think you hold.

Finding an off-heap leak

Reachability is the same model as the GC module (a value survives while a root can reach it); the Node twist is where the bytes live. If heapUsed climbs, take two V8 heap snapshots under steady load and diff retained size, then follow the retainer path to the root holding the garbage. If heapUsed is flat while rss climbs, a JS-object snapshot looks clean because the retained bytes are native. Track external/arrayBuffers over time instead and audit stream lifetimes and long-lived Buffers.

Wrong "flat heapUsed means there's no leak." It means the leak is off the JS heap. Graders listen for exactly that reflex, plus naming pipeline for its error-and-cleanup semantics and being able to say what "pipe handles backpressure" means at the level of the false/drain handshake.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more Node.js

was this useful?