cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Heap snapshots in depth

Learn to read heap snapshots through retained size, retaining paths, dominators, root distance, views, closures, and duplicate data.

By the end, you can
  • 01
    Read ownershipSeparate shallow size from retained size and follow a retaining path to a root.
  • 02
    Use snapshot viewsChoose Summary, Comparison, or Containment for a precise memory question.
  • 03
    Explain 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.

Real-life analogyA photo of your room

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.

Four snapshot questions
TermMeaningSimple picture
Shallow sizeBytes for this object itselfThe empty bag
Retained sizeThis object plus objects lost if it goesEverything that leaves with the bag
DistanceFewest references from a GC rootBreadth-first steps from the entrance
DominatorA node every path to another node passes throughThe 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.

Step through retained size by removing cart
Step 0 of 5Ready
Your turn: follow the blue line

This is a replay of the lesson's retained-size model, not a measurement of V8 object bytes.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  window: ["cart", "banner"],  cart: ["items", "cache"],  items: [],  banner: ["cache"],  cache: [],}; console.log(retainedSize(nodes, "cart", "window"));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Step through the snapshot model
Step 0 of 5Ready
Your turn: follow the blue line

This is a replay of the lesson's graph model, not a debugger attached to V8.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  window: ["cart", "banner"],  cart: ["items", "cache"],  items: [],  banner: ["cache"],  cache: [],}; console.log(snapshotReport(graph, "window"));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Real-life analogyThe only door into a room

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.

Remove one reference
The graphJavaScript
const graph = {
  window: ["cart", "banner"],
  cart: ["items", "cache"],
  items: [],
  banner: ["cache"],
  cache: [],
};

console.log(snapshotReport(graph, "window"));
Snapshot report
windowdistance 0

shallow 8 bytes; retained 94 bytes

cartdistance 1

shallow 12 bytes; retained 36 bytes

bannerdistance 1

shallow 10 bytes; retained 10 bytes

itemsdistance 2

shallow 24 bytes; retained 24 bytes

cachedistance 2

shallow 40 bytes; retained 40 bytes

dominatorswindow: none | cart: none | banner: none | items: cart | cache: none

A listed owner is on every root path to that node in this small model.

Try it yourself

cart retains items; cache is shared with banner. Cart is a gate for items, but not for cache.

This is a small teaching graph. The report recomputes reachability, retained sizes, dominators, and distances with real lesson functions.

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.

Count reference stepsPop out in the code editor (opens in a new tab)JavaScript
const page = { cart: { items: ["tea"] } };
console.log(2); // page -> cart -> items

Line 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.

Compare constructor countsPop out in the code editor (opens in a new tab)JavaScript
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.

Count changes between two small capturesPop out in the code editor (opens in a new tab)JavaScript
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.

The same small graph in three views
ViewWhat it groupsQuestion it answers
SummaryThe same constructor names in one captureWhich kind of object has the surprising count or size?
ComparisonConstructor counts from before and afterWhich kind grew, such as LessonCart changing from two to three?
ContainmentRoot paths and child referencesWhich 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.

Sort snapshot terms
  • 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
Try it yourself
0 of 6 correct

Place each card under size, path, or view.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.

A closure keeps captured dataPop out in the code editor (opens in a new tab)JavaScript
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.

Repeated labelsPop out in the code editor (opens in a new tab)JavaScript
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.

Count repeated labelsPop out in the code editor (opens in a new tab)JavaScript
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.

Node-only V8 snapshot evidenceJavaScript
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

Exercise 1 · Warm-upPredict the cart output

Type the output.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const cart = { items: ["tea", "rice"] };
console.log(cart.items.length);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upName the retainer

    Which name directly retains items?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind the dominator

      Which object is the only door to items?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeChoose a view

        You captured before and after opening a dialog. Which view compares them?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeRead a closure retainer

          What entry often appears between a closure and its captured cart?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeApply it to a real app

            A page retains a large cart after navigation. What should you inspect first?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              Use the same graph rules in every answer: paths decide ownership, and views decide what question you can answer.

              Heap snapshot quiz · 8 questionsScore: first tries count
              1. Question 1 of 8What does this print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const cart = { items: ["tea", "rice"] };
                console.log(cart.items.length);

                Choose an answer to see the explanation.

              2. Question 2 of 8What is shallow size?

                Choose an answer to see the explanation.

              3. Question 3 of 8Why is cache not retained only by cart in the model?

                Choose an answer to see the explanation.

              4. Question 4 of 8What does a dominator describe?

                Choose an answer to see the explanation.

              5. Question 5 of 8Which view groups objects by constructor name?

                Choose an answer to see the explanation.

              6. Question 6 of 8What often explains a system / Context retainer?

                Choose an answer to see the explanation.

              7. Question 7 of 8What does this shortest-path model print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const path = ["window", "cart", "items"];
                console.log(path.length - 1);

                Choose an answer to see the explanation.

              8. 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.

              CompleteFrontend Clear concepts. Working examples.