cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Finding memory leaks

Use Chrome DevTools heap snapshots, comparison views, retainers, and detached DOM clues to find and verify JavaScript memory leaks.

By the end, you can
  • 01
    Capture useful evidenceChoose Heap snapshot, Allocation instrumentation on timeline, Allocation sampling, or Performance monitor for the question at hand.
  • 02
    Read retaining pathsUse shallow size, retained size, distance, dominators, and the Retainers pane to explain why an object is still alive.
  • 03
    Verify a leak fixRepeat a suspected action, compare snapshots, fix the retaining root, and prove the comparison stops growing.

Turn “the heap keeps growing” into evidence

This lesson is the DevTools investigation companion to memory management. That lesson teaches reachability, mark-and-sweep, and common leak sources. WeakRef & FinalizationRegistry explains why weak references are niche and nondeterministic. Here we use those concepts to find what is holding memory in a real debugging session.

Plain definition

Finding a memory leak means proving that memory stays reachable after the feature that needed it is gone, then following the retaining path back to the root that your code can clean up.

The work is practical: single-page apps navigating between routes, dashboards that run all day, virtualized or infinite lists, modals opened hundreds of times, and developer tools that cache large parsed data. You measure first, isolate one action, compare heap snapshots, read retainers, fix the root, and verify with a new comparison.

The Memory panel: which profile answers which question?

DEVTOOLS MAP

Open Chrome DevTools, choose the Memory panel, and pick the profile that matches the question. A Heap snapshot answers “what is reachable now?” Allocation instrumentation on timeline answers “what was allocated during this interaction and remained live?” Allocation sampling answers “which stacks allocate a lot over a longer run?” Detached elements focuses on elements retained by JavaScript references.

Memory tools and when to use them
ToolWhat it recordsUse it when
Heap snapshotA point-in-time graph of reachable JavaScript objects and related DOM nodes.Find what retains memory and compare before/after snapshots.
Allocation instrumentation on timelineInstrumented allocations over time, with objects still live at the end highlighted.Isolate which repeated action allocates objects that do not disappear.
Allocation samplingLower-overhead statistical allocation profile grouped by JavaScript stack.Study longer sessions or allocation-heavy code when exact objects are less important.
Performance monitorLive graphs such as JS heap size while you interact with the page.Notice a trend and choose a smaller action to investigate.
Performance panel Memory checkboxHeap graph correlated with scripting, rendering, and events in a performance recording.Connect memory growth to long tasks, route changes, or user interactions.

Heap snapshots open in views such as Summary, Comparison, Containment, and Statistics. Summary groups by constructor, Comparison shows deltas, Containment starts from roots such as Window and GC roots, and Statistics gives a high-level pie chart. The action bar also includes a collect-garbage button, class filters, and filters such as objects allocated between snapshots or objects retained by detached nodes.

Use page measurements as signals, not proof

The measuring performance lesson covers performance recordings in depth. For leak hunting, use Performance monitor or the Performance panel's Memory checkbox to spot a rising JS heap trend after garbage collection, then switch to the Memory panel to inspect objects and retainers.

A heap snapshot is a graph

MODEL

A heap snapshot is not a screenshot of all RAM. It is a graph of reachable JavaScript objects and related DOM nodes. Roots point to objects. Objects point to other objects. The Retainers pane shows incoming references to the selected object. Retained size comes from the dominator tree: if object A dominates object B, every path from roots to B goes through A, so freeing A could also free B.

Real-life analogyFinding who still holds a library book

If the book should have been returned, you do not guess from its weight. You follow the checkout records to the account that still holds it. Heap snapshots use the same habit: follow references instead of blaming the largest object first.

In real life: Checkout desk and account records
In JavaScript: GC roots such as window, host queues, and module state
In real life: A patron's account points to a borrowed book
In JavaScript: A reference from one object to another
In real life: A family account holds all child cards
In JavaScript: A dominator whose retained size includes objects only reachable through it
In real life: The checkout trail shows who can return the book
In JavaScript: The Retainers pane points back to the root you can fix

Where the analogy stops: Library records have one official owner. A heap object can have several retainers, native browser objects, optimized engine internals, and temporary DevTools console references. The graph model is still the right debugging map.

