open atlas
↑ Back to track
Node.js, zero to senior NODE · 04 · 02

CPU profiles and the heap-snapshot leak hunt

A CPU profile shows where time goes — wide flame frames are hot. A leak is old space that climbs across GCs and never drops; find it with three heap snapshots, chase retained size and the retainer path, then bound the cache or remove the listener.

NODE Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

A checkout service started getting OOM-killed every 14 hours, like clockwork. The team’s first reflex was to raise --max-old-space-size from 2048 to 4096 — and the crash simply moved to every 28 hours. RSS climbed in a straight line across every garbage collection and never came back down, which is the signature of a leak, not a tuning problem. Three heap snapshots later the answer was a one-liner: a module-level Map used as a response cache, keyed by request id, with no eviction. Each request added one entry that nothing ever removed. The “fix” of doubling the heap had only bought time for the same Map to grow twice as large before killing the process again. In the next twenty minutes you’ll know how to read a CPU profile, take three heap snapshots, and follow the retainer chain straight to the culprit.

CPU profiling: find the wide frame

When a service is slow or pegging a core, you do not guess — you sample. A CPU profile records which function is on the stack at each sampling interval, so the function that appears most often is where time actually goes. The fastest way in Node is the built-in flag --cpu-prof: it writes a .cpuprofile file on exit that you open in the Chrome DevTools Performance tab. There you read a flame graph where each box is a stack frame and width = self time — the widest frames are your hot path, full stop. Depth is just call nesting; ignore it and chase width.

# writes isolate-*.cpuprofile on clean exit; open in Chrome DevTools → Performance
node --cpu-prof --cpu-prof-dir=./prof server.js

# the classic V8 tick processor: a text report, no GUI needed
node --prof server.js
node --prof-process isolate-*.log > processed.txt

--prof plus node --prof-process gives you the same sampled data as a text tick report — handy on a headless box where you can’t open DevTools. Higher-level tools wrap this: 0x generates an interactive flame graph from one command, and clinic flame does the same with a guided workflow. All of these are sampling profilers (snapshot the stack every N microseconds, cheap, ~1-2% overhead) as opposed to instrumentation (wrap every call, exact counts, but so heavy it distorts the very timings you’re measuring). For production triage you almost always want sampling. The classic find: a regex or a deep JSON.parse shows up as one fat frame eating 40% of self time — optimize it, cache it, or move it off the main thread to a Worker.

Heap snapshots: the three-snapshot leak hunt

A CPU profile won’t find a leak; for that you photograph the heap. A heap snapshot is a complete graph of every live object and who points to whom. Capture one with v8.writeHeapSnapshot() from inside the process, trigger one on demand with --heapsnapshot-signal=SIGUSR2 (then kill -USR2 <pid>), or take one from the DevTools Memory tab. One snapshot tells you what’s big now; it can’t tell you what’s leaking. For that you use the three-snapshot technique.

import v8 from "node:v8";

// snapshot 1 — baseline, after warm-up
v8.writeHeapSnapshot("./snap-1.heapsnapshot");
await exerciseSuspectPath({ times: 5000 }); // hammer the suspected route
// snapshot 2 — after the load
v8.writeHeapSnapshot("./snap-2.heapsnapshot");
await exerciseSuspectPath({ times: 5000 }); // do it again
v8.writeHeapSnapshot("./snap-3.heapsnapshot");

Load all three into the Memory tab, switch the dropdown to Comparison, and use “Objects allocated between snapshots”: anything whose count only ever grows from snap 1 → 2 → 3, and never drops after a GC, is your leak. A stable workload should reach a sawtooth — allocate, collect, repeat — so the steady upward staircase is the tell. The next column is the one that matters most: not shallow size (the object’s own bytes) but retained size — the memory that would be freed if this object were freed, i.e. everything only it keeps alive. Sort by retained size, pick the constructor whose count grew, and follow its retainer path (the chain of references holding it) straight to the cache, array, or listener that won’t let go.

Leak sourceSnapshot signatureFix
Unbounded Map/array cache, no evictionOne constructor’s count grows every snapshot; huge retained size on a single MapBound it: LRU with a max size / TTL, or WeakMap if keyed by object
Forgotten event listenersListener/closure count climbs; MaxListenersExceededWarning in logsremoveListener / off on teardown; once for one-shots
Closure capturing a large objectRetainer path runs through a closure context holding a big buffer/DOM-like graphNull the reference; don’t close over more than you need
Module-level global that only growsA top-level array/Set rises monotonically; never shrinks across GCsCap it, flush periodically, or move state out of module scope
Quiz

In a heap snapshot, you find a cache entry whose shallow size is 48 bytes but whose retained size is 90 MB. What does that tell you?

GC, the heap limit, and what “leak” actually means

Before you reach for --max-old-space-size, ask yourself: does memory return after a full GC, or does the floor keep climbing? The answer tells you whether you have a provisioning problem or a leak — and the fix is completely different.

V8’s heap is generational. New allocations land in new space, collected by Scavenge — fast, frequent, copying survivors out. Objects that survive a couple of Scavenges get promoted to old space, collected by the slower Mark-Sweep-Compact. The practical definition of a leak falls right out of this: old space (and RSS) that climbs across full GCs and never comes back down. A healthy process sawtooths; a leaking one staircases. Watch it directly with --trace-gc, which prints one line per collection with before/after sizes.

