Finding memory leaks
Use Chrome DevTools heap snapshots, comparison views, retainers, and detached DOM clues to find and verify JavaScript memory leaks.
- 01Pick the right memory toolChoose a heap snapshot, the allocation timeline, allocation sampling, or Performance monitor, based on the question you have.
- 02Read retainer pathsUse shallow size, retained size, distance, and the Retainers pane to explain why an object is still in memory.
- 03Check a leak fixRepeat one action, compare snapshots, fix the root that keeps the memory, and show that the comparison stops growing.
Prove what keeps memory in use
JavaScript keeps the objects your code makes in an area of memory called the heap. When nothing can reach an object any more, the engine frees its memory. This cleanup is called garbage collection, or GC for short. The memory management lesson explains it step by step.
A memory leak happens when your code keeps a link to an object it no longer needs. Then the engine can't free that object. If this happens again and again, the heap keeps growing.
This lesson shows how to find a leak with Chrome DevTools, the tools built into the Chrome browser. The WeakRef & FinalizationRegistry lesson showed why weak references are rarely the fix. Here you use DevTools to find which object still holds the memory.
Finding a memory leak means proving that an object is still in memory after the feature that used it is gone. Then you follow the links back to the place in your code that holds it, and you clean that place up.
You need this in real apps, such as:
- a single-page app that switches pages without reloading;
- a dashboard that stays open all day;
- a long list that loads more rows as you scroll;
- a pop-up window, called a modal, that opens and closes hundreds of times;
- a developer tool that keeps large amounts of loaded data.
The steps are always the same:
- Measure the memory.
- Pick one action to test.
- Take heap snapshots and compare them.
- Follow the links to the object that holds the memory.
- Fix the code that keeps that link.
- Compare again to check the fix.
The Memory panel: which tool answers which question?
DEVTOOLS MAPOpen Chrome DevTools and choose the Memory panel. It has several tools. Some of them talk about allocation: setting aside memory for a new object. Each tool answers a different question:
- Heap snapshot: what is in memory right now?
- Allocation instrumentation on timeline: which objects were made during this action, and are they still there at the end?
- Allocation sampling: which code makes the most objects over a long run?
- Detached elements: which removed page elements does JavaScript still hold?
The table lists these tools and two more. Read each row as: this tool records this, so use it when you need this.
| Tool | What does it record? | When should you use it? |
|---|---|---|
| Heap snapshot | A graph of the JavaScript objects and page elements in memory at one moment. | Find what keeps memory, and compare snapshots from before and after an action. |
| Allocation instrumentation on timeline | Every new object over time. Objects still in memory at the end are marked. | Find which repeated action makes objects that never go away. |
| Allocation sampling | Only some new objects, grouped by the function calls that made them. It slows the page down less. | Study long sessions or code that makes many objects, when the exact objects matter less. |
| Performance monitor | Live graphs, such as the JS heap size, while you use the page. | Notice a trend, then choose a smaller action to look into. |
| Performance panel Memory checkbox | A heap graph lined up with scripts, drawing, and events in a performance recording. | Link memory growth to long tasks, page changes, or clicks. |
A heap snapshot can be shown in four views:
- Summary groups objects by their constructor, the function that made them, such as
ArrayorMap. - Comparison shows how the count of each kind of object changed between two snapshots.
- Containment starts from the starting points, such as
Window, and lets you open what they hold. - Statistics shows a pie chart of what uses the memory.
The toolbar also has a button that runs garbage collection right away. It has a filter by constructor name, too, and filters such as "objects made between two snapshots" or "objects kept by detached nodes".
The measuring performance lesson covers performance recordings. To spot a leak, open Performance monitor, or turn on the Memory checkbox in the Performance panel. Watch the JS heap size. If it keeps rising even after garbage collection, switch to the Memory panel. There you can see which objects stay and what keeps them.
A heap snapshot is a graph
MODELA heap snapshot is not a picture of all the computer's memory. It is a graph: a set of JavaScript objects and page elements, joined by links. Each link is a reference: one object points to another. Here are the words you need:
- A root is a starting point that is always kept, such as
window. DevTools calls these GC roots. - Roots point to objects, and objects point to more objects.
- A retainer is an object that keeps another object in memory by pointing to it. The Retainers pane lists every object that points to the object you selected.
- Retained size is the memory that would be freed if one object were gone. It counts the object itself, plus every object you can reach only through it.
- If every path from a root to object B goes through object A, DevTools says A dominates B. Then freeing A would free B too.
A book should have come back to the library, but it didn't. You don't guess who has it from how heavy it is. You read the checkout records until you find the account that still has it.
Heap snapshots work the same way. You follow the links. You don't blame the biggest object first.
- In real life: The library's checkout desk and account records
- In JavaScript: GC roots, such as
window, the browser's timer list, and variables at the top of a module - In real life: A reader's account lists a borrowed book
- In JavaScript: A reference from one object to another
- In real life: A family account holds all the children's cards
- In JavaScript: An object that dominates others: its retained size counts everything only it reaches
- In real life: The checkout record shows who can return the book
- In JavaScript: The Retainers pane leads back to the root you can fix
The table explains the words you read in a snapshot. Read each row as: this word means this, and you use it like this.
| Word | What does it mean? | How do you use it? |
|---|---|---|
| Shallow size | The memory one object holds itself, such as an array's own storage or one page element. | Use it to spot single big objects. Don't stop there, though. |
| Retained size | The memory that would be freed if this object were gone. It counts the object plus everything you can reach only through it. | Use it to find small objects that keep many other objects in memory. |
| Distance | How many links the shortest path takes from a root, such as window, the browser's own roots, or its timer list. | A small distance often means an owner that lives a long time is close by. |
| Retainers | The objects that point to the selected object. | Follow them back to a root: a Map, a listener, a timer, a closure, or the console. |
The next step-through uses a small, made-up heap that always gives the same result. The lesson provides four helper functions, so Edit & run works on the code exactly as shown:
createLeakSnapshot(n)builds a made-up heap afternleaky actions.computeReachablelists every object the roots can reach.computeRetainedSizesworks out the retained size of each object.findRetainerPathfinds the shortest path from a root to one object.
You used the made-up heap like DevTools: start at the roots, find the path that keeps 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 SNAPSHOTSA reliable way to find growth is called the three-snapshot technique. Before each snapshot, press the button that runs garbage collection, so only objects that are really kept remain.
- Load the page. Do the action once as a warm-up, so code that loads late and caches can settle. Take Snapshot 1.
- Do the action you suspect once more. Take Snapshot 2.
- Repeat the same action several more times. Take Snapshot 3.
Open Snapshot 3 in the Comparison view, against Snapshot 2. Each snapshot has a job:
- Snapshot 1 checks that the warm-up is done.
- Snapshot 2 is the starting point, often called the baseline, that you measure from.
- Snapshot 3 shows what grew while you repeated the action.
The change in a count between two snapshots is called the delta. A constructor row with a growing delta is a suspect, not proof. For proof, pick one object in that row. Then follow its retainers back to a root in your code.
This is the Snapshot 2 to Snapshot 3 comparison from the three-snapshot technique. Next, follow the retainers of the rows that grew.
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(", "));The step-through only shows the last part: Snapshot 2 compared with Snapshot 3. Its before heap has one action. Its after heap has five. So the difference is four extra actions, not five.
In Chrome's Summary view, the constructor filter can show Objects allocated between Snapshots 2 and 3. In the Comparison view, sort by the positive deltas, open a constructor, and pick one object. Then read the Retainers pane. The playground below shows these deltas without making the browser use 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(", "));+4Snapshot 2: 1, Snapshot 3: 5
+4Snapshot 2: 1, Snapshot 3: 5
+4Snapshot 2: 1, Snapshot 3: 5
+8Snapshot 2: 2, Snapshot 3: 10
+4Snapshot 2: 1, Snapshot 3: 5
+4Snapshot 2: 1, Snapshot 3: 5
Snapshot 2 has one action and Snapshot 3 has 5. So 4 extra detached panels and 8 extra callbacks stayed.
Follow the retainers back to a root
ROOT CAUSEA retainer chain answers one question: who still points at this object? Start at an object that should be gone. Then follow the Retainers pane, one link at a time, until you reach a root your code owns.
button:1 and ask who points at it. The answer leads back to moduleCache, the root your code can clean up.Chains often end at one of these:
- a
Mapstored in a variable at the top of a module; - a listener on
window; - a timer callback;
- a closure;
- a cache inside a framework;
- an object you looked at in the DevTools console.
Here are four common ones, and how to fix each:
- Module cache: a
Mapat the top of a module stores page elements or page data. It never deletes old entries. Fix it with a size limit, or delete old entries yourself. If you only attach extra data to objects, use a WeakMap or WeakSet. - Event listener:
windowordocumentkeeps a callback, and the callback uses data from a page that is closed. Fix it withremoveEventListeneror anAbortController. If the listener should run only once, pass{ once: true }. The events lesson explains how listeners work. - Timer: the browser's timer list keeps a
setIntervalcallback alive. Fix it withclearIntervalorclearTimeoutwhen the feature closes. The timers lesson explains the timer list. - Closure: a closure is a function plus the variables it remembers from where it was made. A callback that lives a long time may remember more than it needs. Keep only an ID or a count when that is enough. The closures lesson explains them.
If two separate roots can reach the same object, neither root alone dominates it. So retained size can surprise you:
- A small object can have a big retained size when it is the only way to reach many objects.
- A big object that two owners share may stay in memory when you delete just one owner.
Detached DOM nodes
LIVE DEMOThe DOM is the tree of elements that makes up the page. Each element in it is a node. A detached DOM node is a node that was removed from the tree, but that JavaScript still points at. So the engine can't free it.
In a snapshot, you can find detached nodes with the Detached elements tool, with the detached-node filters, or by a constructor name such as DetachedHTMLDivElement. The real clue is the retainer path: an array, a listener, a closure, a cache, or the console still points at the node.
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 code makes a panel, adds it to the page, and removes it. Then it prints three lines:
true: the search for.preview-panelfinds nothing, so the panel is not in the page.1: theretainedNodesarray still holds the panel.false: the panel'sisConnectedproperty is false, which means it is not attached to the page.
The last two lines clean up this demo. They remove the listeners and empty the array.
panel.remove() takes the panel out of the page. But the retainedNodes array and the onResize listener still point at it, so it stays in memory.The fix uses an AbortController. It gives you a signal. Pass the signal when you add a listener. Later, call controller.abort(), and the browser removes that listener.
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");This version prints true, 0, and listener aborted. The panel is gone from the page, the array is empty, and no listener points at the panel any more.
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();}000Update 0. The demo never keeps more than six panels.
The fixed version removes the panel and aborts its listener, so the demo keeps no detached panels.
Allocation timeline, sampling, and memory graphs
MEASUREA heap snapshot answers: what is in memory now? The allocation tools answer: when was it made? To use Allocations on timeline:
- Start recording.
- Do the action.
- Stop recording, and select a time range.
The timeline shows bars of new objects:
- Blue bars are objects still in memory at the end of the recording.
- Gray bars are objects that were made and later freed.
When you select a constructor, the Retainers pane shows what keeps its objects in memory.
Allocation sampling checks only some of the new objects, so it is less exact. In return, it slows the page down less. It also groups memory by the chain of function calls that made it. Use it for dashboards that run a long time, or for heavy actions.
For a quick trend, open Performance monitor and watch the JS heap size while you repeat the action. Or record in the Performance panel with the Memory checkbox on. Then the heap growth lines up with scripts, drawing, and events on the same timeline.
// 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 not part of any web standard. Only Chrome has it. The newer measureUserAgentSpecificMemory() needs cross-origin isolation: a security setup that the server turns on with special headers. So use these for careful checks you control. Don't build app logic that expects them in every browser.
Node.js heap snapshots
NODENode.js runs JavaScript outside the browser. It uses V8, the same engine as Chrome, so heap snapshots work the same way. To look at a Node program on your own computer:
- Start it with the
--inspectflag. - Open
chrome://inspectin Chrome. - Choose your Node program, and use the Memory panel.
Be careful on a live server. While Node takes a snapshot, it stops running your other code, and it may need a lot of extra memory. So keep the way to start a snapshot private, and use it only where a crash would do no harm.
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, the check prints function, function, and true. To write a real snapshot, call v8.writeHeapSnapshot() from a safe, private place in your code. Then open 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 can also write snapshots for you. The --heapsnapshot-near-heap-limit=3 flag tells it to write up to three snapshots as the heap gets close to its size limit, before an out-of-memory crash.
node --heapsnapshot-near-heap-limit=3 server.js
# Node writes up to three snapshots as the heap approaches its limit.Leak-hunting steps you can repeat
CHECKLISTHunt a leak like any other bug, with steps you can repeat. Don't guess. The advanced debugging lesson uses the same steps for breakpoints and traces: make the bug happen every time, remove other noise, look, explain, fix, and check.
- Reproduce: Make the leak happen with the smallest action you can repeat, such as opening and closing a panel.
- Measure: Use Performance monitor or the Memory checkbox in the Performance panel. Check whether the JS heap keeps rising after garbage collection.
- Isolate: Turn off other work, reload, do the action once as a warm-up, then repeat only the action you suspect.
- Compare: After the warm-up, take Snapshot 1. Do the action once and take Snapshot 2. Repeat the action, take Snapshot 3, then compare Snapshot 2 with Snapshot 3.
- Follow retainers: Open a constructor row that grew, pick one object, and read Retainers until you reach a root you own.
- Fix and verify: Remove listeners, abort signals, clear timers, limit caches, or use WeakMap or WeakSet when the link should not keep the object. Then compare again.
A regression test checks that an old bug doesn't come back. For a leak, it can:
- run in its own Node process with
--expose-gc, a flag that lets the test start garbage collection; - count objects in the heap;
- use
FinalizationRegistryas a hint that an object was freed, with no promise of when.
Never test for the exact moment garbage collection runs in a normal app. Repeat the test, run it in its own process, and fail only on strong evidence, such as an object count that keeps growing.
// 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
Each row shows a wrong idea and what is really true.
| Wrong idea | What is true |
|---|---|
| A heap snapshot is a picture of all memory. | It is a graph of the JavaScript objects and page elements you can reach, starting from roots and following links. |
| The object with the biggest shallow size is always the leak. | A small object with a big retained size can keep much more memory. |
| Detached means freed. | Detached only means removed from the page. JavaScript can still point at the node and everything inside it. |
| One snapshot proves a leak. | You need an action you can repeat, a comparison, retainers, a fix, and a new comparison. |
performance.memory works the same in every browser. | It is not part of any standard. Treat it as a Chrome-only hint, not as app logic. |
The `DetachedHTMLDivElement` count grows by the same amount after every page change.- A cache for code coloring appears only the first time a page opens.
- The DevTools console keeps an object you selected.
A `Window` resize listener keeps the rows array of a page that is closed.- Blue bars stay on the allocation timeline after a repeated action.
- The heap size goes up and down while you type in a search box.
Sort each thing you might see: is it worth looking into, usually normal, or caused by the tools or the test?
Practice: inspect, compare, fix, check
5 EXERCISESThe lesson provides the heap helpers. What number does this print?
const sizes = computeRetainedSizes(createLeakSnapshot(1));
console.log(sizes["panel:1"]);It prints 232: the panel 120 + the button 80 + the text node 32. That is the retained size. The shallow size is only 120.
Type the exact path that the code prints.
const path = findRetainerPath(createLeakSnapshot(1), "button:1");
console.log(path.join(" -> "));It prints moduleCache -> panel:1 -> button:1. In DevTools, this path tells you to clean up the cache.
How many extra detached panels does the comparison show?
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. Snapshot 3 has four more detached panels than Snapshot 2, which had one action.
Name one way to clean up, so the listener no longer keeps 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 on window lives a long time, and it keeps the panel. Remove or abort it. Then the panel can be freed, as long as nothing else points at it.
After you change the code, which DevTools step proves that the leak stopped growing?
// After fixing the listener and the cache, which DevTools step proves it worked?
// Type the step, not code.Repeat the same action, take a new snapshot comparison, and check that the constructor deltas and retainer 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 tool slows the page down least during a long recording?
Choose an answer to see the explanation.
Question 3 of 8What does the retained-size code 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 do you compare Snapshot 2 with Snapshot 3 in the three-snapshot technique?
Choose an answer to see the explanation.
Question 5 of 8What does this comparison code 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 8The Retainers pane for an object 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 option writes snapshots just before an out-of-memory crash?
Choose an answer to see the explanation.
Key takeaways
- The Memory panel has heap snapshots, an allocation timeline, allocation sampling, and a detached elements tool.
- A heap snapshot is a graph of objects joined by links. Retained size counts what only one object reaches. The Retainers pane shows why an object is still in memory.
- In the three-snapshot technique, Snapshot 1 checks the warm-up, Snapshot 2 is the starting point, and Snapshot 3 shows growth from repeating the action.
- A detached DOM node leaks only while JavaScript still points at it. Follow the retainer chain to the array, listener, closure, cache, or console that holds it.
- Fix the root that holds the memory. Then run the same comparison again, and add a careful regression test.
One sentence to keep:
To find a memory leak, don't guess from one heap number: compare repeated actions, and follow the retainers back to the code that should clean up.
Next, the course goes deeper into performance and how the engine works. Come back to these steps any time a page, a dashboard, or a long session keeps getting heavier.