Memory leaks are the classic slow-burn production incident in long-running Node services.
V8 manages a garbage-collected heap per isolate. It is generational:
- New space (young generation) holds fresh objects. A fast scavenger copies survivors; most objects die here cheaply.
- Old space holds objects that survived a couple of scavenges. It is collected by mark-sweep-compact, mostly concurrent and incremental, but with some pauses.
- Large object space, code space, and others hold special cases.
Buffers and ArrayBuffers live outside the JS heap (counted in process.memoryUsage().external and arrayBuffers), so RSS can exceed heap size.
The old-space limit is set by --max-old-space-size (MB). The default depends on the Node version and the memory it detects, so set it explicitly in containers. Leave headroom for RSS beyond the heap: a common choice is about 75% of the container limit. When the heap can't grow, V8 crashes with "JavaScript heap out of memory".
A leak in JavaScript means something still references data you no longer need. Usual suspects: unbounded caches and Maps, listeners added per request, closures held by timers, global arrays used as queues, and per-request data stashed on long-lived objects.
To find one, compare heap snapshots:
- Take a snapshot, run load, take another (DevTools Memory tab via
--inspect,v8.writeHeapSnapshot(), or--heapsnapshot-signal=SIGUSR2). - Use the Comparison view to see which constructors grew, then follow retainers to the root keeping them alive.
- Snapshots pause the process and need roughly 2x the heap in memory; avoid them on a struggling production instance without capacity.
Cheaper signals: --trace-gc, v8.getHeapStatistics(), and --heapsnapshot-near-heap-limit=N to capture evidence automatically before an OOM. Use WeakMap, WeakRef and LRU caches with size limits to make retention explicit.
Example
import v8 from "node:v8";
import { EventEmitter } from "node:events";
const bus = new EventEmitter();
bus.setMaxListeners(0); // hides the warning that would have caught this
function handleRequest(i) {
const payload = Buffer.alloc(10_000).toString("hex"); // per-request data
bus.on("shutdown", () => console.log("cleanup", i, payload.length)); // LEAK: never removed
}
const mb = (n) => (n / 1e6).toFixed(1) + " MB";
for (let round = 1; round <= 3; round++) {
for (let i = 0; i < 2000; i++) handleRequest(i);
globalThis.gc?.(); // run with --expose-gc for stable numbers
console.log(`round ${round}: heapUsed ${mb(process.memoryUsage().heapUsed)}`);
}
console.log("listeners:", bus.listenerCount("shutdown")); // 6000 and growing
const file = v8.writeHeapSnapshot(); // open in DevTools > Memory, follow retainers
console.log("snapshot written:", file);Remember
- Young generation is scavenged cheaply; old space uses mark-sweep-compact
- Set
--max-old-space-sizebelow the container limit, leaving headroom - Leaks are unwanted references; compare heap snapshots and follow retainers
- Buffers live outside the JS heap
Watch out for
- Raising
--max-old-space-sizeto hide a leak, which only delays the crash. - Taking heap snapshots on an instance with no spare memory and triggering the OOM yourself.