completefrontend
Open lab My progress
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 what holds an objectTell shallow size from retained size, and follow a retaining path back to a root.
  • 02
    Use the snapshot viewsPick Summary, Comparison, or Containment for the question you want to answer.
  • 03
    Explain internal entriesRecognize Contexts, closures, repeated strings, and objects kept alive by the DevTools console.

A photo of the heap

JavaScript keeps your objects in a part of memory called the heap. A heap snapshot is a record of the heap at one moment. It lists every object that is still alive. It also lists the references. A reference is a link from one object to another, like cart.items linking a cart to its array.

A snapshot tells you two things:

  • which objects exist right now;
  • which object holds each one.

It can't tell you which click or which line of code made the heap grow. For that, you compare two snapshots, or you record which code creates objects. That recording is the next lesson.

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

Line 1 creates cart. Inside it, items is an array with two strings, tea and rice. Line 2 prints 2, because the array has two entries. In a snapshot, the cart and its array are two separate objects, with a reference from the cart to the array.

Real-life analogyA photo of your room

A photo of your room shows one moment. You can see everything in it, and who is holding what. You can't see how the room looked before the photo, or after it.

In real life: The photo shows every item in the room now
In JavaScript: A snapshot lists every object now
In real life: You can see who is holding each bag
In JavaScript: You can see which object points to which
In real life: It doesn't show how the room got messy
In JavaScript: It doesn't show which code made each object

The steps to take a snapshot in the browser are in Finding memory leaks. This lesson is about reading one: how to tell, from the snapshot, what keeps an object alive.

Shallow and retained size

Every object in a snapshot has two sizes. Both are counted in bytes. A byte is a tiny unit of memory.

  • Shallow size is the memory for the object itself, and nothing else.
  • Retained size is the memory that would be freed if this object went away. It counts the object itself, plus every object that only it keeps alive.

Each row of this table is one word you will meet in a snapshot. Read across: what it means, then an everyday picture of it.

Four words used in heap snapshots
WordWhat it meansEveryday picture
Shallow sizeThe bytes for this one object alone.The weight of an empty bag.
Retained sizeThis object, plus every object that would be lost if it went away.The bag plus everything that leaves with it.
DistanceThe fewest arrows you follow from a root to reach the object.How many stops you ride from the first station.
DominatorAn object that every path to another object goes through.The only door into a room.

Think of shallow size as the weight of an empty bag. Retained size is the bag plus everything that leaves with it when you throw it away. Things that someone else is also holding stay behind.

This lesson uses one small made-up set of five objects. The objects and their references form a graph: dots joined by arrows. window is a root. A root is a starting point that the garbage collector always keeps. (The garbage collector is the part of the engine that frees memory no code can reach.)

  • window points to cart and banner.
  • cart points to items and cache.
  • banner also points to cache.

cart has 12 bytes and items has 24. If window stops pointing to cart, both are lost. So cart retains 12 + 24 = 36 bytes. cache isn't part of that total, because banner still points to it.

Five objects with their sizes in bytes. window, the root, points to cart, 12, and banner, 10. cart points to items, 24, and cache, 40. banner also points to cache. If cart goes, cart and items are lost: 12 plus 24 is 36. cache stays, because banner still holds it.lost if cart goes: 12 + 24 = 36windowrootcart: 12items: 24banner: 10cache: 40banner still holds it
The numbers are sizes in bytes. If cart goes, cart and items go with it: 12 + 24 = 36. cache stays, because banner still points to it.

So looking at what an object points to isn't enough. To find its retained size, start from the root, take the object away, and see what can no longer be reached. The next step-through does exactly that, with the same graph.

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

This replays the lesson's retained-size code. The byte numbers are made up; they are not real V8 sizes.

Running in
  1. script
Next: line 1
  window: ["cart", "banner"],  cart: ["items", "cache"],  items: [],  banner: ["cache"],  cache: [],}; console.log(retainedSize(nodes, "cart", "window"));
CallStoreChangeResultBlue line: runs next. 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 anything that points to an object, and so keeps it alive. A retaining path is the chain of retainers from a root down to the object. You read it backward: from the object, up to the root. It answers one question: who keeps this object alive?

A path from page to itemsPop out in the code editor (opens in a new tab)JavaScript
const page = { cart: { items: ["tea"] } };
console.log(page.cart.items.length);

Line 1 makes page, a cart inside page, and an items array inside the cart. So the path goes page, then cart, then items. Line 2 follows that path and prints 1, because items has one entry.

Say items should be gone, but it is still in a snapshot. Walk back up its retainers, one at a time. Stop at the first one that should have let go of it.

Don't stop at the first big object. The real reason an array stays alive may be one of these:

  • a cache that saved it;
  • an event listener, which is a function that runs when something happens, like a click;
  • a closure, which is a function that keeps using variables from where it was made;
  • a global variable.
