Measuring the heap
The tools that turn 'memory keeps climbing' into a named retainer: heap snapshots (shallow vs retained size, dominators, the three-snapshot diff), allocation timelines, process.memoryUsage(), --trace-gc, v8.getHeapStatistics(), --max-old-space-size
“Memory keeps climbing” is a feeling. “47,000 retained Session objects, each rooted through app.activeSockets, holding 12 MB between them” is a bug report you can fix. The distance between those two sentences is entirely tooling: heap snapshots, dominator trees, and a couple of Node flags. The previous lesson said every leak is a retainer path — this one is how you make the engine show you that path instead of guessing.
Shallow size vs retained size — the distinction that matters
This lesson is the engine-level companion to performance/04-gc/06-gc-production (which covers the ops side — alerting, limits, restarts). Here: how to read the heap directly.
A heap snapshot is a full dump of the object graph at one instant — every object, its size, and its references. Two size numbers per object, and confusing them wastes hours:
- Shallow size — the memory the object itself occupies (its own fields), excluding what it references. An array’s shallow size is tiny even if it holds millions of huge objects.
- Retained size — the memory that would be freed if this object were deleted, i.e. the object plus everything reachable only through it. This is the number that tells you the cost of a leak: a small object with a huge retained size is anchoring a big subtree.
You hunt leaks by retained size, not shallow size. The leaked Map has a trivial shallow size but a retained size of every entry it alone keeps alive.
The dominator tree
To compute retained size, V8 builds the dominator tree of the object graph. Object A dominates object B if every path from a root to B passes through A — meaning if A is freed, B necessarily becomes unreachable too. An object’s retained size is the total shallow size of everything it dominates.
The dominator tree is the leak-hunter’s map: walk down from the roots and find the object whose retained size is large and unexpected. Its dominated subtree is exactly what it is keeping alive — and the path from a root to it is the retainer path you must cut.
The three-snapshot technique
A single snapshot shows what is in the heap, but not what is growing. The classic leak-finding method is three snapshots:
- Snapshot 1 — establish a baseline after warm-up.
- Exercise the suspected operation N times (navigate, run the request, etc.).
- Snapshot 2.
- Exercise it N more times.
- Snapshot 3.
DevTools’ “Objects allocated between snapshots” comparison shows objects born after snapshot 1 that are still alive at snapshot 3. In a healthy operation those should be near zero (everything transient got collected); a leak shows a count growing linearly with N. Sort the survivors by retained size, open one, and read its retainer path to the root. That sequence — diff, sort by retained, read retainer — is the whole workflow.
Allocation timeline and sampling
Snapshots are point-in-time; the allocation timeline (DevTools) and allocation sampling answer “where is the churn coming from?” by attributing allocations to the call stacks that made them, over time. Sampling is low-overhead enough for production-ish use. This is how you find a hot allocation site driving GC pressure (the lesson-02 minor-GC problem), distinct from a retention leak.
Node and programmatic counters
You don’t always have DevTools attached. Numbers you can log:
process.memoryUsage()→{ rss, heapTotal, heapUsed, external, arrayBuffers }.heapUsed= live V8 heap objects;heapTotal= committed V8 heap;rss= total resident process memory (includes native, stacks, code);external/arrayBuffers= memory held by C++ objects bound to JS (Buffers, ArrayBuffers). ClimbingheapUsed⇒ JS retention leak; climbingexternal/arrayBufferswith flatheapUsed⇒ a Buffer/native leak; highrsswith flatheapUsed⇒ fragmentation.v8.getHeapStatistics()/getHeapSpaceStatistics()— per-space sizes (new vs old vs large-object),used_heap_size,heap_size_limit. Lets you watch old space specifically.--trace-gc/--trace-gc-verbose— log every GC event with heap-before/heap-after and pause time. Plot old-space-after over time: monotonic growth = leak; oscillation around a mean = the GC keeping up.--max-old-space-size=<MB>— the old-space cap; raising it buys headroom but does not fix a leak (it only delays the OOM and lengthens major-GC pauses). The default is roughly 2 GB on modern 64-bit Node but version-dependent — measure, don’t assume.v8.writeHeapSnapshot()— dump a.heapsnapshotfrom inside the process (e.g. on a signal) to load into DevTools offline. Invaluable in production where you cannot attach a debugger.
In the browser, performance.measureUserAgentSpecificMemory() gives a cross-origin-isolated, breakdown-by-type estimate of the page’s memory — the supported successor to the old non-standard performance.memory.
- Hunt leaks by
- retained size, not shallow
- Retained size from
- the dominator tree
- What is growing?
- three-snapshot diff
- heapUsed climbs
- JS retention leak
- external/arrayBuffers climb
- Buffer / native leak
- rss high, heapUsed flat
- fragmentation
An array has a shallow size of 200 bytes but a retained size of 80 MB. What does that tell you?
process.memoryUsage() shows rss climbing and external climbing, but heapUsed is flat. What is the likely cause?
Order the three-snapshot leak-finding workflow in DevTools.
- 1 Take snapshot 1 as a baseline after warm-up
- 2 Exercise the suspected operation N times
- 3 Take snapshot 2
- 4 Exercise the operation N more times, then take snapshot 3
- 5 Compare 'objects allocated between 1 and 2 still alive at 3', sort by retained size
- 6 Open a top retainer and read its retainer path to the GC root to find what to cut
▸More practice
A production-safe routine: register a SIGUSR2 handler that calls v8.writeHeapSnapshot(), and a metrics loop that logs process.memoryUsage() plus v8.getHeapStatistics().used_heap_size every minute. When RSS alerts fire, dump two snapshots a few minutes apart and diff them offline in DevTools. This gets you a retainer path from a live, un-debuggable process without attaching —inspect (which you usually cannot do under load). Pair with —trace-gc redirected to a log so you can correlate the growth with GC behavior after the fact.
- 01What is the difference between shallow and retained size, and why do you hunt leaks by retained size?
- 02What is the dominator tree and how does it relate to retainer paths?
- 03How do you tell a JS retention leak, a native/Buffer leak, and fragmentation apart with process.memoryUsage()?
Measuring the heap turns “memory keeps climbing” into a named retainer. A heap snapshot dumps the whole object graph; the number that matters is retained size — the memory freed if an object were deleted, i.e. the object plus its dominated subtree — not shallow size, which is just the object’s own bytes. V8 computes retained size from the dominator tree, where A dominates B if every root-to-B path goes through A; the small object with a huge retained size is your leak’s anchor, and the path from a root to it is the retainer path to cut. To find what is growing rather than what merely exists, use the three-snapshot technique: baseline, exercise, snapshot, exercise, snapshot, then diff for objects allocated early and still alive late, sorted by retained size. Allocation timelines and sampling attribute churn to call stacks for the distinct minor-GC-pressure problem. Without a debugger, process.memoryUsage() separates a JS retention leak (heapUsed climbs) from a native/Buffer leak (external/arrayBuffers climb) from fragmentation (rss high, heapUsed flat); v8.getHeapStatistics() gives per-space sizes and the heap limit; —trace-gc logs every collection to plot old-space growth; —max-old-space-size caps old space (delaying, not fixing, a leak); and v8.writeHeapSnapshot() plus measureUserAgentSpecificMemory() capture the heap in production and in the browser. Now when you see an RSS alert fire, you have a repeatable procedure: read memoryUsage() to classify the leak type, dump two snapshots, diff by retained size, read the retainer path — and cut it.
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.