---
title: 'Memory Inspection'
source: 'https://academia.sh/en/courses/browser-platform/memory-inspection'
course: 'The Browser and the Web Platform'
language: en
updated: '2026-08-17T18:09:11+00:00'
license: 'CC BY-SA 4.0'
---

# Memory Inspection

Defining a leak through reachability; shallow size, retained size, and retainer path computation, comparing snapshots, the measured difference between strong and weak references, and browser-specific leak sources.

A performance recording gives a breakdown of a moment. Some problems do not fit this
window: if the measurement page slows down the longer it stays open, growing heavier on
every refresh of the list, no single recording shows a long task for it. What accumulates
is memory that grows over time and is never released.

This lesson covers the tool that measures that accumulation and how to find its source.

## Defining a Leak: Reachability

The model established in the Memory Lifecycle lesson of the Asynchronous JavaScript and
Runtime course is the basis for diagnosis here: the garbage collector reclaims every
object **not reachable from the root set**. No reachable object is ever collected.

The definition of a leak follows from this. A **memory leak** is an object no longer used
staying reachable anyway. It is not the garbage collector's fault; it cannot know an
object is unused, it only knows reachability. Diagnosis is therefore done not with "which
object is large" but with **"who still refers to this object."**

## Shallow Size, Retained Size, and Retainer Path

Memory views list two separate sizes for an object, and the difference between them is
the center of the diagnosis.

```js
// retained-size.mjs — shallow size, retained size, and retainer path computation
const SHALLOW = {
  root: 16, listener: 32, closure: 32, measurementArray: 8192,
  detachedRow: 128, cell1: 64, cell2: 64,
  dashboard: 64, badge: 48, sharedStyle: 256,
};

const EDGES = {
  root: ["listener", "dashboard"],
  listener: ["closure"],
  closure: ["detachedRow", "measurementArray"],
  detachedRow: ["cell1", "cell2"],
  measurementArray: [], cell1: [], cell2: [],
  dashboard: ["badge", "sharedStyle"],
  badge: ["sharedStyle"],
  sharedStyle: [],
};

const ROOT_SET = ["root"];

function reachable(excluding = null) {
  const seen = new Set();
  const stack = ROOT_SET.filter((d) => d !== excluding);
  while (stack.length) {
    const d = stack.pop();
    if (seen.has(d)) continue;
    seen.add(d);
    for (const neighbor of EDGES[d]) if (neighbor !== excluding) stack.push(neighbor);
  }
  return seen;
}

const size = (set) => [...set].reduce((t, d) => t + SHALLOW[d], 0);

function retainedSize(node) {
  const before = reachable();
  const after = reachable(node);
  return size(before) - size(after);
}

// Retainer path: the shortest path from the root to the node.
function retainerPath(target) {
  const queue = ROOT_SET.map((d) => [d]);
  const seen = new Set(ROOT_SET);
  while (queue.length) {
    const path = queue.shift();
    const last = path[path.length - 1];
    if (last === target) return path;
    for (const neighbor of EDGES[last])
      if (!seen.has(neighbor)) { seen.add(neighbor); queue.push([...path, neighbor]); }
  }
  return null;
}

console.log("node".padEnd(18) + "shallow".padStart(9) + "retained".padStart(10));
for (const d of Object.keys(SHALLOW))
  console.log(d.padEnd(18) + String(SHALLOW[d]).padStart(9) + String(retainedSize(d)).padStart(10));

console.log("\nretainer path (detachedRow):", retainerPath("detachedRow").join(" → "));
console.log("retainer path (sharedStyle):", retainerPath("sharedStyle").join(" → "));
```

```
node                shallow  retained
root                     16      8896
listener                 32      8512
closure                  32      8480
measurementArray       8192      8192
detachedRow             128       256
cell1                    64        64
cell2                    64        64
dashboard                64       368
badge                    48        48
sharedStyle             256       256

retainer path (detachedRow): root → listener → closure → detachedRow
retainer path (sharedStyle): root → dashboard → sharedStyle
```

**Shallow size** is the object's own field allocation. **Retained size** is the total
space that would be reclaimed if that object were freed — the sum of the object and
everything reachable only through it.

The listener row summarizes the distinction: its shallow size is thirty-two bytes, its
retained size over eight thousand. In a view sorted by shallow size, this listener draws
no attention at all; sorted by retained size, it rises to the top. **In a leak hunt, the
sort criterion is retained size.**

The `badge` versus `sharedStyle` comparison gives the second rule. The badge's retained
size is only its own shallow size, because the style object it points to is also
referenced from the dashboard; freeing the badge leaves that object reachable still.
**Shared objects enter no retainer's retained size**; this is why summing retained sizes
does not give the heap's total size.

