Heap snapshots in depth
Learn to read heap snapshots through retained size, retaining paths, dominators, root distance, views, closures, and duplicate data.
- 01Read ownershipSeparate shallow size from retained size and follow a retaining path to a root.
- 02Use snapshot viewsChoose Summary, Comparison, or Containment for a precise memory question.
- 03Explain internal retainersRecognize contexts, closures, duplicate strings, and DevTools console retention.
A photo of the heap
A heap snapshot is a record of objects and references at one moment. It tells you what exists and who holds it. It cannot prove which action made it grow, so use it with a comparison or allocation profile.
const cart = { items: ["tea", "rice"] };
console.log(cart.items.length);Line 1 creates cart and its items array. Line 2 prints 2, because the array contains tea and rice. In a snapshot, cart has its own shallow size and may retain the array too.
A photo of your room captures one moment. You can see everything and who is holding what, but not how the room changed before or after the photo.
- In real life: The photo shows every item now
- In JavaScript: A snapshot lists objects now
- In real life: You can see who holds a bag
- In JavaScript: You can inspect references
- In real life: It cannot show yesterday's tidy-up
- In JavaScript: It cannot show allocation history
Where the analogy stops: A real snapshot contains engine details and optimized layouts, not a literal room.
The everyday capture workflow is already in Finding memory leaks. This lesson goes deeper: it teaches how the engine-shaped data answers a precise ownership question.
Shallow and retained size
Shallow size is the memory for one object itself. Retained size is memory that would become unreachable if that object vanished, including its own shallow size.
| Term | Meaning | Simple picture |
|---|---|---|
| Shallow size | Bytes for this object itself | The empty bag |
| Retained size | This object plus objects lost if it goes | Everything that leaves with the bag |
| Distance | Fewest references from a GC root | Breadth-first steps from the entrance |
| Dominator | A node every path to another node passes through | The only door into a room |
Think of shallow size as the weight of an empty bag. Retained size is everything that leaves with the bag when you throw it away, but only things nobody else is holding.
In the graph, cart has 12 bytes and items has 24. Removing window.cart loses both, so cart retains 36 bytes. Cache does not join that total because banner has another route to it.
That is why a child list is not enough. Start from the roots, remove one candidate edge, and ask what becomes unreachable. The next replay does exactly that with the same graph used throughout the lesson.
This is a replay of the lesson's retained-size model, not a measurement of V8 object bytes.
script
window: ["cart", "banner"], cart: ["items", "cache"], items: [], banner: ["cache"], cache: [],}; console.log(retainedSize(nodes, "cart", "window"));Retainers and retaining paths
A retainer is an object or root that points at another object. A retaining path walks backward from a suspicious object to a GC root, answering the practical question: who keeps this alive?
const page = { cart: { items: ["tea"] } };
console.log(page.cart.items.length);Line 1 creates a path from window to cart to items. Line 2 prints 1. If items should disappear but remains in a snapshot, expand retainers until you find the first owner that should have let go.
Do not stop at the first large object. A cache, listener, closure, or global can be the true reason an ordinary array remains reachable.
This is a replay of the lesson's graph model, not a debugger attached to V8.
script
window: ["cart", "banner"], cart: ["items", "cache"], items: [], banner: ["cache"], cache: [],}; console.log(snapshotReport(graph, "window"));Dominator trees
A dominator is a node that every path to another node must pass through. Remove the dominator, and those dominated nodes become unreachable.
In the lesson graph, cart dominates items. Remove the window-to-cart reference and items has no path from window. Cart does not dominate cache, because window can still reach cache through banner.
A dominator is like the only door into a room. Everything inside can be reached only through that door, so removing the door frees all of it.
- In real life: One door leads into a room
- In JavaScript: One dominator leads to a subgraph
- In real life: Removing the door blocks the room
- In JavaScript: Removing the dominator loses reachability
- In real life: A second door changes the answer
- In JavaScript: A shared path prevents domination
Where the analogy stops: Objects can have many reference paths, while a room analogy has physical doors.
const graph = {
window: ["cart", "banner"],
cart: ["items", "cache"],
items: [],
banner: ["cache"],
cache: [],
};
console.log(snapshotReport(graph, "window"));distance 0shallow 8 bytes; retained 94 bytes
distance 1shallow 12 bytes; retained 36 bytes
distance 1shallow 10 bytes; retained 10 bytes
distance 2shallow 24 bytes; retained 24 bytes
distance 2shallow 40 bytes; retained 40 bytes
window: none | cart: none | banner: none | items: cart | cache: noneA listed owner is on every root path to that node in this small model.
cart retains items; cache is shared with banner. Cart is a gate for items, but not for cache.
Distance from GC roots
Distance from a GC root is the shortest number of references from a root. The engine computes it like breadth-first search: visit all distance-one objects before distance-two objects.
const page = { cart: { items: ["tea"] } };
console.log(2); // page -> cart -> itemsLine 1 has two edges from window to items. Line 2 prints 2. A short distance can make a path easy to understand, but it does not prove that an object is wrong or expensive.
Use distance to orient yourself, then inspect retaining paths and retained size. A deep object may be harmless; a close console reference may be the entire leak.
Summary, comparison, containment
Summary groups objects by constructor name. It is a good first view when you ask which kind of object keeps increasing.
const before = ["LessonCart", "LessonCart", "Banner"];
const after = ["LessonCart", "LessonCart", "LessonCart", "Banner"];
console.log(compareConstructors(before, after));Line 1 models a first capture with two carts. Line 2 models the second capture with three carts. Line 4 prints a small comparison report: LessonCart has a delta of one while Banner has a delta of zero.
function countConstructors(names) {
return names.reduce((counts, name) => {
counts[name] = (counts[name] ?? 0) + 1;
return counts;
}, {});
}
function compareCounts(before, after) {
const beforeCounts = countConstructors(before);
const afterCounts = countConstructors(after);
return Object.keys({ ...beforeCounts, ...afterCounts })
.sort()
.map((name) => name + ": " + ((afterCounts[name] ?? 0) - (beforeCounts[name] ?? 0)));
}
const before = ["LessonCart", "LessonCart", "Banner"];
const after = ["LessonCart", "LessonCart", "LessonCart", "Banner"];
console.log(compareCounts(before, after).join(", "));This model prints Banner: 0, LessonCart: 1. It shows the count change without claiming that the new cart is a leak.
Comparison subtracts one capture from another. It models added and deleted count per constructor, so a growing LessonCart count is easier to spot than one isolated row.
| View | What it groups | Question it answers |
|---|---|---|
| Summary | The same constructor names in one capture | Which kind of object has the surprising count or size? |
| Comparison | Constructor counts from before and after | Which kind grew, such as LessonCart changing from two to three? |
| Containment | Root paths and child references | Which concrete owner or retainer keeps one object alive? |
Containment expands a tree from GC roots. Use it after Summary or Comparison points you at a type and you need to follow a concrete path. The report and playground show this as window, then cart or banner, then their children.
- Bytes for the object itself
- Memory freed when one object disappears
- Walk backward to find who holds it
- Shortest edge count from a root
- Group entries by constructor
- Expand a tree starting at roots
Place each card under size, path, or view.
Contexts and closures
Internal objects are entries the engine uses to implement JavaScript. Names such as (system), (closure), and system / Context are usually evidence, not automatically a bug.
function makeCartReader() {
const cart = { items: ["tea", "rice"] };
return () => cart.items.length;
}
const readCart = makeCartReader();
console.log(readCart());Line 2 creates cart. Line 3 returns a function that reads cart later. Line 6 calls that function and prints 2. The closure needs storage for captured variables, so a Context object can appear on its retaining path.
Read the surrounding function name and the captured variable before judging the Context. A live callback may need it; a forgotten listener may be the root cause.
Duplicated strings and console objects
Duplicated strings are repeated text values that can make a Summary count surprising. Confirm the values, where they came from, and whether the browser has represented them separately before choosing a fix.
const labels = ["tea", "tea", "tea"];
console.log(labels.length);Line 1 creates three entries with the same text. Line 2 prints 3. A snapshot may lead you to the code that repeats text, but the right fix could be changing a response shape, a cache, or no code at all.
function countDuplicateLabels(labels) {
return labels.reduce((counts, label) => {
counts[label] = (counts[label] ?? 0) + 1;
return counts;
}, {});
}
const labels = ["tea", "tea", "rice", "tea"];
const counts = countDuplicateLabels(labels);
console.log(counts.tea);
console.log(counts.rice);This model prints 3 for tea and 1 for rice. It counts values, not allocated bytes, so inspect a retaining path before deciding that repeated text needs a fix.
DevTools can also retain an object that you logged while the console keeps the entry. Clear the console, avoid logging huge live graphs during investigation, then capture again before calling it an application leak.
Reading a real leak
Take a baseline snapshot, repeat the user action, and take a second snapshot. In Comparison, find a constructor whose count remains after cleanup. Then use Containment or retaining paths to find the owner.
import v8 from "node:v8";
class LessonCart {}
const carts = [new LessonCart(), new LessonCart()];
function keepContext() { const label = "snapshot lesson"; return () => label; }
keepContext()();
const chunks = [];
for await (const chunk of v8.getHeapSnapshot()) chunks.push(chunk);
const snapshot = JSON.parse(chunks.join(""));
const fields = snapshot.snapshot.meta.node_fields;
const typeIndex = fields.indexOf("type");
const nameIndex = fields.indexOf("name");
const nodeFieldCount = fields.length;
const names = snapshot.strings;
const nodeTypes = snapshot.snapshot.meta.node_types[typeIndex];
let cartCount = 0;
let contextCount = 0;
for (let index = 0; index < snapshot.nodes.length; index += nodeFieldCount) {
const type = nodeTypes[snapshot.nodes[index + typeIndex]];
const name = names[snapshot.nodes[index + nameIndex]];
if (type === "object" && name === "LessonCart") cartCount += 1;
if (name === "system / Context") contextCount += 1;
}
console.log("self_size", fields.includes("self_size"));
console.log("LessonCart", cartCount);
console.log("Context", contextCount > 0);The lesson test streams V8 snapshot JSON from a child process. It checks only stable facts: node fields include self_size, two LessonCart instances appear, and a system / Context entry exists. It never asserts IDs or byte sizes.
That restraint matters. Snapshot layout and object sizes change between V8 versions, flags, and runs. Your diagnosis should rest on a persistent path and a reproducible before-and-after difference.
Common mistakes
- The biggest shallow object is the leak. Follow its retainers first.
- Retained size belongs to every parent. Shared objects have other paths.
- A Context is always bad. Closures normally need captured variables.
- The console cannot affect a capture. DevTools can retain logged values.
A snapshot answers ownership at one moment. It does not prove intent, lifetime after the capture, or the single action that allocated an object.
Practice exercises
Type the output.
const cart = { items: ["tea", "rice"] };
console.log(cart.items.length);It prints 2.
Which name directly retains items?
cart is a direct retainer of items.
Which object is the only door to items?
Cart dominates items in the teaching graph.
You captured before and after opening a dialog. Which view compares them?
Use Comparison view.
What entry often appears between a closure and its captured cart?
A system / Context entry commonly holds closure variables.
A page retains a large cart after navigation. What should you inspect first?
Inspect the retaining path before changing code.
Check your understanding
Use the same graph rules in every answer: paths decide ownership, and views decide what question you can answer.
Question 1 of 8What does this print?
Read the code, then predictconst cart = { items: ["tea", "rice"] }; console.log(cart.items.length);Choose an answer to see the explanation.
Question 2 of 8What is shallow size?
Choose an answer to see the explanation.
Question 3 of 8Why is
cachenot retained only bycartin the model?Choose an answer to see the explanation.
Question 4 of 8What does a dominator describe?
Choose an answer to see the explanation.
Question 5 of 8Which view groups objects by constructor name?
Choose an answer to see the explanation.
Question 6 of 8What often explains a
system / Contextretainer?Choose an answer to see the explanation.
Question 7 of 8What does this shortest-path model print?
Read the code, then predictconst path = ["window", "cart", "items"]; console.log(path.length - 1);Choose an answer to see the explanation.
Question 8 of 8You have a baseline and a later snapshot after closing a drawer. Which order best tests a leak?
Choose an answer to see the explanation.
Key takeaways
- Shallow size is one object; retained size is what disappears with it.
- Retaining paths lead backward to a GC root.
- Dominators reveal gateways, while shared paths prevent ownership.
- Summary groups, Comparison subtracts, and Containment traces.
- Contexts, duplicate strings, and console logs need evidence before blame.
Remember the one-liner.
A heap snapshot is an ownership map, not a history of memory.
Coming next: Allocation profiling.
Work the same graph by hand
The small graph is useful because every view answers a different question without changing the objects. Read it slowly: window points to cart and banner; cart points to items and cache; banner also points to cache.
In DevTools the names and sizes will be different, but the questions are identical. First identify a surprising object, then choose a view, then prove the path. Do not start by deleting references or by trusting one large number.
Shallow versus retained size
Start with the smallest fact. Cart has a shallow size of 12 bytes in this teaching model. That number belongs only to cart itself; it does not include the array it points at, the shared cache, or the window that points at cart.
Now remove the one edge from window to cart. Cart and items lose every route from the root, so cart retains 12 plus 24 bytes: 36. Cache stays alive through banner, so adding its 40 bytes would be wrong. This is the empty-bag question: what leaves only when this bag leaves?
The retained-size replay makes that test visible. Its first frame records all reachable objects, its middle frame removes one root edge, and its result frame adds only the objects that actually disappeared. Treat the byte counts as model values, not V8 measurements.
Retainers and retaining paths
A retainer is simply a holder. In window.cart.items, cart retains items directly and window retains them indirectly. When an array should have gone away, expand backward until the path ends at a root or host handle that explains the lifetime.
The two paths to cache are window -> cart -> cache and window -> banner -> cache. This is why “cart contains cache” does not answer the ownership question. The second path is the small detail that changes the diagnosis.
In an application, name the first surprising owner in plain language: “a saved banner still points to this response” is more useful than “a large object exists.” Then trace one more step toward the root until you find the listener, cache, global, or closure that should release it.
Dominator trees and distance
A dominator tree turns the same paths into gateway questions. Cart is the only door to items because every route from window to items crosses cart. Remove cart from the model and items cannot be reached. In contrast, cart is not the only door to cache because banner is a second door.
Distance is simpler: count the shortest edges from a root. Window is distance zero, cart and banner are one, and items and cache are two. The model uses a breadth-first walk, so it visits all one-edge choices before considering two-edge choices.
Short distance helps you orient yourself, not judge blame. A distance-one global cache can be intentional. A distance-six object can be a leak if a forgotten listener owns the chain. Use distance to find your bearings, then use a retaining path to explain ownership.
Summary, comparison, and containment
Summary asks, “Which kinds of things are here?” It groups objects with the same constructor name, so a sudden group of many LessonCart entries is easy to notice. It is a useful opening screen, not proof that one instance is wrong.
Comparison asks, “What changed between two captures?” In the lesson data, LessonCart changes from two to three while Banner remains one. That delta points at a repeatable action to investigate, while Containment answers the next question by expanding from roots through concrete references.
Work in that order when the page grows after a repeated action: Comparison finds a growing group, Summary gives its ordinary shape, and Containment or Retainers reveals the particular owner. One view rarely supplies all three answers.
Contexts, closures, repeated strings, and the console
A closure is normal JavaScript. makeCartReader returns a function that still needs its local cart after the outer function returned. An engine can represent those captured locals through an internal Context, so a path through system / Context is evidence to read, not a verdict.
Read the function name and captured value beside the internal entry. A live button handler may correctly retain a small cart. A listener that should have been removed can retain a whole page model through the same kind of Context. The difference is product lifetime, not the word Context.
Repeated strings need the same patience. Three labels that all read tea may appear as repeated entries or may be represented differently by one engine run. Check their retaining paths and data source. Also clear DevTools console output and capture again: a console entry can keep a logged object available for inspection and make an investigation look like an application leak.
A repeatable reading order
Take a baseline only after the page is quiet. Repeat one user action, such as opening and closing a product drawer, then take a second snapshot. If the action should clean up, the comparison should not keep adding the same application objects after each repetition.
Choose one growing constructor and inspect a real instance. Read its retaining path backward. If the path says a mounted component owns it, repeat the action after unmounting. If it says a console entry owns it, clear the console and capture again. Change one cause at a time so the next comparison has a clear meaning.
Finally, write down both the path and the expected lifetime. “The drawer listener retains the old cart after the drawer closes” is a testable statement. “The heap is big” is only an observation. Heap snapshots are most useful when they turn a large report into that small ownership sentence.
You do not need to memorize every DevTools label. Start with one object that surprises you, read one path to a root, and state when that owner should release it. That small explanation is enough to choose the next measurement or code change.