Retained Size vs Shallow Size
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.
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
nameandscores - 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) — ifcontrolleris the only reference to this Map - Includes
history(10MB) — ifcontrolleris 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.
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 StatedominatesCache(only path from root goes through App)App Statedoes NOT dominateShared Config(Sidebar provides an alternate path)- If
App Stateis collected:Cacheis freed,Shared Configsurvives
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.
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);
| Object | Shallow Size | Retained 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);
});
}
| Object | Shallow Size | Retained 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 bytes | All 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
- Take a heap snapshot
- In the Summary view, click the Retained Size column header to sort descending
- The top entries are your biggest memory consumers
- Expand any entry to see individual instances
- 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.
- 1Shallow size is the object's own memory — retained size includes everything that would be freed if the object were collected
- 2Retained size only counts objects reachable exclusively through this object — shared references do not count
- 3Always sort heap snapshots by retained size when hunting leaks — tiny objects can retain gigabytes
- 4The dominator tree determines retained size: A dominates B if every path from GC roots to B passes through A
- 5A forgotten reference to a small controller/manager can retain enormous amounts of memory through its private fields
| What developers do | What 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 |