**Retainer path** is the reference chain from the root set to the object, and it is the
answer to the leak. The detached row's path says what holds it is a closure, and what
holds the closure is a listener; the fix belongs not in memory but in the code where that
listener is never removed.

## Measuring: Snapshot and Comparison

A single **heap snapshot** gives a state at one moment; a leak, though, is a trend. This
is why measurement is done by comparison.

The established method rests on three snapshots. The suspected operation — opening and
closing the measurement list — runs once, and the first snapshot is taken; this sets as
baseline the structures that form on first use and count as normal. The operation repeats
a few more times for the second snapshot, then once more for the third. **Objects that
keep existing between the second and third snapshot** are leak candidates; ones that grew
relative to the first but vanished in the second were temporary allocations.

For the comparison to be meaningful, garbage collection has to have run before each
snapshot is taken; tools do this automatically while taking one. Otherwise the measured
growth may just be garbage not yet collected.

## Strong and Weak References

The most common leak source is helper structures that store objects as keys: a mapping
holding data attached to an element keeps referring to it even after the element has left
the tree. The size of the difference can be measured directly.

The following measurement calls the garbage collector by hand and, for that, must be run
with `node --expose-gc`; run without the flag, the call is undefined.

```js
// leak-measurement.mjs — run with: node --expose-gc leak-measurement.mjs
// Two registries do the same job; the difference is whether they hold the object
// they use as a key with a strong or a weak reference.
const MB = (bytes) => (bytes / 1024 / 1024).toFixed(1);

function measure() {
  globalThis.gc();                     // undefined without --expose-gc
  return process.memoryUsage().heapUsed;
}

function trial(registry, count) {
  const base = measure();
  for (let i = 0; i < count; i++) {
    const node = { id: i, measurement: new Array(64).fill(i) };
    registry.set(node, { lastUpdated: i });
    // once the loop turn ends, nothing outside holds a reference to node
  }
  return measure() - base;
}

const COUNT = 200_000;
console.log(`heap remaining after creating and dropping ${COUNT} nodes:`);
console.log("  strong-reference registry:", MB(trial(new Map(), COUNT)), "MB");
console.log("  weak-reference registry  :", MB(trial(new WeakMap(), COUNT)), "MB");
```

```
heap remaining after creating and dropping 200000 nodes:
  strong-reference registry: 127.5 MB
  weak-reference registry  : 1.0 MB
```

The numbers depend on the runtime and machine; the ratio is what stays stable. The
strong-reference registry keeps every node reachable, and none is collected. In the
weak-reference registry, the key does not keep the object alive; a node with no other
reference is collected, and its entry in the registry goes with it.

The rule: **side data attached to an object should not extend its lifetime.** A weak
reference provides this, at the cost that the registry's contents cannot be iterated —
asking which keys still exist would itself keep them alive.

## Browser-Specific Leak Sources

Four patterns repeat in a page's context, and all four leave recognizable traces in the
retainer path.

**Detached nodes.** DOM nodes removed from the tree but still referenced by a variable, an
array, or a closure. They are marked as a separate class in memory views; a detached
node's entire subtree is retained with it. Holding a static list from the DOM API lesson
for a long time produces this result.

**Listeners not removed.** Every bound listener ties both the function and its closure to
the target node. The abort-signal option from the Event Model lesson solves this at the
source: one cancellation removes every listener bound to that signal. For a custom
element author, the counterpart is tearing down in the disconnect callback what was set
up in the connect callback.

**Timers and observers left running.** A repeating timer keeps its callback's closure
reachable indefinitely. The same holds for an observer that is not stopped even after the
element it watches leaves the tree. Both have the same remedy: an explicit stop call.

**Unboundedly growing caches.** A result mapping held inside a program grows for its whole
lifetime if given no limit and no eviction policy. The eviction-policy concept from the
Processor Cache lesson applies here: an unbounded cache is not a cache, it is a leak.

## Summary

- A memory leak is an unused object staying reachable; diagnosis is done with "who still
  refers to this."
- Shallow size is an object's own field, retained size the total space reclaimed if
  freed; the sort criterion in a leak hunt is retained size.
- Shared objects enter no retainer's retained size, so the sum of retained sizes does not
  give the heap's size.
- Retainer path is the chain from the root set to the object and points to the line of
  code to fix.
- A leak is a trend; objects that keep existing across consecutive snapshots are
  candidates.
- Side data attached to an object should not extend its lifetime; weak-reference
  structures provide this, at the cost of not being iterable.

## Next Step

The tools so far measured a result: which rule won, how long the request took, which
function held the main thread, which object stayed in memory. None answer "what was the
variable's value when this line ran." Why a row of the measurement list rendered missing,
which branch a recording took under which condition — these are only seen by stopping the
program at a chosen point and reading its state there. The next lesson covers this pausing
mechanism and how paused code in production output is traced back to its source.