Columns and panes you read in a snapshot
TermMeaningHow to use it
Shallow sizeBytes held directly by one object, such as an array backing store or a DOM wrapper.Use it to spot large individual objects, but do not stop there.
Retained sizeBytes that could be freed if that object and everything only reachable through it became unreachable.Use it to find dominators: small owners can retain large subgraphs.
DistanceThe shortest reference distance from a GC root such as window, native roots, or a host queue.Lower distance often means a long-lived owner is nearby.
RetainersObjects that point to the selected object.Read the chain back to a root: a Map, listener, timer, closure, or console reference.

The next replay uses a deterministic teaching graph. The lesson's run context provides helper functions, so the driver code can run in Edit & run exactly as shown.

Read a retainer path
Step 0 of 7Ready
Your turn: follow the blue line

Use the simulated heap like DevTools: start from roots, find the path that retains a leaked object, and compare shallow size with retained size.

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
const reachable = computeReachable(snapshot);const retained = computeRetainedSizes(snapshot);const path = findRetainerPath(snapshot, "button:1"); console.log(reachable.length);console.log(path.join(" -> "));console.log(retained["panel:1"]);
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.

Take and compare heap snapshots

3 SNAPSHOTS

The reliable workflow is often called the three-snapshot technique. Load the page and perform one warm-up action so lazy code, caches, and initial listeners settle. Take a baseline snapshot. Repeat the suspected action several times, collect garbage from DevTools, take another snapshot, then switch the later snapshot to Comparison. Growing constructor rows are suspects, not verdicts; the verdict comes from retainers.

Compare two heap snapshots
Step 0 of 8Ready
Your turn: follow the blue line

This is the three-snapshot technique in miniature: warm up, take a baseline, repeat the suspected action, compare, then follow retainers.

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
const after = createLeakSnapshot(5);const diff = diffConstructorCounts(before, after);const detached = diff.find((row) => row.name === "DetachedHTMLDivElement");const functions = diff.find((row) => row.name === "Function");console.log(detached.delta);console.log(functions.delta);console.log(diff.filter((row) => row.delta > 0).map((row) => row.name + ":" + row.delta).join(", "));
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.

In Chrome's Summary view, the constructor filter can show Objects allocated between Snapshots 1 and 2. In Comparison view, sort positive deltas, expand the constructor, choose one instance, and read the Retainers pane. The playground below makes those deltas visible without asking the browser to allocate real memory.

Snapshot comparison playground
Snapshot comparison driverPop out in the code editor (opens in a new tab)JavaScript
const before = createLeakSnapshot(1);const after = createLeakSnapshot(5);const diff = diffConstructorCounts(before, after);const detached = diff.find((row) => row.name === "DetachedHTMLDivElement");const functions = diff.find((row) => row.name === "Function");console.log(detached.delta);console.log(functions.delta);console.log(diff.filter((row) => row.delta > 0).map((row) => row.name + ":" + row.delta).join(", "));
Constructor delta5 actions
Array+4

before 1, after 5

Context+4

before 1, after 5

DetachedHTMLDivElement+4

before 1, after 5

Function+8

before 2, after 10

HTMLButtonElement+4

before 1, after 5

Text+4

before 1, after 5

Try it yourself

Comparing one warm-up action with 5 repeated actions leaves 4 extra detached panels and 8 extra callbacks.

This is a deterministic simulation of constructor deltas. It teaches how to read DevTools Comparison output; it does not inspect this page's real heap.

Read retainer chains back to a root

ROOT CAUSE

A retainer chain is the answer to “who still points at this?” Start at a suspicious instance, then walk the Retainers pane until the chain reaches a root you own. Common endings are a module-level Map, a listener on window, a timer queue callback, a closure context, a framework cache, or an object you pinned in the DevTools console.

  • Module cache: a Map stores DOM nodes or route data and never deletes old entries. Fix it with a size cap, explicit deletion, or, for object metadata, WeakMap or WeakSet.
  • Event listener: Window or document stores a callback that closes over route data. Fix it with removeEventListener, AbortController, or { once: true } when the behavior is one-shot. Review events for the mechanics.
  • Timer: the host timer queue keeps an interval callback alive. Fix it with clearInterval or clearTimeout during teardown; the timers lesson covers the queue behavior.
  • Closure: a long-lived callback captures more than it needs. Review closures, then capture an ID or count instead of a whole object graph when that is enough.
