Reading the Retainers Panel in a Heap Snapshot

You have found an object in a heap snapshot that should have been collected, and the Retainers panel below it shows a tree of cryptic entries like context in function(), [123] and (internal array) — this guide, within Interpreting Heap Snapshots for Memory Analysis in the Browser DevTools & Performance Profiling Workflows section, explains how to read that tree and find the one reference you must remove.

Symptom Root Cause Immediate Action Measurable Impact
Retainers tree is dozens of levels deep DevTools shows every path, not only the shortest Expand only the first (shortest-distance) branch at each level Reaches a GC root in 5–15 clicks instead of hundreds
Top retainer is context in function() A closure’s context object captures the variable Open the function link to see which closure and variable Identifies the closure to rewrite or release
Path runs through WeakMap or (weak) entries Weak edges do not keep objects alive Skip weak branches; look for a strong sibling path Avoids fixing references that were never the problem
Every path ends in (Global handles) or (Document DOM trees) Native or framework-held references Look one level below the root for the JS owner Finds the listener, observer or map that registered it
Object disappears from the tree after refresh You inspected garbage that a forced GC removed Re-take the snapshot after a full reproduce cycle Ensures you only chase genuinely retained objects

Root Cause: Why Retainer Paths Are Hard to Read

A heap snapshot is a directed graph: every object is a node and every reference is an edge. The upper pane of the Memory panel lists nodes; the Retainers pane at the bottom answers the reverse question for the selected node — who points at me? — and then who points at them, recursively, until each path reaches a GC root such as the global Window, the stack, or a native handle. An object leaks when at least one strong path from a root to it still exists, so the job is to find that path and break it at the point where your code owns the reference.

Three details make the pane harder than it looks. First, it is a tree of all retaining paths, not only the one that matters, and a popular object can have hundreds. DevTools sorts each level by the retainer’s Distance — the number of edges from the nearest root — so the first child at each level lies on the shortest path; that is almost always the right one to follow. Second, entries use V8’s internal edge names. context in function() means a closure’s context object holds the variable; [42] is an element index in an array; table in Map or (internal array) points into a collection’s backing store; __proto__ and map are structural edges you almost never care about. Third, some edges are weak. Entries inside a WeakMap, WeakRef, or marked (weak) do not keep the object alive, and DevTools renders them in a dimmed style. If the only remaining paths are weak, the object is simply awaiting collection.

The practical reading rule is: follow the shortest strong path upwards, stop at the first entry that is your code — a named variable, a property on a class you wrote, an array you control — and ask why that reference still exists. The owner is usually one or two levels below the root, not the root itself. Understanding how retained size and shallow size are computed helps you pick which leaked object to start from: select the one with the largest retained size, since cutting its path frees everything it dominates.

Following the shortest strong retainer path The leaked Row object at the bottom is retained by index 42 of an array, which is the rows property of a Store instance, which is held by a closure context in an onResize function, which is registered on Window. A second branch to the right goes through a WeakMap and is dimmed because weak edges do not retain. The Store rows edge is highlighted as the reference to cut. Window (GC root) · distance 1 context in onResize() · distance 2 rows in Store · distance 3 ← cut here [42] in Array · distance 4 Row @8841 (the leaked object) table in WeakMap (weak — ignore) selected in the upper pane; Retainers pane reads upwards

Step-by-Step Fix

  1. Select a leaked object with a large retained size. In DevTools → Memory, open your snapshot in Comparison view against a baseline, sort by # Delta, expand the suspicious constructor, and click one instance. Prefer the instance with the largest Retained Size. Verification: the Retainers pane at the bottom populates with a tree.
  2. Expand only the first child at each level. The first child has the smallest Distance and lies on the shortest path to a root. Keep expanding the first child until you reach an entry whose distance is 1 or which is labelled as a root. Verification: the distance values decrease by one at each level you open.
  3. Translate each edge name as you go. Read rows in Store @123 as “the rows property of a Store object”; [42] in Array as “element 42 of an array”; context in function onResize() as “a variable captured by the onResize closure”. Verification: you can write the path as a sentence, such as “Window → onResize closure → store.rows → array[42] → Row”.
  4. Skip weak and structural edges. Ignore entries through WeakMap, WeakRef, (weak), __proto__ and map. If every branch is weak, the object is collectable — re-take the snapshot and confirm it is gone. Verification: the path you follow contains only strong, named references.
  5. Find the first reference your code owns. Walking downwards from the root, the first entry you wrote — a module variable, a class field, a listener registration — is the place to cut. Verification: clicking the function or source link next to the entry opens a file in your repository.
  6. Cut the reference and re-snapshot. Remove the object from the array on teardown, unregister the listener, or scope the closure so it no longer captures the store. Repeat the scenario and compare again. Verification: the constructor’s # Delta drops to 0 and the selected instance no longer exists.
