V8 memory, leaks and heap snapshots

Node.js · Expert · 20 min

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-size below 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-size to hide a leak, which only delays the crash.
  • Taking heap snapshots on an instance with no spare memory and triggering the OOM yourself.

Check yourself

  1. Heap usage climbs steadily under constant load and never drops after GC. What's the next diagnostic step?

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

Official docs: nodejs.org/learn/diagnostics/memory/using-heap-snapshot