Retained size is about exclusive ownership

If two independent roots can reach the same object, neither root exclusively dominates it. That is why retained size can be surprising: a small object can retain a large private subtree, while a large shared object may not be freed by deleting one owner.

Detached DOM nodes

LIVE DEMO

A detached DOM node has been removed from the document tree but is still referenced. In snapshots it can appear through detached-element tooling, detached-node filters, or constructor names that make the DOM wrapper obvious. The real clue is the retainer path: an array, listener, closure, cache, or console reference still points at it.

Detached from the document, still retainedPop out in the code editor (opens in a new tab)JavaScript
const retainedNodes = [];const leakedListeners = []; function mountAndLeakPanel() {  const panel = document.createElement("section");  panel.className = "preview-panel";  panel.textContent = "Preview panel";  const onResize = () => panel.textContent.length;  window.addEventListener("resize", onResize);  document.body.append(panel);  panel.remove();  retainedNodes.push(panel);  leakedListeners.push(onResize);} mountAndLeakPanel();console.log(document.querySelector(".preview-panel") === null);console.log(retainedNodes.length);console.log(retainedNodes[0].isConnected);for (const listener of leakedListeners) window.removeEventListener("resize", listener);retainedNodes.length = 0;

The snippet prints true, 1, and false: the panel is no longer found in the document, one JavaScript array still stores it, and the node's isConnected flag is false. The final two lines clean up this bounded demo.

Fixed version: abort the listener and keep no nodePop out in the code editor (opens in a new tab)JavaScript
const retainedNodes = []; function mountAndCleanPanel() {  const controller = new AbortController();  const panel = document.createElement("section");  panel.className = "preview-panel";  panel.textContent = "Preview panel";  const onResize = () => panel.textContent.length;  window.addEventListener("resize", onResize, { signal: controller.signal });  document.body.append(panel);  panel.remove();  controller.abort();} mountAndCleanPanel();console.log(document.querySelector(".preview-panel") === null);console.log(retainedNodes.length);console.log("listener aborted");
Leaky versus fixed mount/unmount demo
Leaky and fixed component sketchPop out in the code editor (opens in a new tab)JavaScript
const retained = []; function leakyMountUnmount(host) {  const panel = document.createElement("section");  panel.textContent = "Preview";  const onResize = () => panel.textContent.length;  window.addEventListener("resize", onResize);  host.append(panel);  panel.remove();  retained.push({ panel, onResize });} function fixedMountUnmount(host) {  const controller = new AbortController();  const panel = document.createElement("section");  panel.textContent = "Preview";  window.addEventListener("resize", () => panel.textContent.length, { signal: controller.signal });  host.append(panel);  panel.remove();  controller.abort();}
Live demo statebounded
Detached panels retained by demo array0
Leaky listeners currently installed0
Fixed runs completed0

Render tick 0. The page never allocates unbounded data.

Try it yourself

The fixed path removes the panel and aborts the listener, so this demo keeps no detached panels.

Use this bounded demo with the real Chrome Memory panel: run the leaky button several times, collect garbage, take snapshots, then clear and try the fixed button.

Allocation timeline, sampling, and heap graphs

MEASURE

Heap snapshots answer “what is alive now?” Allocation tools answer “when did it appear?” With Allocations on timeline, record, perform the interaction, stop, and select the time range. Blue bars represent objects still live at the end of the recording; gray bars represent objects that were allocated and later collected. Selecting a constructor shows its retaining tree in the Retainers pane.

Allocation sampling trades precision for lower overhead and a stack-based allocation breakdown. Use it for long-running dashboards or heavy interactions. For a quick trend, open Performance monitor and watch JS heap size while you repeat the action, or record in the Performance panel with the Memory checkbox so heap growth is lined up with script, rendering, and events.

