Heap snapshots and flame graphs: finding leaks and hot paths in production Node
Heap snapshots and flame graphs are the two lenses on a hot Node process: retained size and the three-snapshot diff find a leak, the V8 profiler and flame graph find where CPU goes — and you must tell a true leak from harmless churn.
The pod restarts every six hours. RSS climbs a clean diagonal from 180MB to the 512MB cgroup limit, the kernel OOM-kills it, Kubernetes restarts it, and the line starts again — a perfect sawtooth on the dashboard that everyone has learned to ignore. “It’s just Node, it leaks, we bumped the limit.” Three weeks later p99 latency spikes start riding the same six-hour cycle: GC works harder as the live set grows, and the last hour before each kill is visibly slower. Nobody added a feature. The leak was a Map used as a per-request cache that nothing ever evicted — every request added a key, none were removed, and a heap snapshot would have named the retainer in two minutes.
Taking the snapshot: get the heap out of the process
A V8 heap snapshot is a complete graph of every reachable object at one instant, with the edges that keep each object alive. You have three ways to capture one, and the right choice depends on whether the process is healthy, hard to reach, or about to die.
From inside the process, v8.writeHeapSnapshot() dumps a .heapsnapshot file synchronously — it stops the world for the duration, which on a multi-GB heap can be seconds, so never call it on a hot path. Wire it to a signal handler so you can trigger it on demand:
const v8 = require("node:v8");
process.on("SIGUSR2", () => {
const file = `/tmp/heap-${Date.now()}.heapsnapshot`;
console.log("writing", v8.writeHeapSnapshot(file));
});
// kill -SIGUSR2 <pid> — captures a snapshot without an attached debuggerFor a process you can reach over the inspector, start it with --inspect and open chrome://inspect in Chrome; the Memory tab takes and diffs snapshots interactively. And for the case in the hook — a process that dies before you can catch it — --heapsnapshot-near-heap-limit=2 tells V8 to automatically write up to two snapshots as it approaches the heap limit, so you get the dump from the exact moment of failure instead of guessing.
Before any snapshot, v8.getHeapStatistics() is the cheap first read: used_heap_size, heap_size_limit, and number_of_native_contexts (a steadily rising context count is itself a classic leak signature). Sample it on an interval and you have the monotonic-growth signal that justifies pulling a full snapshot.
const v8 = require("node:v8");
setInterval(() => {
const s = v8.getHeapStatistics();
console.log(JSON.stringify({
used: s.used_heap_size,
limit: s.heap_size_limit,
contexts: s.number_of_native_contexts,
detached: s.number_of_detached_contexts,
}));
}, 30_000).unref();Reading the snapshot: shallow vs retained, and the retaining path
A snapshot is useless until you know which two sizes you are looking at. Shallow size is the memory the object itself occupies — for a plain object, just its headers and field slots, often tiny. Retained size is the memory that would be freed if this object were deleted: the object plus everything reachable only through it. A 200-byte Map with a shallow size of 200 bytes can have a retained size of 400MB because it is the sole thing keeping 400MB of entries alive. Retained size is what you sort by to find a leak; shallow size will hide the culprit at the bottom of the list.
The retaining path answers the actual question — why is this still alive? It is the chain of references from a GC root down to the object. If it terminates at a global, a module-level const, a closure, or an active timer, that is your leak’s anchor. The dominator of an object is the node that every retaining path must pass through; deleting a dominator frees its whole subtree, which is why dominator-tree view groups a leak into one collapsible block instead of ten thousand individual entries.
The hard part in practice is separating your objects from V8 internals. A raw snapshot is full of (system), (compiled code), Map (the hidden-class kind, not the JS Map), and (array) nodes that are V8 bookkeeping, not your data. Filter the Summary view by your own constructor names, sort by retained size, and follow the retaining path — the bytes you can act on are almost always rooted in your own closures, caches, or event-emitter listener arrays.
▸Why this works
A snapshot only contains reachable objects — V8 runs a full GC immediately before writing, so anything genuinely collectable is already gone and will not appear. This is exactly what makes a snapshot trustworthy for leak hunting: if an object is in the snapshot, it is reachable, which means something is deliberately keeping it alive. The job is never “why wasn’t this collected” (it can’t be — it’s reachable); it is “what reference do I need to break.”
The three-snapshot technique: name the survivors
A single snapshot tells you what is big now; it cannot tell you what is growing. The three-snapshot technique turns “big” into “leaking” by isolating objects that are allocated during work and then never released.
Concretely, in Chrome DevTools’ Memory tab: take snapshot 1 after the process has settled. Drive the suspected workload (replay N identical requests). Take snapshot 2. Drive the same workload again. Take snapshot 3. Now switch the dropdown to Comparison and compare snapshot 3 against snapshot 1 — or use the “Objects allocated between snapshot 1 and snapshot 2” selector. Anything that was allocated during the exercise and is still alive two snapshots later is suspect: a non-leaking workload allocates and releases, so its delta returns to zero. A leak shows a # New count that grows by the same amount each round and a # Deleted of zero. That linear, repeatable growth — not raw size — is the signature you trust.
| Signal | Healthy (churn) | Leak |
|---|---|---|
| RSS / heap over time | Sawtooth — climbs, GC reclaims, drops back | Monotonic — rises and never returns to baseline |
| 3-snapshot delta per round | Returns to ~0; allocated objects are deleted | Grows by a fixed amount each round; #Deleted ≈ 0 |
| Retaining path of survivors | None — objects unreachable after the request | Rooted in a global Map, closure, listener array, or timer |
| GC pressure / pause time | High allocation rate, short stable pauses | Pauses lengthen as the live set grows |
CPU profiling and flame graphs: where the time goes
A leak is a memory question; a hot endpoint is a CPU question, and the tool is a profiler, not a snapshot. The V8 sampling profiler interrupts the process thousands of times a second and records the call stack each time; the share of samples a function appears in approximates the share of CPU it consumed.
The lowest-friction capture is node --cpu-prof app.js, which writes a .cpuprofile you load straight into the Chrome DevTools Performance tab. The classic, dependency-free path is node --prof app.js, which emits a raw isolate-*.log of V8 ticks; node --prof-process isolate-*.log turns it into a human-readable summary with a “ticks” breakdown and a bottom-up call tree. For a flame graph specifically, 0x app.js or clinic flame -- node app.js renders the stacks as an interactive SVG.
# One-shot CPU profile, no deps — opens in DevTools › Performance
node --cpu-prof --cpu-prof-dir=/tmp/prof server.js
# V8 tick processor: raw log → ranked summary
node --prof server.js
node --prof-process isolate-*.log > processed.txt
# Flame graph
npx 0x -- node server.jsReading a flame graph is mechanical once you know the axes. Width is time, not call order — a frame’s width is the fraction of samples it was on the stack, so the widest frames are where CPU went. The y-axis is stack depth: a box sits on top of its caller. A function’s self time is its own width minus the widths of its children stacked above it; a wide frame with nothing on top is doing the work itself (a tight loop, a sync JSON.parse, a regex), while a wide frame that is wide only because of a tall stack above it is just a hot caller. Plateaus — wide flat tops — are your optimization targets. The horizontal order means nothing; do not read it left-to-right as a timeline.
A Node service's RSS climbs steadily toward its container limit over hours and gets OOM-killed. You need the artifact that names the retainer. Pick the first move.
In a heap snapshot, an object's shallow size is 80 bytes but its retained size is 300MB. What does that tell you?
A flame graph shows one very wide frame with almost nothing stacked on top of it. What does the width and the bare top mean?
Order the steps to find a suspected leak with the three-snapshot technique:
- 1 Let the process settle, then take snapshot 1 as the baseline
- 2 Exercise the suspected path — replay N identical requests
- 3 Take snapshot 2, then exercise the same N requests again
- 4 Take snapshot 3, then switch to Comparison and diff 3 against 1
- 5 Sort surviving allocations by retained size and follow the retaining path to the GC root
- 01Walk through how the three-snapshot technique distinguishes a true leak from harmless churn, and what 'retained size' adds.
- 02You have a flame graph from --cpu-prof. How do you read it to find what to optimize, and why is a snapshot the wrong tool here?
Heap snapshots and flame graphs are the two lenses on a hot Node process, and they answer different questions. A snapshot is the reachable object graph at one instant; capture it with v8.writeHeapSnapshot() on a signal, over the inspector’s Memory tab, or — for a process that dies before you can catch it — with --heapsnapshot-near-heap-limit, after sampling v8.getHeapStatistics() to confirm monotonic growth. Read it by retained size, not shallow size, because the leak is usually a tiny container anchoring a huge subtree; the retaining path from a GC root names the anchor and the dominator groups the subtree. The three-snapshot technique turns “big” into “leaking”: baseline, exercise, snapshot, exercise again, snapshot, then diff — objects allocated during work that survive to the third snapshot, with a flat # Deleted, are the leak. For CPU you reach for a profiler instead: --cpu-prof or --prof + --prof-process, rendered as a flame graph where width is CPU time and a wide bare top is high self time — the plateau you optimize. And the discipline underneath all of it is telling a true leak (monotonic growth, retained, rooted) from churn (high allocation, GC reclaims, sawtooth), because raising --max-old-space-size or cron-restarting only delays a leak you could have named in two minutes. Now when you see a pod restarting on a clean sawtooth schedule, the first thing you reach for is a signal handler and three snapshots — not a higher memory limit.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.