Skip to content

Retained Size vs Shallow Size

advanced12 min read

The Two Numbers That Explain Everything

Open any heap snapshot in Chrome DevTools and you will see two size columns for every object: Shallow Size and Retained Size. Most developers glance at shallow size and move on. That is a mistake. The difference between these two numbers is the difference between finding a memory leak in 5 minutes and staring at a heap snapshot for hours.

Mental Model

Imagine you are cleaning out a storage unit. Each box has its own weight (shallow size). But some boxes contain keys to other locked storage units. If you throw away a box, you also free the units it was the only key to. The total weight freed — the box itself plus all the units you can now access and clean out — is the retained size. A small box weighing 100 grams could be the sole key holder for 10 tons of stuff.

Shallow Size

Shallow size is the memory consumed by the object itself — just the bytes used by its own internal fields and structure. It does not include anything the object points to.

const obj = {
  name: "Alice",
  scores: [95, 87, 91, 88, 94],
};

The shallow size of obj is roughly:

  • The object header (hidden class pointer, properties pointer)
  • The inline property slots for name and scores
  • Typically 32-64 bytes depending on V8's object layout

The shallow size does not include the string "Alice" or the array [95, 87, 91, 88, 94]. Those are separate objects in the heap that obj merely points to.

Retained Size

Retained size is the total memory that would be freed by the garbage collector if this object were removed from the heap. It includes the object itself plus all objects that are only reachable through this object.

The key phrase is "only reachable through." If Object A points to Object B, but Object C also points to Object B, then B is not part of A's retained size — because deleting A would not make B collectible (C still references it).

const controller = {
  cache: new Map(),       // 50MB of cached data
  history: [],            // 10MB of navigation history
  view: document.body,    // shared — also referenced by the DOM
};

The retained size of controller:

  • Includes cache (50MB) — if controller is the only reference to this Map
  • Includes history (10MB) — if controller is the only reference to this array
  • Does NOT include document.body — it is reachable from the DOM tree independently

So shallow size might be ~64 bytes, but retained size is ~60MB.

Quiz
Object A (shallow: 100 bytes) references Object B (shallow: 50MB). Object B is also referenced by Object C. What is Object A's retained size?

The Dominator Tree

To understand retained sizes, you need to understand the dominator tree. In graph theory, node A dominates node B if every path from the root to B must pass through A.

In heap terms: Object A dominates Object B if there is no way to reach B from a GC root without going through A first. If A is collected, B must also be collected.

In this example:

  • App State dominates Cache (only path from root goes through App)
  • App State does NOT dominate Shared Config (Sidebar provides an alternate path)
  • If App State is collected: Cache is freed, Shared Config survives
Quiz
In a dominator tree, what does it mean for Object A to dominate Object B?

Why Retained Size Matters More for Leak Hunting

When you are hunting memory leaks, shallow size is almost always misleading. Here is a real-world pattern:

class DataManager {
  #rawData = null;
  #processedResults = null;
  #domCache = null;

  load(url) {
    this.#rawData = fetch(url).then(r => r.json());
    this.#processedResults = transform(this.#rawData);
    this.#domCache = buildDOM(this.#processedResults);
  }
}

A DataManager instance has a shallow size of maybe 200 bytes — just the object header and three pointer-sized private fields. But if #rawData is 20MB, #processedResults is 15MB, and #domCache is 5MB, the retained size is ~40MB.

In a heap snapshot sorted by shallow size, this DataManager would not even appear on your radar. Sorted by retained size, it jumps to the top.

Common Trap

A single forgotten reference to a small "manager" or "controller" object can retain enormous amounts of memory. When leak hunting, always sort by retained size. A 64-byte object retaining 500MB is a common pattern in production apps — the leak is not the manager itself, it is everything the manager holds onto.

Practical Examples

Example 1: Closure Retaining a Large Dataset

function createFilter(data) {
  const processed = heavyTransform(data);

  return function filter(query) {
    return processed.filter(item => item.name.includes(query));
  };
}

const myFilter = createFilter(hugeDataset);
ObjectShallow SizeRetained Size
myFilter (closure)~100 bytes~100 bytes + size of processed
processed (array)~40 bytes (array header)Array header + all elements
Each element~200 bytes~200 bytes

The closure myFilter has a tiny shallow size. But its retained size includes the entire processed array and every object in it.

Example 2: Event Listener Retaining a Component Tree

function initDashboard() {
  const dashboard = buildComplexDOM();
  const charts = loadChartData();

  window.addEventListener('resize', () => {
    resizeCharts(dashboard, charts);
  });
}
ObjectShallow SizeRetained Size
Resize handler (closure)~100 bytes~100 bytes + dashboard DOM + charts data
dashboard (DOM tree)~80 bytes (root node)Entire subtree — potentially MB
charts (data)~40 bytesAll chart data — potentially MB

The resize handler is a tiny closure. But it closes over dashboard and charts, making its retained size potentially enormous.

How to See Retained Size in DevTools

  1. Take a heap snapshot
  2. In the Summary view, click the Retained Size column header to sort descending
  3. The top entries are your biggest memory consumers
  4. Expand any entry to see individual instances
  5. Click an instance to see its retaining tree in the lower pane
How V8 calculates retained size

Chrome computes retained sizes by building the dominator tree of the heap graph. This is done using the Lengauer-Tarjan algorithm — an efficient O(n log n) algorithm for computing dominators. For each node, V8 sums the shallow sizes of all nodes it dominates. This is why taking a heap snapshot can take a moment on large heaps — the dominator tree computation is not trivial. The good news: Chrome caches this computation, so subsequent views of the same snapshot are instant.

Key Rules
  1. 1Shallow size is the object's own memory — retained size includes everything that would be freed if the object were collected
  2. 2Retained size only counts objects reachable exclusively through this object — shared references do not count
  3. 3Always sort heap snapshots by retained size when hunting leaks — tiny objects can retain gigabytes
  4. 4The dominator tree determines retained size: A dominates B if every path from GC roots to B passes through A
  5. 5A forgotten reference to a small controller/manager can retain enormous amounts of memory through its private fields
What developers doWhat they should do
Sorting heap snapshots by shallow size to find leaks
Shallow size misses the real memory impact. A 100-byte closure retaining 50MB is invisible when sorted by shallow size
Sort by retained size — the biggest consumers are often tiny objects retaining large subgraphs
Assuming retained size equals the sum of all referenced objects' shallow sizes
If Object B is referenced by both A and C, B is not part of A's retained size because C keeps B alive independently
Retained size only includes objects exclusively retained — shared references are not counted
Ignoring objects with small shallow size in the heap snapshot
The most common leak pattern is a small object holding references to large data structures
Check retained size for small objects, especially closures, controllers, and manager patterns