Browser memory APIs are diagnostics, not leak testsJavaScript
// Chrome exposes this non-standard property in some contexts.console.log("memory" in performance); // This newer measurement requires cross-origin isolation headers.if (crossOriginIsolated && "measureUserAgentSpecificMemory" in performance) {  const report = await performance.measureUserAgentSpecificMemory();  console.log(report.bytes);} else {  console.log("needs cross-origin isolation");}

performance.memory is non-standard and Chrome-specific. The newer measureUserAgentSpecificMemory() API requires cross-origin isolation, so it is useful for controlled diagnostics, not as a portable app invariant.

Node.js heap snapshots

NODE

Node uses V8 too, so the same heap snapshot ideas apply. Start a local process with --inspect, open chrome://inspect, choose the Node target, and use the Memory panel. Snapshot creation can block the event loop and temporarily require much more memory, so production triggers must be private and safe to crash.

Inspect a Node process from ChromeShell
node --inspect server.js
# Open chrome://inspect, choose the Node target, then use the Memory panel.
Prove the V8 heap APIs existJavaScript
import v8 from "node:v8";console.log(typeof v8.getHeapStatistics);console.log(typeof v8.writeHeapSnapshot);console.log(v8.getHeapStatistics().heap_size_limit > 0);

In this lesson's tests on Node 22, that probe prints function, function, and true. To write an actual snapshot, call v8.writeHeapSnapshot() from a protected diagnostic path, then load the .heapsnapshot file in Chrome DevTools.

Programmatic Node heap snapshotJavaScript
import { writeHeapSnapshot } from "node:v8"; // Use a private diagnostics path and protect whatever trigger calls this.const file = writeHeapSnapshot("./diagnostics/app.heapsnapshot");console.log(file);
Automatic snapshots near heap limitShell
node --heapsnapshot-near-heap-limit=3 server.js
# Node writes up to three snapshots as the heap approaches its limit.

A leak-hunting workflow you can repeat

CHECKLIST

Treat leak hunting like debugging, not fortune-telling. The advanced debugging lesson gives you the same discipline for breakpoints and traces: make the bug repeatable, reduce noise, observe, explain, fix, and verify.

  1. Reproduce: Make the leak happen with the smallest repeatable action, such as opening and closing a panel.
  2. Measure: Use Performance monitor or the Performance panel Memory checkbox to see whether used JS heap trends up after collection.
  3. Isolate: Remove unrelated work, reload, warm up once, then repeat only the suspected action.
  4. Compare: Take the three snapshots: warm-up baseline, after one action, after many actions. Compare the later two.
  5. Follow retainers: Open a growing constructor, select an instance, and read Retainers until you reach a root you own.
  6. Fix and verify: Remove listeners, abort signals, clear timers, cap caches, or use WeakMap/WeakSet when ownership should be weak. Then compare again.
Regression tests must be honest

A memory regression test can run in a dedicated Node process with --expose-gc, sample heap counts, or use FinalizationRegistry as a best-effort signal. Never assert exact GC timing in a normal app. Repeat the test, isolate the process, and fail only on robust evidence such as a retained object count that keeps increasing.

A cautious leak-regression sketchJavaScript
// Run in a dedicated Node process, not in a normal browser page.// node --expose-gc leak-regression.test.jsconst registry = new FinalizationRegistry((label) => {  console.log("collected", label);}); (function mountAndUnmount() {  const marker = { label: "panel" };  registry.register(marker, "panel");})(); globalThis.gc?.();await new Promise((resolve) => setTimeout(resolve, 0));console.log("test can only use this as a best-effort signal");

Common misconceptions

What to say instead
MisconceptionMore accurate wording
A heap snapshot is a screenshot of RAMIt is a graph of reachable JavaScript objects and related DOM nodes, organized by roots and references.
The biggest shallow object is always the leakA small owner with a large retained size can dominate far more memory.
Detached means collectedDetached only means removed from the document. JavaScript references can still retain the node and its subtree.
One snapshot proves a leakYou need a repeatable action, comparison, retainers, a fix, and a new comparison.
performance.memory is a portable test APIIt is non-standard. Treat it as a Chrome diagnostic hint, not production logic.
Leak clue or normal memory behavior?
  • `DetachedHTMLDivElement` count grows by the same amount after every route change.
  • A syntax-highlighter cache appears only the first time a route opens.
  • A selected object is retained by the DevTools console.
  • A `Window` resize listener retains a closed route's rows array.
  • Blue bars remain in an allocation timeline after a repeated action.
  • Heap size bounces up and down while you type in a search box.