Edge names you will meet in the Retainers pane A table-like diagram with six rows. context in function means a closure captured the variable and it retains. A bracketed number in Array means an element index and it retains. A property name in Object means a named property and it retains. Table in Map means a Map or Set backing store and it retains. Weak or WeakMap entries do not retain. Double underscore proto and map are structural edges to ignore. Edge label Plain meaning Retains? context in function() a closure captured this variable yes [42] in Array element at index 42 yes rows in Store named property on an object yes table in Map / Set collection backing store yes (weak), WeakMap, WeakRef observed, not owned no __proto__, map, (shape) structural V8 internals ignore

Command and Code Reference

Use case: the leak pattern behind the path in the diagram above. A resize handler registered on window captures a store whose rows array keeps growing after each view is destroyed.

// Leaky: every mounted view registers a closure on window that captures the store
function mountView(store) {
  const onResize = () => layout(store.rows); // closure context captures `store`
  window.addEventListener('resize', onResize);
  store.rows.push(...createRows());           // rows appended for this view
  // no teardown: window → onResize → context → store → rows → Row
}

// Fixed: teardown removes both the listener and the rows this view added
function mountViewFixed(store) {
  const controller = new AbortController();
  const added = createRows();
  store.rows.push(...added);
  window.addEventListener('resize', () => layout(store.rows), {
    signal: controller.signal,               // one call removes the listener
  });
  return function unmount() {
    controller.abort();                                     // cut edge 1: window → closure
    store.rows = store.rows.filter((r) => !added.includes(r)); // cut edge 2: rows → Row
  };
}

Use case: print the shortest retainer path from a saved snapshot in Node.js. Useful in CI to attach a readable path to a failing leak test without opening DevTools.

// Using the memlab heap analysis API (npm i -D @memlab/heap-analysis @memlab/core)
import { getFullHeapFromFile } from '@memlab/heap-analysis';

const heap = await getFullHeapFromFile('./after.heapsnapshot');
let target = null;
heap.nodes.forEach((node) => {
  // pick the first instance of the constructor we believe is leaking
  if (!target && node.name === 'Row') target = node;
});

// Walk the dominator-free shortest path upwards via pathEdge (set by memlab)
let edge = target && target.pathEdge;
const steps = [];
while (edge) {
  steps.push(`${edge.fromNode.name}.${edge.name_or_index}`);
  edge = edge.fromNode.pathEdge;
}
console.log(steps.reverse().join(' → '));

Verification and Regression Prevention

A retainer fix is verified when the same scenario, repeated the same number of times, produces a Comparison view with a # Delta of zero for the constructor you were chasing and the selected instance’s path no longer exists. Check the neighbouring constructors too: cutting one edge often reveals a second, longer path to the same object, which the Retainers pane will now show as its first child. Keep iterating until the object is gone, not merely until the path changes.

To prevent the regression, encode the path in a test. Scripted leak detection such as finding leaks with Memlab scenarios reports retainer traces automatically, and a CI job that fails when a trace containing Store.rows reappears will catch the same mistake in a future refactor. Pair it with a lint rule or code-review checklist item that every addEventListener on window or document in component code must be paired with an AbortController signal or an explicit removal.

Iterating until the object is gone Cut the edge the Retainers pane shows first, repeat the scenario the same number of times, and check the Comparison view. If the constructor’s # Delta is zero the fix is verified. If a second longer path appears as the first child, cut that edge next and repeat. Cut first edge the top retainer path Repeat scenario same count of repetitions Comparison view # Delta for the constructor Delta = 0 path no longer exists Delta above 0: the Retainers pane now shows the next path — cut it and repeat

Frequently Asked Questions

Why does the Retainers pane show so many paths?

Every object that is reachable at all can be reached in many ways, and DevTools lists every retainer, not only the one that matters. Follow the first child at each level, which has the smallest distance from a root; that shortest strong path is the one keeping the object alive in practice.

What does (Global handles) at the top of a path mean?

It means native code — the browser engine or an embedder — holds a handle to a JavaScript object. For web pages this is often an event listener registration or a pending callback. Look one level down for the JavaScript object that registered it, because that registration is what your code can undo.

Can an object be retained only by weak references?

No. If every path consists of weak edges, the object is unreachable in the strong sense and will be collected at the next suitable garbage collection. It may still appear in a snapshot taken at an unlucky moment; retaking the snapshot after the scenario settles will show it gone.