Step through the snapshot report
Step 0 of 5Ready
Your turn: follow the blue line

This replays the lesson's own graph code. It is not a debugger connected to V8.

Running in
  1. script
Next: line 1
  window: ["cart", "banner"],  cart: ["items", "cache"],  items: [],  banner: ["cache"],  cache: [],}; console.log(snapshotReport(graph, "window"));
CallStoreChangeResultBlue line: runs next. 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 of an object is another object that every path to it must pass through. Take the dominator away, and the object can no longer be reached. A dominator tree lists the objects in the order of which one dominates which.

In our graph, cart dominates items. If window stops pointing to cart, nothing else leads to items. cart does not dominate cache, because window can still reach cache through banner.

Left: cart is the only object that points to items, so cart is the only door to items. Right: both cart and banner point to cache, so there is no single door to cache.one way incartitemscart is the only doortwo ways incartbannercacheno single door
Every path to items goes through cart, so cart dominates items. Two paths reach cache, so neither one dominates it.
Real-life analogyThe only door into a room

A dominator is like the only door into a room. You can reach everything inside only through that door. So if you block the door, everything inside is cut off.

In real life: One door leads into a room
In JavaScript: One object is on every path to some others
In real life: Block the door, and no one gets in
In JavaScript: Take that object away, and the others can't be reached
In real life: A room with a second door stays open
In JavaScript: A second path means no single dominator
Take away one arrow
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

Each object is followed by its dominator: the object that every path from window to it goes through. "none" means there is no single door.

Try it yourself

cart keeps items alive. cache is shared with banner. So cart dominates items, but not cache.

A small made-up graph. The report is worked out again by the lesson's own code each time you press the button.

Distance from GC roots

A GC root is a root of the garbage collector, like window. An object's distance is the smallest number of references you must follow from a root to reach it. To find it, the engine first visits every object one arrow away, then every object two arrows away, and so on. So the first time it reaches an object is always by the shortest path.

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

Line 1 makes page point to cart, and cart point to items. That is two arrows. Line 2 prints 2, and its comment names the two arrows.

page points to cart, and cart points to items. Under them: distance 0, distance 1, distance 2. items is two arrows away from page.pagestep 1cartstep 2itemsdistance 0distance 1distance 2
Count the arrows from page. cart is one arrow away and items is two, so line 2 prints 2.

A short distance makes a path easy to read. It does not prove that the object is a problem, or that it is big.

Use distance to find your way around. Then check retaining paths and retained size. An object far from the root may be fine. An object one step from the console may be the whole leak.

Summary, comparison, containment

Chrome DevTools can show a snapshot in different views. Each view groups the same objects in a different way.

Summary groups objects by constructor name. A constructor is the class or function that made the object, like LessonCart. Start here when you want to know which kind of object keeps growing.

Count each kind of object before and afterPop 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 stands for a first snapshot, with two carts and one banner. Line 2 stands for a second snapshot, with three carts and one banner. Line 4 prints a small report. LessonCart has a delta of 1, and Banner has a delta of 0. A delta is the change: the count after, minus the count before.

The same count, written by handPop 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 version counts each name in both lists, then subtracts. It prints Banner: 0, LessonCart: 1. It shows that the count went up. It doesn't claim that the new cart is a leak.

Before: LessonCart 2, Banner 1. After: LessonCart 3, Banner 1. Change: LessonCart 1, Banner 0.beforeLessonCart: 2Banner: 1afterLessonCart: 3Banner: 1changeLessonCart: 1Banner: 0
Comparison counts each name in both snapshots and subtracts. LessonCart went up by one; Banner did not change.

Comparison does this subtraction between two real snapshots. For each constructor, it shows how many objects were added and how many were deleted. A growing LessonCart count is easier to spot this way than in a single snapshot.

Each row of this table is one view. Read across: how it groups objects, then the question it helps you answer.

Three ways to view the same snapshot
ViewHow it groups objectsWhat question it answers
SummaryBy constructor name, in one snapshot.Which kind of object has a surprising count or size?
ComparisonBy constructor name, counted before and after.Which kind grew? For example, LessonCart went from two to three.
ContainmentAs a tree that starts at the roots.What holds this one object and keeps it alive?

Containment shows a tree that starts at the roots. You open it one level at a time: window, then cart or banner, then what they point to. Use it after Summary or Comparison has pointed you to one kind of object, and you want to follow one real path.

Sort the snapshot words
  • The bytes for one object alone
  • The memory freed if one object goes away
  • Walk backward to find what holds an object
  • The fewest arrows from a root
  • Group objects by constructor
  • Open a tree that starts at the roots
Try it yourself
0 of 6 correct