Try it yourself
0 of 6 correct

Sort each observation by whether it deserves investigation, is usually normal, or is likely a tooling artifact.

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

Practice: inspect, compare, fix, verify

5 EXERCISES
Exercise 1 · Warm-upPredict retained size

The run context supplies the heap helpers. What number does this print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
const sizes = computeRetainedSizes(createLeakSnapshot(1));
console.log(sizes["panel:1"]);

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

    Exercise 2 · PracticeRead the retaining path

    Type the exact retaining path printed by the simulation.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const path = findRetainerPath(createLeakSnapshot(1), "button:1");
    console.log(path.join(" -> "));

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

      Exercise 3 · PracticeRead the comparison delta

      How many extra detached panels does the comparison report?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const before = createLeakSnapshot(1);
      const after = createLeakSnapshot(5);
      const detached = diffConstructorCounts(before, after)
        .find((row) => row.name === "DetachedHTMLDivElement");
      console.log(detached.delta);

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

        Exercise 4 · PracticeFix the retaining root

        Name one cleanup technique that would prevent the listener from retaining the panel.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        function mountPanel() {
          const panel = document.createElement("section");
          const onResize = () => panel.textContent.length;
          window.addEventListener("resize", onResize);
          panel.remove();
          // What cleanup should run here?
        }

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

          Exercise 5 · ChallengeVerify the fix

          After changing code, what DevTools step proves the leak stopped growing?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          // After fixing the listener and cache, what DevTools step proves it?
          // Type the workflow step, not code.

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

            Quiz: finding memory leaks

            8 QUESTIONS
            Lesson quiz · 8 questionsScore: first tries count
            1. Question 1 of 8What is a heap snapshot in the Memory panel?

              Choose an answer to see the explanation.

            2. Question 2 of 8Which Memory panel profile has the lowest overhead for long allocation sessions?

              Choose an answer to see the explanation.

            3. Question 3 of 8What does the retained-size snippet print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const sizes = computeRetainedSizes(createLeakSnapshot(1));
              console.log(sizes["panel:1"]);

              Choose an answer to see the explanation.

            4. Question 4 of 8Why compare snapshot 2 to snapshot 3 in the three-snapshot technique?

              Choose an answer to see the explanation.

            5. Question 5 of 8What does this comparison snippet print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const before = createLeakSnapshot(1);
              const after = createLeakSnapshot(5);
              const detached = diffConstructorCounts(before, after)
                .find((row) => row.name === "DetachedHTMLDivElement");
              console.log(detached.delta);

              Choose an answer to see the explanation.

            6. Question 6 of 8A selected object's Retainers pane shows Window → resize listener → closure → rows. What is the likely fix?

              Choose an answer to see the explanation.

            7. Question 7 of 8What is true about detached DOM nodes?

              Choose an answer to see the explanation.

            8. Question 8 of 8Which Node.js approach writes snapshots near an out-of-memory crash?

              Choose an answer to see the explanation.

            Key takeaways

            • The Memory panel gives you heap snapshots, allocation timelines, allocation sampling, and detached-element views.
            • A heap snapshot is a reachable-object graph. Retained size comes from dominators, and Retainers explains why an object is alive.
            • The three-snapshot technique filters out warm-up noise and highlights repeated growth.
            • Detached DOM nodes leak only when JavaScript still references them; follow the retainer chain to the array, listener, closure, cache, or console root.
            • Fix the owning root, then run the same comparison again and add an honest regression check.

            One sentence to keep:
            Memory leak debugging is not guessing from a heap number; it is comparing repeated actions and following retaining paths back to cleanup code.

            Up next, the curriculum moves deeper into performance and engine behavior. Keep this workflow handy any time a route, dashboard, or long-lived session keeps getting heavier.

            CompleteFrontend Clear concepts. Working examples.