Node.js — Heap Out of Memory Caused by Missing Stream Backpressure
Diagnose heap out of memory while streaming large responses or files, spot the writable that never waits for drain, and add backpressure with pipeline and highWaterMark.
Symptoms
- FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory during a file or export job
- process.memoryUsage().heapUsed climbs in lockstep with the size of the input, even though the code streams chunk by chunk
- The service survives small exports and dies only on multi-gigabyte inputs
Possible causes
- A readable is piped or looped into a writable that always accepts chunks, so writes queue in memory faster than they flush
- A manual while loop reads chunks without awaiting the writable drain event
- res.write or fs.write return values are ignored, so false is never acted on
- An intermediate transform accumulates state (for example concatenating a string) instead of passing chunks through
- A promise-based wrapper collects all chunks into an array before responding
Diagnosis steps
Confirm the heap growth pattern
node --expose-gc --inspect script.js # then take heap snapshots in DevToolsInterpretation
Two snapshots dominated by Array or String retention inside the streaming path confirm buffering rather than a leak from listeners or closures.
Audit every write call for drain handling
grep -R "write(\|pipeline(\|pipe(" src/ | grep -v drainInterpretation
Any hot path that writes without checking the return value or using pipeline is a backpressure suspect.
Watch the writable length live
setInterval(() => log(out.writableLength), 500)Interpretation
writableLength growing without bound means the stream is queuing chunks in memory instead of applying pressure upstream.
Root cause
The readable side produced data faster than the writable side could flush, and the code never propagated the false return value or drain event. Every unread chunk was buffered inside the writable stream queue, so effective memory usage tracked the full input size despite the streaming design.
Fix
Replace manual read/write loops with stream.pipeline or Readable.pipe with proper error handling, await the drain event when writing by hand, size highWaterMark to the chunk budget you can afford, and make transforms stateless so they never accumulate the whole payload.
Risk note
Raising --max-old-space-size only delays the crash and hides the symptom; a 4 GB heap on a 6 GB input still dies. Fix the buffering first.
Verify the fix
Stream a file several times larger than the heap limit and assert that heapUsed stays under a fixed ceiling and writableLength returns to near zero between flushes.
Symptom walkthrough
The crash message is generic, but the trigger is specific: it happens while moving a large payload through code that looks streamed:
FATAL ERROR: Reached heap limit Allocation failed -
JavaScript heap out of memory
Take two heap snapshots a few seconds apart. When the retained objects are mostly Array buffers owned by a writable stream, the readable side is outrunning the sink. A quick live check makes it undeniable:
setInterval(() => {
console.log('queued bytes', out.writableLength);
}, 500);
A bounded value that returns to zero means healthy backpressure. A value that grows toward the input size means every chunk is parked in memory.
Fix in practice
Let pipeline own the plumbing, because it wires destroy, error propagation, and drain handling for you:
import { pipeline } from 'node:stream/promises';
await pipeline(
fs.createReadStream(inputPath, { highWaterMark: 1 << 20 }),
new LineTransform(), // stateless: emits per line, never accumulates
fs.createWriteStream(outputPath)
);
When a loop is unavoidable, respect the write return value. The two-line rule that prevents the crash: if write returns false, stop reading until drain fires.
for await (const chunk of source) {
if (!out.write(chunk)) {
await once(out, 'drain');
}
}
Also search for accidental full-payload buffers: string concatenation in a transform, or a wrapper that does await streamToString(body) before replying.
Verification
Re-run the export with an input at least three times the heap limit, with the interval logger on. Success is threefold: the process completes, heapUsed stays under your ceiling for the whole run, and writableLength oscillates near zero rather than climbing. Add a test that fails if the old write-without-drain pattern returns in review.