node --trace-gc server.js
# [12345:0x...] 4210 ms: Mark-Sweep 1850.2 (2048.0) -> 1844.9 (2048.0) MB
#                                    ^ after a full GC it barely dropped → leaking

The hard ceiling is --max-old-space-size=<MB>. On 64-bit Node the old-space default is large but bounded (historically ~2 GB; it varies by platform and version), and a real workload with a big legitimate heap may need it raised. But raising it is a tuning knob, not a leak fix — as the hook shows, doubling the limit just doubles the time to OOM. The diagnostic question is always the same: does memory return after a full GC? If yes, you’re under-provisioned, raise the limit. If no, you have a leak, take snapshots.

Why this works

Why does an unhandled, ever-growing Map defeat GC entirely? Garbage collection frees only what is unreachable. A module-level Map is reachable for the entire process lifetime, and every value you put in it is reachable through the Map — so from V8’s point of view none of it is garbage, ever. The collector is working perfectly; you’ve simply told it, by keeping the reference, that all of this is live. That’s why the fix is never “tune the GC” but “drop the reference” — add eviction so old keys become unreachable and the next Mark-Sweep can reclaim them.

Pick the best fit

A production API on a headless server is intermittently pegging one CPU core. You can't open Chrome DevTools against it and you want the lowest-friction way to see the hot function. What do you reach for first?

The leak-triage runbook

When memory climbs and you get OOM-killed, the moves are mechanical, not magical. Confirm it’s a leak (old space doesn’t drop after a full GC via --trace-gc). Take a baseline snapshot, exercise the suspected path hard, snapshot again, repeat for a third. Use the comparison view’s “allocated between snapshots” to find the constructor whose count only grows. Sort by retained size, open the retainer path, and walk it to the root reference — almost always an unbounded cache, a forgotten listener, a fat closure, or a module global. Then make the reference go away: add LRU eviction or a TTL to the cache, removeListener/off on teardown, null the closed-over object. Re-run the snapshot diff to prove the count is now flat. A regression test that asserts steady-state RSS after N iterations keeps it from coming back.

// before: leaks one entry per request, forever
const cache = new Map();
function handle(req) {
  cache.set(req.id, expensiveResult(req)); // never evicted → OOM in hours/days
}

// after: bounded — old keys become unreachable and GC reclaims them
import { LRUCache } from "lru-cache";
const cache = new LRUCache({ max: 10_000, ttl: 60_000 });
function handle(req) {
  cache.set(req.id, expensiveResult(req)); // evicts oldest past 10k entries
}
Order the steps

Order the steps of a heap-snapshot leak hunt, from first suspicion to verified fix:

  1. 1 Confirm it's a leak: --trace-gc shows old space not dropping after a full GC
  2. 2 Take a baseline snapshot after warm-up
  3. 3 Exercise the suspected path hard, then snapshot again — repeat for a third
  4. 4 Use the comparison view to find the constructor whose count only grows
  5. 5 Sort by retained size and follow the retainer path to the root reference
  6. 6 Cut the reference (LRU eviction / removeListener) and re-diff snapshots to prove it's flat
Quiz

With --trace-gc running, you see old space sit at ~1850 MB before a Mark-Sweep and ~1845 MB after, climbing a few MB every cycle. What does this pattern mean?

Recall before you leave
  1. 01
    What is the difference between shallow and retained size in a heap snapshot, and which one do you sort by to hunt a leak?
  2. 02
    Why does doubling --max-old-space-size not fix a memory leak, and how do you tell a leak from genuine under-provisioning?
Recap

Profiling and leak-hunting are two different jobs with two different tools. For CPU, you sample: --cpu-prof writes a .cpuprofile for the Chrome DevTools Performance tab, --prof plus node --prof-process gives a headless-friendly text tick report, and 0x or clinic flame wrap the same data into a flame graph where width is self time — chase the widest frame, ignore depth. For memory, you photograph the heap. A heap snapshot (v8.writeHeapSnapshot(), --heapsnapshot-signal=SIGUSR2, or the DevTools Memory tab) shows what’s live now, but only the three-snapshot technique — baseline, exercise the path, snapshot, repeat — reveals a leak, via the comparison view’s “objects allocated between snapshots”: the constructor whose count only grows is your culprit. Sort by retained size (what’s freed if this is freed), not shallow size, and follow the retainer path to the root: an unbounded Map/array cache, a forgotten event listener (MaxListenersExceededWarning), a fat closure, or a module global. The mechanism underneath is V8’s generational heap — fast Scavenge on new space, slower Mark-Sweep-Compact on old space — so the precise definition of a leak is old space that climbs across full GCs and never drops, which you watch with --trace-gc. Raising --max-old-space-size is a provisioning knob, never a leak fix: if memory returns after a full GC you’re under-provisioned, if it doesn’t you have a leak. The fix is always to make the reference unreachable — add LRU eviction or a TTL, removeListener on teardown, null the closure — then re-diff the snapshots to prove the count is flat. Now when you see RSS climbing in a straight line across GCs, you reach for --trace-gc first, not a bigger heap 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.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.