What Distance Means in a Heap Snapshot

The Distance column sits next to Shallow and Retained Size in every heap snapshot view and most people never sort by it — this guide, part of Interpreting Heap Snapshots for Memory Analysis in the Browser DevTools & Performance Profiling Workflows section, shows what the number means and how it turns a wall of objects into a short list of leak suspects.

Symptom Root Cause Immediate Action Measurable Impact
Thousands of instances of one constructor, no idea which leak Healthy and leaked instances look identical by size Sort instances by Distance and compare the groups Separates live UI objects from retained leftovers in one click
Some instances show a much larger distance than their siblings They are reached through an unexpected long path (cache, closure chain) Open the Retainers pane of the outlier Finds the cache or history list holding old copies
Detached DOM nodes show distance 10+ They hang off a JS-held subtree, not the document Follow the shortest retainer to the JS owner Pinpoints the variable holding the detached tree
Distance shows as a dash or is missing Object is unreachable or only weakly reachable Retake the snapshot; it will be collected Avoids investigating garbage
Linked list or history grows, distance keeps increasing Each new entry is one edge further from the root Cap the list length or convert to a ring buffer Distance and count both stop growing

Root Cause: Distance Is the Shortest Path Length from a Root

When DevTools loads a heap snapshot, it runs a breadth-first search from the GC roots — the global object, native handles, the stack and a few internal root sets — and records, for every node, the number of edges on the shortest path from any root. That number is Distance. The global Window object has distance 1, a property on it has distance 2, an object stored in that property has distance 3, and so on. DOM nodes attached to the live document sit at small, predictable distances because they hang off document.

The value is useful because leaks change the shape of reachability, not only the amount. Consider 2,000 Row objects. 200 of them are rendered in the visible table and reachable through the component tree at distance 8 or 9. The other 1,800 were rendered on previous pages and are still held by an undo history array that grows by one entry per navigation, so they sit at distance 12, 13, 14 and beyond, each page one edge further away. Sorting the constructor’s instances by Distance splits those two populations instantly. The healthy group clusters at one depth; the leaked group sits deeper and fans out.

Distance also helps you navigate the Retainers pane. Its tree is ordered by the retainers’ own distances, so the first child at every level lies on the shortest path, as explained in reading the Retainers panel. And it is the tell-tale for detached DOM: a node in the document is a few edges from document, while a detached DOM node can only be reached through some JavaScript variable, so its distance reflects the depth of that variable plus the depth inside the detached subtree.

A missing or dash distance means BFS never reached the node through strong edges. That object is garbage waiting for the next collection — DevTools forces a GC before snapshotting, so you will rarely see it, but objects reachable only through weak references can linger with no distance until they are swept.

Two populations of one constructor, split by Distance A histogram plots the number of Row instances at each distance from 7 to 16. A tall green cluster at distances 8 and 9 holds about 200 visible rows reachable through the component tree. A red tail from distance 12 to 16 holds about 1,800 rows reached through an undo history array, each navigation one edge deeper. 400 200 0 7 8 9 10 11 12 13 14 15 16 Distance from nearest GC root visible rows (~200) history-held rows (~1,800)

Step-by-Step Fix

  1. Take a snapshot after reproducing the growth. Run the suspect flow several times, then open DevTools → Memory → Heap snapshot → Take snapshot. Verification: the snapshot’s Summary view lists the constructor you suspect with a count higher than the number of objects on screen.
  2. Expand the constructor and sort by Distance. Click the Distance column header inside the expanded constructor group. Verification: instances reorder, and you see one or more clusters of equal distance values.
  3. Identify the healthy cluster. Click an instance from the smallest-distance cluster and check its Retainers: it should lead through your framework’s component tree to document or the root component. Verification: the count of this cluster roughly matches what is rendered.
  4. Inspect an outlier from the deep cluster. Click an instance with a markedly larger distance. Its Retainers pane shows the long path — typically an array, a Map used as a cache, a history stack or a chain of closures. Verification: the path contains an owner that is not part of the rendered tree.
  5. Bound or clear the owner. Cap the history length, evict cache entries on navigation, or drop references in the component’s teardown. Verification: after repeating the flow, the deep cluster is gone or bounded to a fixed size.
  6. Re-snapshot and re-sort. Repeat the same flow the same number of times. Verification: all instances sit in the healthy distance band and the constructor’s count matches what is on screen within a small margin.
Why a growing history increases distance Window at distance 1 holds app at distance 2, which holds history at distance 3. The history is a linked chain of entries: entry 1 at distance 4, entry 2 at distance 5, entry 3 at distance 6, each holding that page's rows one level deeper. Every navigation adds an entry, so the leaked rows drift further from the root. Window d = 1 app.history d = 3 entry 1 (prev) d = 4 entry 2 (prev) d = 5 entry 3 (prev) d = 6 rows · d = 12 rows · d = 13 rows · d = 14 each navigation adds one link — leaked rows drift one edge deeper

