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.
Step-by-Step Fix
- 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.
- 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.
- 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.
- Do not aggregate retained size. Expected: the column’s sum exceeds the heap, because a parent’s figure already includes its children’s.
- 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
}
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.
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.
Related
- Interpreting Heap Snapshots for Memory Analysis — the dominator tree these columns come from
- How to Take and Compare Heap Snapshots in Chrome — producing the captures to compare
- Object Shapes, Strings and Collection Memory Costs — what makes a shallow size larger than expected