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.
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.
constructor delta # delta bytes
Buffer 80000 8125 KB
ArrayBuffer 80000 7500 KB
Object 80000 3750 KB
(unnamed array) 0 2688 KBOne 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.