Command and Code Reference

Use case: compute a distance histogram for one constructor from a saved snapshot. When a snapshot is too big to browse comfortably, this script prints how many instances sit at each distance, reproducing the chart above.

// distance-histogram.mjs — node distance-histogram.mjs file.heapsnapshot Row
// Parses the raw .heapsnapshot format directly: no dependencies needed.
import { readFileSync } from 'node:fs';

const [file, ctor] = process.argv.slice(2);
const snap = JSON.parse(readFileSync(file, 'utf8'));
const { node_fields, edge_fields, edge_types } = snap.snapshot.meta;
const NF = node_fields.length, EF = edge_fields.length;
const nameOff = node_fields.indexOf('name');
const edgeCountOff = node_fields.indexOf('edge_count');
const edgeTypeOff = edge_fields.indexOf('type');
const toNodeOff = edge_fields.indexOf('to_node');
const WEAK = edge_types[0].indexOf('weak');
const nodeCount = snap.nodes.length / NF;

// First-edge index for every node (edges are stored in node order)
const firstEdge = new Uint32Array(nodeCount + 1);
for (let i = 0; i < nodeCount; i++) {
  firstEdge[i + 1] = firstEdge[i] + snap.nodes[i * NF + edgeCountOff] * EF;
}

// Breadth-first search from the synthetic root (node 0), skipping weak edges
const dist = new Int32Array(nodeCount).fill(-1);
dist[0] = 0;
const queue = [0];
for (let q = 0; q < queue.length; q++) {
  const n = queue[q];
  for (let e = firstEdge[n]; e < firstEdge[n + 1]; e += EF) {
    if (snap.edges[e + edgeTypeOff] === WEAK) continue; // weak edges do not retain
    const to = snap.edges[e + toNodeOff] / NF;
    if (dist[to] === -1) { dist[to] = dist[n] + 1; queue.push(to); }
  }
}

// Histogram of distances for the requested constructor name
const hist = new Map();
for (let i = 0; i < nodeCount; i++) {
  if (snap.strings[snap.nodes[i * NF + nameOff]] !== ctor) continue;
  hist.set(dist[i], (hist.get(dist[i]) || 0) + 1);
}
[...hist.entries()].sort((a, b) => a[0] - b[0])
  .forEach(([d, n]) => console.log(`distance ${String(d).padStart(3)}: ${n}`));

Use case: the fix for the history-driven leak. A bounded ring buffer keeps undo working for recent pages while releasing everything older.

// Bounded history: at most `limit` entries, oldest evicted first
class BoundedHistory {
  constructor(limit = 20) {
    this.limit = limit;
    this.entries = [];
  }
  push(snapshotOfPage) {
    this.entries.push(snapshotOfPage);
    // drop the oldest entry so its rows become unreachable
    if (this.entries.length > this.limit) this.entries.shift();
  }
  pop() {
    return this.entries.pop();
  }
}

Verification and Regression Prevention

After the fix, the distance histogram for the constructor should show a single cluster at the healthy depth, with at most limit pages’ worth of instances in any deeper band. The instance count should stay flat across ten repetitions of the flow, and the constructor’s retained size in a Comparison-view diff should not increase between the second and third snapshots.

To make the check permanent, run the histogram script against snapshots captured in CI and assert that no instance of the watched constructor sits more than a few edges deeper than the healthy cluster, or simply that the count stays below a threshold after N repetitions. Any unbounded list, history or cache will break that assertion long before it breaks a user’s tab.

Instance count across repetitions Across ten repetitions of the flow, the suspect constructor’s instance count rose with every repetition before the fix. After the fix it stays flat at the healthy depth cluster, and its retained size does not increase between the second and third snapshots. instances flow repetitions (1 → 10) before: deep-distance instances pile up after: one cluster at healthy distance

Frequently Asked Questions

Is a large distance always a leak?

No. Deeply nested but legitimate structures, such as a large tree rendered by a component, naturally have large distances. Distance is a sorting tool: it becomes a leak signal when instances of one constructor split into clusters, or when the deepest values keep increasing as you repeat a flow.

Why is distance different from the number of levels in the Retainers pane?

The Retainers pane lists retainers, and each level includes every retainer, not just the one on the shortest path. Distance counts edges on the single shortest path from any GC root. Following the first child at each level of the Retainers pane traces exactly that path.

Do weak references affect distance?

Weak edges are not traversed when DevTools computes distance, so an object reachable only through a WeakMap key or a WeakRef has no meaningful distance and is eligible for collection. That is why objects cached only in weak structures do not show up as retained.