Finding memory leaks
Use Chrome DevTools heap snapshots, comparison views, retainers, and detached DOM clues to find and verify JavaScript memory leaks.
- 01Capture useful evidenceChoose Heap snapshot, Allocation instrumentation on timeline, Allocation sampling, or Performance monitor for the question at hand.
- 02Read retaining pathsUse shallow size, retained size, distance, dominators, and the Retainers pane to explain why an object is still alive.
- 03Verify 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.
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 MAPOpen 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.
| Tool | What it records | Use it when |
|---|---|---|
| Heap snapshot | A point-in-time graph of reachable JavaScript objects and related DOM nodes. | Find what retains memory and compare before/after snapshots. |
| Allocation instrumentation on timeline | Instrumented allocations over time, with objects still live at the end highlighted. | Isolate which repeated action allocates objects that do not disappear. |
| Allocation sampling | Lower-overhead statistical allocation profile grouped by JavaScript stack. | Study longer sessions or allocation-heavy code when exact objects are less important. |
| Performance monitor | Live 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 checkbox | Heap 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.
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
MODELA 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.
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.
| Term | Meaning | How to use it |
|---|---|---|
| Shallow size | Bytes 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 size | Bytes 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. |
| Distance | The 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. |
| Retainers | Objects 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.
Use the simulated heap like DevTools: start from roots, find the path that retains a leaked object, and compare shallow size with retained size.
script
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"]);Take and compare heap snapshots
3 SNAPSHOTSThe 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.
This is the three-snapshot technique in miniature: warm up, take a baseline, repeat the suspected action, compare, then follow retainers.
script
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(", "));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.
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(", "));+4before 1, after 5
+4before 1, after 5
+4before 1, after 5
+8before 2, after 10
+4before 1, after 5
+4before 1, after 5
Comparing one warm-up action with 5 repeated actions leaves 4 extra detached panels and 8 extra callbacks.
Read retainer chains back to a root
ROOT CAUSEA 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
Mapstores 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:
Windowordocumentstores a callback that closes over route data. Fix it withremoveEventListener,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
clearIntervalorclearTimeoutduring 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.
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 DEMOA 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.
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.
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");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();}000Render tick 0. The page never allocates unbounded data.
The fixed path removes the panel and aborts the listener, so this demo keeps no detached panels.
Allocation timeline, sampling, and heap graphs
MEASUREHeap 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.
// 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
NODENode 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.
node --inspect server.js
# Open chrome://inspect, choose the Node target, then use the Memory panel.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.
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);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
CHECKLISTTreat 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.
- Reproduce: Make the leak happen with the smallest repeatable action, such as opening and closing a panel.
- Measure: Use Performance monitor or the Performance panel Memory checkbox to see whether used JS heap trends up after collection.
- Isolate: Remove unrelated work, reload, warm up once, then repeat only the suspected action.
- Compare: Take the three snapshots: warm-up baseline, after one action, after many actions. Compare the later two.
- Follow retainers: Open a growing constructor, select an instance, and read Retainers until you reach a root you own.
- Fix and verify: Remove listeners, abort signals, clear timers, cap caches, or use WeakMap/WeakSet when ownership should be weak. Then compare again.
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.
// 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
| Misconception | More accurate wording |
|---|---|
| A heap snapshot is a screenshot of RAM | It is a graph of reachable JavaScript objects and related DOM nodes, organized by roots and references. |
| The biggest shallow object is always the leak | A small owner with a large retained size can dominate far more memory. |
| Detached means collected | Detached only means removed from the document. JavaScript references can still retain the node and its subtree. |
| One snapshot proves a leak | You need a repeatable action, comparison, retainers, a fix, and a new comparison. |
performance.memory is a portable test API | It is non-standard. Treat it as a Chrome diagnostic hint, not production logic. |
`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.
Sort each observation by whether it deserves investigation, is usually normal, or is likely a tooling artifact.
Practice: inspect, compare, fix, verify
5 EXERCISESThe run context supplies the heap helpers. What number does this print?
const sizes = computeRetainedSizes(createLeakSnapshot(1));
console.log(sizes["panel:1"]);It prints 232: panel 120 + button 80 + text node 32. That is retained size, not shallow size.
Type the exact retaining path printed by the simulation.
const path = findRetainerPath(createLeakSnapshot(1), "button:1");
console.log(path.join(" -> "));It prints moduleCache -> panel:1 -> button:1. In DevTools, that same path would point you at the cache to clear.
How many extra detached panels does the comparison report?
const before = createLeakSnapshot(1);
const after = createLeakSnapshot(5);
const detached = diffConstructorCounts(before, after)
.find((row) => row.name === "DetachedHTMLDivElement");
console.log(detached.delta);It prints 4: the later snapshot has four more detached panels than the warm-up baseline.
Name one cleanup technique that would prevent the listener from retaining the panel.
function mountPanel() {
const panel = document.createElement("section");
const onResize = () => panel.textContent.length;
window.addEventListener("resize", onResize);
panel.remove();
// What cleanup should run here?
}window.removeEventListener("resize", onResize);
// or pass { signal: controller.signal } and call controller.abort()The listener is the long-lived retaining root. Removing or aborting it lets the closed-over panel become unreachable if no other references remain.
After changing code, what DevTools step proves the leak stopped growing?
// After fixing the listener and cache, what DevTools step proves it?
// Type the workflow step, not code.Repeat the isolated action, take a fresh snapshot comparison, and confirm the constructor deltas and retaining paths no longer grow.
Quiz: finding memory leaks
8 QUESTIONSQuestion 1 of 8What is a heap snapshot in the Memory panel?
Choose an answer to see the explanation.
Question 2 of 8Which Memory panel profile has the lowest overhead for long allocation sessions?
Choose an answer to see the explanation.
Question 3 of 8What does the retained-size snippet print?
Read the code, then predictconst sizes = computeRetainedSizes(createLeakSnapshot(1)); console.log(sizes["panel:1"]);Choose an answer to see the explanation.
Question 4 of 8Why compare snapshot 2 to snapshot 3 in the three-snapshot technique?
Choose an answer to see the explanation.
Question 5 of 8What does this comparison snippet print?
Read the code, then predictconst 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.
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.
Question 7 of 8What is true about detached DOM nodes?
Choose an answer to see the explanation.
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.