Heap Snapshots

Heap Snapshots and Memory Leaks

A leak in Node is almost never a lost allocation; it is a reference you forgot to drop. A heap snapshot finds it, because it records every reachable object and who keeps it alive. v8.writeHeapSnapshot() writes one from inside the process, --heapsnapshot-signal=SIGUSR2 writes one on a POSIX signal, and --max-old-space-size=100 --heapsnapshot-near-heap-limit=3 writes up to three as the heap nears its ceiling, the only way to catch a container about to be killed. That flag is stable as of Node 25.4.0.

One snapshot says what is on the heap; two say what is growing, and growth is the leak. Run the program below as node --expose-gc leak.mjs: 20,000 requests, snapshot, 80,000 more, snapshot again, collecting garbage first so only survivors count.

A session map that nobody ever cleans upJavaScript
import v8 from 'node:v8';
const sessions = new Map();
function handleRequest(id) {
  sessions.set(id, { id, at: Date.now(), scratch: Buffer.alloc(1024) });
}
for (let i = 0; i < 20000; i++) handleRequest(i);
global.gc();
v8.writeHeapSnapshot('heap-1.heapsnapshot');
for (let i = 20000; i < 100000; i++) handleRequest(i);
global.gc();
v8.writeHeapSnapshot('heap-2.heapsnapshot');

A .heapsnapshot is JSON, but not the kind you read: nodes is a flat array of integers, meta.node_fields names the fields of each fixed-width record, and every string lives once in a strings table. Grouping object and array nodes by constructor name and subtracting the first tally from the second is thirty lines of work.

Objects surviving a full GC, second snapshot minus firstJavaScript
constructor              delta #   delta bytes
Buffer                     80000      8125 KB
ArrayBuffer                80000      7500 KB
Object                     80000      3750 KB
(unnamed array)                0      2688 KB

One live Object and one Buffer per extra request, 80,000 of each, plus 2.6 MB more in the same backing array. Nothing else moved. Following the edges names the chain: a Buffer in the scratch property of an Object, that object at element 65540 of the Map's 3.6 MB table array, the Map in the module scope declaring sessions. DevTools does this without arithmetic — load both files into the Memory panel, switch to Comparison, sort by Delta, open Retainers on a survivor.

The fix is whatever drops the reference: delete the entry when the response ends, bound the map with an LRU, or key it with a WeakMap. Repeat the comparison; a fixed leak shows a delta near zero.