Retained Size vs Shallow Size Explained

Two columns, two questions, and choosing the wrong one is the most common reason a snapshot investigation goes in circles. This page belongs to interpreting heap snapshots for memory analysis inside Browser DevTools & Performance Profiling Workflows.

Symptom Root Cause Immediate Action
The biggest constructor turns out to be irrelevant Sorted by shallow size during a leak hunt Sort by retained size instead
A 96-byte object shows 4 MB retained It exclusively dominates a large subgraph That is correct — and it is your target
Retained sizes sum to more than the heap Retention nests and overlaps Never aggregate the column
Removing the top object frees far less than expected Its children had another retainer Check the dominator, not just the chain
Small instances have a huge shallow size Spilled properties or dictionary mode Sort by shallow size for that question

Root Cause: One Column Measures the Object, the Other Measures the Consequence

Shallow size is the memory of the object itself: its header, its in-object property slots, and for a string or array its own data. It is a local property, easy to compute and easy to reason about.

Retained size is the size of everything that would be freed if that object became unreachable — the object plus every object it exclusively dominates. Computing it requires building a dominator tree over the whole heap graph, which is why a snapshot takes a moment to process after capture.

The word “exclusively” carries the weight. If two parents both reference a child, neither exclusively dominates it, so the child’s bytes appear in neither parent’s retained size; they are attributed to the nearest common dominator further up. This is why removing the object at the top of the retained-size list sometimes frees less than the column promised: the estimate was correct about exclusivity at snapshot time, and your change altered which references remained.

The practical consequence is a simple rule. A leak hunt asks “what will I get back if I remove this”, and that is retained size. A bloat investigation asks “why is each of these instances so large”, and that is shallow size. The two questions look similar and have almost no overlap.

Exclusive domination decides retained size A cache entry of ninety-six bytes exclusively dominates a four megabyte response body, so its retained size is four megabytes. A separate shared configuration object is referenced by two parents, so neither parent retains it and its bytes are attributed to their common ancestor instead. Same heap, two very different attributions CacheEntry shallow 96 B · retained 4.1 MB response body shallow 4.1 MB · one parent exclusive → counted in the entry's retained size ViewA retained 0.2 MB ViewB retained 0.3 MB shared config shallow 2.4 MB · two parents shared → counted in NEITHER view's retained size it belongs to their common ancestor

Step-by-Step Fix

  1. Start every leak hunt sorted by retained size. DevTools path: Memory → Heap snapshot → Summary → click Retained Size. Expected: the top rows are the objects worth removing.
  2. Read the gap between the columns. Expected: a large ratio means the object is a handle to something big; a ratio near one means the object is the memory.
  3. Switch to shallow size for a bloat question. Expected: constructors whose per-instance size exceeds what their fields justify — the signal for spilled properties or dictionary mode.
  4. Do not aggregate retained size. Expected: the column’s sum exceeds the heap, because a parent’s figure already includes its children’s.
  5. Confirm with Size Delta. DevTools path: Comparison view. Expected: what grew between two captures, which is more actionable than what is merely large.

Command & Code Reference

Reproducing the gap so the two columns become concrete rather than theoretical.

// Small object, huge retained size: the entry exclusively holds the body.
const cache = new Map();
async function cacheResponse(url) {
  const body = await (await fetch(url)).text();   // ~4 MB
  // Shallow size of this entry object: under 100 bytes.
  // Retained size: ~4 MB, because nothing else references `body`.
  cache.set(url, { fetchedAt: Date.now(), body });
}

// Same bytes, no retention: the body is used and dropped.
async function useResponse(url) {
  const body = await (await fetch(url)).text();
  const count = body.split('\n').length;
  return count;              // `body` is collectable on return
}

Computing shallow and retained totals from a snapshot file, which is what DevTools shows in the two columns.

// Shallow totals are a linear pass; retained sizes require a dominator tree,
// which is why this only reproduces the first column.
function shallowByConstructor(snapshotPath) {
  const snap = JSON.parse(require('node:fs').readFileSync(snapshotPath, 'utf8'));
  const f = snap.snapshot.meta.node_fields;
  const stride = f.length;
  const nameIdx = f.indexOf('name');
  const sizeIdx = f.indexOf('self_size');
  const totals = new Map();
  for (let i = 0; i < snap.nodes.length; i += stride) {
    const name = snap.strings[snap.nodes[i + nameIdx]];
    totals.set(name, (totals.get(name) ?? 0) + snap.nodes[i + sizeIdx]);
  }
  // Sum of ALL shallow sizes equals the heap; sum of retained sizes does not.
  return [...totals].sort((a, b) => b[1] - a[1]).slice(0, 10);
}

Turning a large retained size into a smaller one without changing behaviour.

// The entry retained 4 MB because it held the whole body. Keeping only what
// is read drops the retained size to a few hundred bytes.
async function cacheSummary(url) {
  const body = await (await fetch(url)).text();
  const summary = {
    fetchedAt: Date.now(),
    lineCount: body.split('\n').length,
    // Copy, do not slice: a slice would retain the parent string.
    title: (' ' + body.slice(0, 80)).slice(1),
  };
  cache.set(url, summary);     // retained size now ~0.3 KB
}
The same snapshot, sorted two ways Sorted by shallow size the top rows are string, Array and Object, which are generic and unactionable. Sorted by retained size the top rows are CacheEntry, Detached HTMLDivElement and RouteState, each of which names a specific application structure. Same capture, two sorts — only one of them names your bug sorted by shallow size (string) 38.2 MB Array 21.6 MB Object 14.9 MB Generic engine categories. True, and impossible to act on. sorted by retained size CacheEntry 41.0 MB Detached HTMLDivElement 9.8 MB RouteState 6.2 MB Three names from your own codebase, each with an obvious next step.

Verification & Regression Prevention

Verify a fix by re-reading the retained size of the same object, not the heap total. Heap totals move for many reasons; a specific object’s retained size falling from 4.1 MB to 0.3 KB is unambiguous evidence that the change did what you intended.

For prevention, record the retained size of your application’s top three structures — the store, the cache, the router state — as part of the routine measurement described in automated leak detection. Those three numbers change slowly on a healthy application and step up sharply when someone introduces a new reference, which makes them a better early signal than the heap total they are part of.

Which column answers which question What will I get back if I remove this is answered by retained size. Why is each instance so large is answered by shallow size. What grew between captures is answered by size delta. How many instances exist is answered by the object count. Four questions, four columns — matching them is most of snapshot literacy Question Column What comes back if I remove this? Retained Size Why is each instance this big? Shallow Size What grew between two captures? Size Delta (Comparison) How many of these exist? Objects count Sorting by the wrong one produces a true answer to a question you were not asking.

FAQ

Why is a small object’s retained size several megabytes?

Because retained size counts everything that would be collected if that object became unreachable. A 96-byte cache entry that exclusively holds a 4 MB response body has a shallow size of 96 bytes and a retained size of about 4 MB. That gap is the entire point of the column.

Why do the retained sizes add up to more than the heap?

Retention is nested. A parent’s retained size includes its children’s, so summing a column counts the same bytes several times. Retained size is meaningful per object and meaningless in aggregate.

Which column should I sort by when hunting a leak?

Retained size, almost always. It answers the question you have — how much memory comes back if I remove this — while shallow size answers a different one, which is whether each individual instance is bigger than it should be.