Put 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

Some entries in a snapshot are not your objects. They are internal objects: things the engine makes so it can run your code. You will see names like (system), (closure), and system / Context. Seeing one is a clue, not a bug by itself.

A returned function keeps the cartPop 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 inside makeCartReader.
  • Line 3 returns a small function that reads cart later. This small function is a closure: it keeps using a variable from the place where it was made.
  • Line 6 calls makeCartReader and saves the small function as readCart.
  • Line 7 calls readCart() and prints 2.

The engine must keep cart after makeCartReader has finished. It keeps it in an internal object called a Context. So a Context can show up on the retaining path to cart.

Before you judge a Context, look at the function next to it and the variable it keeps. A function that is still in use may need it. A listener you forgot to remove may be the real cause.

Duplicated strings and console objects

Duplicated strings are the same text stored more than once. They can make the counts in Summary look surprising. Before you change any code, check three things:

  • what the values are;
  • where they came from;
  • whether the browser really keeps them as separate copies.
Three labels with the same textPop out in the code editor (opens in a new tab)JavaScript
const labels = ["tea", "tea", "tea"];
console.log(labels.length);

Line 1 creates an array with three entries that all say tea. Line 2 prints 3. A snapshot can lead you to the code that repeats the text. But the right fix might be to change what a server sends back, to change a cache, or to change nothing at all.

Count how often each label appearsPop 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 code prints 3 for tea and 1 for rice. It counts values, not bytes. So check a retaining path before you decide that the repeated text needs a fix.

The DevTools console can also keep an object alive. When you log an object with console.log, the console keeps it so you can open it later. So before you call something a leak in your app:

  • clear the console;
  • don't log huge objects while you investigate;
  • take a new snapshot.

Reading a real leak

A memory leak is memory that your app keeps but no longer needs. To find one with snapshots:

  1. Take a first snapshot. This one is the baseline.
  2. Repeat the action you suspect, like opening a menu.
  3. Take a second snapshot.
  4. In Comparison, find a constructor whose count stays higher after the action has cleaned up.
  5. Use Containment or a retaining path to find what holds it.
Read a real V8 snapshot in NodeJavaScript
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);

V8 is the JavaScript engine inside Chrome and Node. This code runs in Node only:

  • Lines 2 to 5 make two LessonCart objects and a closure.
  • Line 7 asks V8 for a snapshot. It arrives in pieces, and line 8 joins the pieces and reads them as JSON.
  • Lines 9 to 22 read each entry's type and name, and count the LessonCart objects and the system / Context entries.
  • Lines 23 to 25 print three facts.

The lesson test runs this code and checks only these facts:

  • each entry has a self_size field, which is V8's name for shallow size;
  • there are two LessonCart objects;
  • at least one system / Context entry exists.

The test never checks entry IDs or byte sizes. Those change between V8 versions, settings, and even runs. Your own diagnosis should rest on the same kind of facts: a path that stays, and a difference you can repeat before and after an action.

Common mistakes

  • "The object with the biggest shallow size is the leak." Not always. First follow its retainers to see what holds it.
  • "Retained size belongs to every object above it." No. A shared object, like cache, has another path, so it isn't counted for just one of them.
  • "A Context is always bad." No. Closures normally need to keep the variables they use.
  • "The console can't change a snapshot." It can. DevTools keeps the objects you logged alive.

A snapshot tells you who holds what at one moment. It can't tell you:

  • what the code was meant to do;
  • how long an object lives after the snapshot;
  • which action created the object.

Practice exercises

Exercise 1 · Warm-upPredict what the cart code prints

