open atlas
↑ Back to track
JavaScript Engine internals JSE · 06 · 06

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

JSE Senior ◷ 15 min
Level
FoundationsJuniorMiddleSenior

“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:

  1. Snapshot 1 — establish a baseline after warm-up.
  2. Exercise the suspected operation N times (navigate, run the request, etc.).
  3. Snapshot 2.
  4. Exercise it N more times.
  5. 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). Climbing heapUsed ⇒ JS retention leak; climbing external/arrayBuffers with flat heapUsed ⇒ a Buffer/native leak; high rss with flat heapUsed ⇒ 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 .heapsnapshot from 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.

Heap measurement cheat sheet
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
Quiz

An array has a shallow size of 200 bytes but a retained size of 80 MB. What does that tell you?

Quiz

process.memoryUsage() shows rss climbing and external climbing, but heapUsed is flat. What is the likely cause?

Order the steps

Order the three-snapshot leak-finding workflow in DevTools.

  1. 1 Take snapshot 1 as a baseline after warm-up
  2. 2 Exercise the suspected operation N times
  3. 3 Take snapshot 2
  4. 4 Exercise the operation N more times, then take snapshot 3
  5. 5 Compare 'objects allocated between 1 and 2 still alive at 3', sort by retained size
  6. 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.

Recall before you leave
  1. 01
    What is the difference between shallow and retained size, and why do you hunt leaks by retained size?
  2. 02
    What is the dominator tree and how does it relate to retainer paths?
  3. 03
    How do you tell a JS retention leak, a native/Buffer leak, and fragmentation apart with process.memoryUsage()?
Recap

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.

recallapplystretch0 of 8 done
Connected lessons
appears again in3

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.