Type what this code prints.

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

    In the lesson graph, which object points straight to items and so keeps it alive?

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

      Exercise 3 · PracticeFind the dominator

      In the lesson graph, 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 took one snapshot before opening a dialog, and one after. Which view compares them?

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

          Exercise 5 · ChallengeName the closure's helper

          Which entry often sits between a closure and the cart it uses?

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

            Exercise 6 · ChallengeApply it to a real app

            After you leave a page, a large cart is still in memory. What should you look at first?

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

              Check your understanding

              Use the same lesson graph for every answer. Paths decide what holds an object. Views decide which 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 8In the lesson graph, why isn't cache part of cart's retained size?

                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 8This code counts the arrows on a path. What does it 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 took one snapshot, then opened and closed a drawer, then took another. Which order is the best way to test for a leak?

                Choose an answer to see the explanation.

              Work the same graph by hand

              This section goes over the whole lesson again, slowly, with the same five objects. Every view asks a different question about them, but the objects never change:

              • 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. The questions stay the same. First find an object that surprises you. Then pick a view. Then check its path. Don't start by deleting references, and don't trust one big number on its own.

              Shallow versus retained size

              Start with the smallest fact. In this lesson's graph, cart has a shallow size of 12 bytes. That number is for cart alone. It doesn't include the array cart points to, the shared cache, or the window that points to cart.

              Now take away the one arrow from window to cart. Cart and items can no longer be reached from the root. So cart retains 12 + 24 = 36 bytes. 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 step-through shows this test in three parts:

              1. It lists every object that can be reached.
              2. It takes away the arrow from window to cart.
              3. It adds up only the objects that were lost.

              The byte counts are made up for the lesson. They are not real V8 sizes.

              Retainers and retaining paths

              A retainer is simply a holder. In page.cart.items, cart holds items directly, and page holds items through cart. When an array should have gone away, walk backward from it. Keep going until the path ends at a root, or at something the browser holds, that explains why it is still alive.

              There are two paths to cache: window -> cart -> cache and window -> banner -> cache. So "cart contains cache" doesn't tell you what keeps cache alive. The second path changes the answer.

              In a real app, name the first surprising holder in plain words. "A saved banner still points to this response" helps more than "a large object exists." Then follow the path one more step toward the root. Keep going until you find the listener, cache, global variable, or closure that should let it go.

              Dominator trees and distance

              A dominator tree uses the same paths to ask: which object is the only door? Cart is the only door to items, because every path from window to items goes through cart. Take cart away, and items can't be reached. But cart is not the only door to cache, because banner is a second door.

              Distance is simpler: count the fewest arrows from a root. Window is at distance 0. Cart and banner are at 1. Items and cache are at 2. The lesson's code visits every object one arrow away before any object two arrows away.

              A short distance helps you find your way. It doesn't tell you who is to blame. A global cache one step from the root may be there on purpose. An object six steps away may be a leak, if a forgotten listener holds the chain. Use distance to find your way around. Then use a retaining path to explain who holds the object.

              Summary, comparison, and containment

              Summary asks: "What kinds of objects are here?" It groups objects with the same constructor name. So a sudden group of many LessonCart objects is easy to notice. It is a good first screen. It doesn't prove that any one object is wrong.

              Comparison asks: "What changed between two snapshots?" In the lesson's code, LessonCart goes from two to three, while Banner stays at one. That change points you to an action worth checking. Containment answers the next question: it opens the tree from the roots, one real reference at a time.

              When the page grows after a repeated action, use them in this order:

              1. Comparison finds a kind of object that keeps growing.
              2. Summary shows what that kind of object normally looks like.
              3. Containment or the retainers show what holds one of them.

              One view on its own rarely gives you all three answers.

              Contexts, closures, repeated strings, and the console

              A closure is normal JavaScript. makeCartReader returns a function that still needs its own cart, even after makeCartReader has finished. The engine may keep that cart in an internal Context. So a path through system / Context is a clue to read, not proof of a bug.

              Read the function name and the kept variable next to the Context. A button's click function that is still in use may rightly keep a small cart alive. A listener that should have been removed can keep a whole page's data alive through the same kind of Context. The difference is how long your app needs it, not the word Context.

              Repeated strings need the same care. Three labels that all say tea may show up as three separate entries, or the engine may store them in another way. Check their retaining paths and where the data came from.

              Also clear the DevTools console and take the snapshot again. The console keeps every logged object so you can open it later. That can make your own investigation look like a leak in the app.

              A repeatable reading order

              Take the baseline snapshot only after the page is quiet. Repeat one action, such as opening and closing a product drawer. Then take a second snapshot. If the action cleans up after itself, the same app objects should not keep adding up each time you repeat it.

              Pick one constructor that keeps growing, and look at one real object of that kind. Read its retaining path backward:

              • If a component on the page holds it, repeat the action after that component has been removed.
              • If a console entry holds it, clear the console and take the snapshot again.

              Change one cause at a time. Then the next comparison has a clear meaning.

              Last, write down the path and how long the object should live. "The drawer listener keeps the old cart after the drawer closes" is a sentence you can test. "The heap is big" is only something you noticed. Heap snapshots are most useful when they turn a big report into one short sentence like that, about who holds what.

              You don't need to learn every DevTools label. Start with one object that surprises you. Read one path to a root. Then say when that holder should let go. That short sentence is enough to choose your next measurement or code change.

              Key takeaways

              • Shallow size is one object alone. Retained size is everything that goes away with it.
              • A retaining path leads backward from an object to a root.
              • A dominator is the only door to an object. A second path means there is no single door.
              • Summary groups, Comparison subtracts, and Containment follows paths from the roots.
              • Contexts, repeated strings, and console logs are clues. Check the path before you blame them.

              Remember the one-liner.
              A heap snapshot is a map of who holds what, not a history of memory.

              Coming next: Allocation profiling.

              CompleteFrontend Clear concepts. Working examples.