Guide · Frontend
Frontend Memory Leaks Explained: Detached DOM, Listeners and Heap Snapshots
22 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Frontend memory leaks happen when your code still references something the user is done with. The garbage collector, the part of the browser that frees unused memory, then cannot free it.
- The usual holders are forgotten event listeners, timers that keep running, closures (functions that remember variables) and caches that only grow. They often keep detached DOM: elements removed from the page but still in memory.
- In our Chromium 153 test, one forgotten resize listener kept every closed panel alive. Thirty opens and closes left 12,030 extra DOM nodes and about 29 MB of extra heap.
- The fix is to stop whatever you start. One AbortController removes every listener at once, clearInterval stops the timer, a WeakMap lets cached panels go, and React effects return a cleanup function.
- Beginners can stop after the heap snapshot walkthrough in Chrome DevTools. Engineers then get a 34-line leak test for CI using Playwright, a browser automation tool. They also see how DevTools labels detached nodes, and why Chrome's old performance.memory counter read exactly 10,000,000 bytes four times running.
What goes wrong when a single-page app stays open all day?
It slowly eats memory, gets slower, and can end with a crashed tab. Nothing breaks in a quick test, because the leak only shows up after hundreds of screen changes.
A single-page app, or SPA, never reloads the page. Old screens are removed by your code, not by the browser. Any frontend memory leaks in that removal code add up for as long as the tab stays open.
Picture a common setup. A support team keeps an orders dashboard open for a whole 9-hour shift, and every order click opens a details panel with 200 rows. The setup is made up, but the bug pattern is real.
Closing the panel removes it from the screen. But the panel added a window resize listener when it opened, and nobody removed it. So every closed panel stays in memory. By evening the tab is slow, scrolling stutters, and someone reloads it.
This guide follows that one panel. You will see why it leaks, measure the leak in a real browser, find it in a heap snapshot, fix it, and write a test that stops it coming back.
Which words do you need before hunting memory leaks?
You need six words: heap, garbage collector, reachable, DOM node, detached and closure. Each one gets a plain meaning and where it shows up in our panel.
- Heap: the area of memory where JavaScript keeps objects, arrays and functions. Our panel's data lives here.
- Garbage collector, or GC: the part of the browser engine that finds objects nobody can reach any more and frees their memory. You cannot call it from normal page code.
- Reachable: an object you can get to by following references from a root, such as the global window object. Reachable objects are never freed, even if you will never use them again.
- DOM node: one piece of the page tree, such as an element or a run of text. Our panel is 401 nodes: one section, 200 row elements and 200 text nodes.
- Detached: a DOM node removed from the page while some JavaScript still points to it. It is invisible but still in memory.
- Closure: a function together with the variables it can see from where it was written. Our resize handler is a closure that can see the panel variable.
MDN's memory management guide puts the key rule in one line: the engine reduces "no longer needed" to "unreachable". The GC cannot know your intent. It only knows what is still connected.
What are frontend memory leaks, and what causes them?
Frontend memory leaks are objects that stay reachable after your app is done with them, so the page uses more memory with every repeat of an action. Chrome's DevTools docs define a leak the same way: memory that keeps growing over time because of a bug.
A leak is different from memory bloat. Bloat means a page simply uses more memory than it needs, such as a huge image list. Bloat is big from the start; a leak keeps growing.
Think of the GC as a cleaner in an office who throws away anything not tied to a desk by a string. The desk is a root, like window. Strings are references. Your panel is a box on the floor.
When you close the panel, you cut the string from the page to the box. But the resize listener is a second string, tied from window to the box. The cleaner sees a string and leaves the box alone, forever.
Where the picture breaks: a real cleaner can see that a box is useless. The GC never judges usefulness. It only follows references, and it may also run whenever it likes, not on a schedule you control.
| Cause | What still holds the memory | Fix |
|---|---|---|
| Forgotten event listener | window or document keeps the handler, and the handler's closure keeps the element | removeEventListener with the same function, or the signal option with an AbortController |
| Timer never stopped | the browser keeps a setInterval callback until clearInterval is called | Save the id and call clearInterval on close |
| Closure kept somewhere long-lived | a stored function keeps every variable in its scope that any sibling closure used | Keep long-lived functions small; set big variables to null when done |
| Cache that only grows | a Map or array keyed by elements or ids is never cleared | WeakMap for element keys, or a size limit |
| Detached DOM | any of the above pointing to a removed element keeps its whole subtree | Remove the reference, not just the element |
Detached DOM is rarely the cause on its own. It is the expensive result of one of the first four, because one reference to a removed section keeps all its children too.
Why does removing an element not free its memory?
Because remove only takes the element out of the page tree. If any reachable object still references it, the GC must keep it, along with every child inside it.
Here is the naive panel code. It looks harmless, and this is the version that leaks. Read closePanel carefully.
// Naive version: looks fine, but leaks one panel per open and close.
function openPanel() {
const panel = document.createElement('section');
for (let i = 0; i !== 200; i++) {
const row = document.createElement('div');
row.textContent = 'Order ' + i;
panel.appendChild(row);
}
document.body.appendChild(panel);
window.addEventListener('resize', function () {
panel.dataset.width = String(window.innerWidth);
});
setInterval(function () {
panel.dataset.updated = String(Date.now());
}, 5000);
return function closePanel() {
panel.remove(); // gone from the screen, not from memory
};
}Pause and predict: after the user opens and closes this panel 20 times, how many panels are still in memory? Zero feels right, because each one was removed.
The answer is 20. Each open adds one more resize listener to window, and each listener's closure can see its own panel. The heap snapshot later in this guide shows that exact chain.
The retaining path that keeps a closed panel alive
Drag to exploreRead from the top. window is a root, so everything on this chain is reachable. Cut any one arrow and the whole panel, rows included, can be freed.
Diagram connections:
- window (a GC root) to Resize listener list (holds)
- Resize listener list to Your resize handler (holds)
- Your resize handler to Closure scope (remembers)
- Closure scope to Detached section (panel variable)
- Detached section to 200 rows and 200 texts (children)
- window (a GC root): Always reachable while the tab is open
- Resize listener list: window keeps every handler you added
- Your resize handler: A function, so it has a closure scope
- Closure scope: Holds the variable named panel
- Detached section: Removed from the page, still in memory
- 200 rows and 200 texts: Kept because their parent is kept
What just happened? Removing the panel cut only the link from the page body. The link from window, through the listener, was never cut. That one forgotten line is the whole leak.
How much memory do forgotten listeners, timers and caches leak in Chromium 153?
Each forgotten reference keeps one whole panel per open and close, so memory grows in a straight line. We measured it instead of guessing.
We ran the panel in Chromium 153 (the build bundled with Playwright 1.63.0, as of 25 October 2026). We tested each cause on its own, and each test panel also held a 1 MB data array, like a loaded order list. After every 10 cycles we forced a garbage collection and read the counters.
| Version | After 10 cycles | After 20 cycles | After 30 cycles |
|---|---|---|---|
| Start (all versions) | 0.6 MB heap, 10 nodes | - | - |
| Leaky: resize listener never removed | 10.1 MB, 4,020 nodes, 10 listeners | 19.7 MB, 8,030 nodes, 20 listeners | 29.2 MB, 12,040 nodes, 30 listeners |
| Leaky: setInterval never cleared | 10.1 MB, 4,020 nodes | 19.7 MB, 8,030 nodes | 29.2 MB, 12,040 nodes |
| Leaky: Map cache keyed by panel | 10.1 MB, 4,020 nodes | 19.7 MB, 8,030 nodes | 29.2 MB, 12,040 nodes |
| Fixed: AbortController, clearInterval, WeakMap | 0.6 MB, 10 nodes, 0 listeners | 0.6 MB, 10 nodes, 0 listeners | 0.6 MB, 10 nodes, 0 listeners |
Read the nodes column first. Each cycle adds exactly 401 nodes, which is one panel. Heap megabytes depend on what the panel holds, but node and listener counts point straight at the cause.
A timer leaks for the same reason as a listener. The browser keeps a setInterval callback until clearInterval runs, and that callback's closure sees the panel. A one-shot setTimeout is different: in our test its closure was freed once it had fired.
How do you fix event listener, timer and cache leaks?
Make closing undo everything opening did. For listeners, the cleanest tool is the signal option with an AbortController: one abort call removes every listener added with that signal.
// Fixed version: everything the panel starts, closePanel stops.
function openPanel() {
const panel = document.createElement('section');
for (let i = 0; i !== 200; i++) {
const row = document.createElement('div');
row.textContent = 'Order ' + i;
panel.appendChild(row);
}
document.body.appendChild(panel);
const controller = new AbortController();
window.addEventListener('resize', function () {
panel.dataset.width = String(window.innerWidth);
}, { signal: controller.signal });
const timerId = setInterval(function () {
panel.dataset.updated = String(Date.now());
}, 5000);
return function closePanel() {
controller.abort(); // removes every listener added with this signal
clearInterval(timerId); // stops the timer, so its closure can be freed
panel.remove();
};
}In our 30-cycle tests the fixed versions stayed at the start values throughout. This exact file also passes the automated leak check later in the guide.
The removeEventListener rules that catch people
removeEventListener only works if you pass the same event type and the very same function object. An inline arrow function written twice is two different functions, so removal silently does nothing.
- Same function needed: in our test, removing with a fresh arrow function left the original listener firing.
- Only capture matters: MDN says removeEventListener checks only the capture flag. A listener added with capture true stayed until we removed it with capture true; passive made no difference.
- No error on a miss: a removal that matches nothing has no effect, so the bug stays silent.
- Duplicates are ignored: adding the same function twice for the same type and capture registers it once.
- once: true removes a listener after its first call, which fits one-time events.
- An already aborted signal adds nothing: the DOM spec returns early, so reusing an aborted controller means your listener never runs.
Weak references for caches
A WeakMap does not keep its keys alive. MDN states that an object's presence as a WeakMap key does not prevent it from being garbage collected. When the key goes, its value can go too.
That makes WeakMap the right home for extra data about elements, such as when each panel opened. The trade-off: you cannot list its keys or get its size, because that would depend on GC timing.
WeakRef and FinalizationRegistry also exist, but MDN tells you to avoid them where possible. A WeakRef may never clear, and a cleanup callback may run late or not at all. Never use them as your main cleanup.
How do closures leak memory without you noticing?
A closure keeps alive the variables it uses, and in V8 it can also keep variables that a sibling function from the same scope used. So a tiny function you keep can hold a huge array you never touch.
We tested four versions in Chromium 153. Each created a 10 MB array called big inside a function, then stored one small returned function on window.
| Setup inside the function | Kept after GC |
|---|---|
| The stored function reads big | 9.6 MB |
| The stored function ignores big, and nothing else uses it | 0.0 MB |
| The stored function ignores big, but a sibling function used big | 9.6 MB |
| Same as above, then big = null before returning | 0.0 MB |
The third line is the surprise. The stored function never mentions big, yet big stayed alive because a sibling function from the same scope used it. This is engine behaviour, not a language rule.
The practical fix is boring and reliable. Keep functions that live a long time, such as listeners and callbacks you store, small. Set big local variables to null when you finish with them.
How do React effect cleanups prevent memory leaks?
React runs the cleanup function your effect returns before the effect runs again and when the component unmounts. Any listener, timer or subscription you start in an effect should be stopped in that cleanup.
import { useEffect, useState } from 'react';
export function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(function () {
const controller = new AbortController();
window.addEventListener('resize', function () {
setWidth(window.innerWidth);
}, { signal: controller.signal });
return function cleanup() {
controller.abort(); // React calls this on unmount and before re-running the effect
};
}, []);
return width;
}React's docs add a development check. In Strict Mode, React runs one extra setup and cleanup cycle before the real setup, so a missing cleanup shows up straight away as duplicate work.
We mounted and unmounted a panel 21 times with React 19.3.1 and counted window listeners after a forced GC. Production builds and the Strict Mode development build behaved differently, as the docs predict.
| Effect version | Extra listeners | Extra DOM nodes | What you notice |
|---|---|---|---|
| No cleanup, handler reads ref.current | +21 in production, +42 in Strict Mode dev | 0 | Each old handler throws a null error on every resize |
| No cleanup, handler saved the node itself | +21 in production, +42 in Strict Mode dev | 12,621 (21 detached panels) | Nothing visible, memory just grows |
| Cleanup with AbortController | 0 | 0 | Nothing, which is correct |
The first row is worth a second look. React sets a ref back to null when the element goes, so the panel's DOM was freed. But the leaked handlers still ran on every resize and crashed reading null.
The second row is the quiet, worse leak. Copying ref.current into a local variable inside the effect kept every unmounted panel alive. Leaks in React are the same leaks as in plain JavaScript, just behind a hook.
How do you find memory leaks with heap snapshots in Chrome DevTools?
Repeat one action several times, take heap snapshots before and after, then look for detached nodes and objects whose count grows with each repeat. The Retainers section tells you which reference keeps them.
A heap snapshot is a full picture of every reachable object at one moment. Chrome's docs note that taking a snapshot always starts with a garbage collection, so garbage does not hide the real leak.
Confirm memory grows
Open Chrome's Task Manager with Shift+Esc, right-click the table header and turn on JavaScript memory. Watch the number in brackets in that column while you open and close the panel 10 times. A number that keeps climbing is your first signal.
Record a timeline
In the Performance panel, tick the Memory checkbox. Click the Collect garbage button, record the 10 cycles, and collect garbage again. A rising floor in the JS heap and Nodes lines points to a leak.
Take a baseline snapshot
Open the Memory panel, choose Heap snapshot and take one with the panel closed. This is your before picture.
Repeat the action and snapshot again
Open and close the panel exactly 5 times, then take a second snapshot. Using a round number makes the growth easy to spot.
Filter for detached nodes
In the Summary view, type Detached in the class filter. Our leaky panel showed 5 detached sections, 1,000 detached row divs and 1,000 detached text nodes: five panels, each with 200 rows.
Compare the two snapshots
Switch the view to Comparison against the first snapshot. Constructors whose count grew by a multiple of 5 are your suspects.
Read the retainers
Click one detached section. The Retainers section shows what points to it. In our snapshot the chain ran from window, through the event listener and its closure context, to the variable panel.
Fix, then repeat
Apply the fix and run the same steps. Our fixed version showed zero detached nodes after 5 and after 10 cycles.
Two size columns help you rank suspects. Shallow size is the memory an object holds itself. Retained size is the memory that would be freed if that object went, including everything only it keeps alive.
A detached section is the classic case. On its own it is small, but rows that only it keeps alive count towards its retained size. Sort by retained size to find the costliest leak.
How do you test for memory leaks automatically in CI?
Drive the page with a test browser, repeat the action many times, force a garbage collection, and compare DOM node and listener counts with the start. If they grew, fail the build.
Playwright can open a Chrome DevTools Protocol session, or CDP, the protocol Chrome's DevTools use to inspect a page. Three CDP calls do the measuring: HeapProfiler.collectGarbage, Performance.getMetrics and Runtime.getHeapUsage.
// leak-check.js: open and close the panel 20 times, force garbage collection,
// and fail if DOM nodes or JS event listeners did not come back to the starting count.
// Run: node leak-check.js panel-leaky.js (or panel-fixed.js)
const { chromium } = require('playwright');
const fs = require('fs');
async function counters(cdp) {
await cdp.send('HeapProfiler.collectGarbage');
const { metrics } = await cdp.send('Performance.getMetrics');
const m = Object.fromEntries(metrics.map(function (x) { return [x.name, x.value]; }));
const heap = await cdp.send('Runtime.getHeapUsage');
return { nodes: m.Nodes, listeners: m.JSEventListeners, heapMB: (heap.usedSize / 1048576).toFixed(1) };
}
(async function () {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.addScriptTag({ content: fs.readFileSync(process.argv[2], 'utf8') });
const cdp = await page.context().newCDPSession(page);
await cdp.send('Performance.enable');
const before = await counters(cdp);
await page.evaluate(function () {
for (let i = 0; i !== 20; i++) openPanel()();
});
const after = await counters(cdp);
console.log('before', before);
console.log('after ', after);
const leaked = after.nodes !== before.nodes || after.listeners !== before.listeners;
console.log(leaked ? 'LEAK: counts grew after 20 open/close cycles' : 'OK: counts returned to baseline');
await browser.close();
process.exitCode = leaked ? 1 : 0;
})();The line openPanel()() opens a panel and at once calls the closePanel function it returns. To run it, install with npm install --save-exact [email protected], then npx playwright install chromium. In a real app, replace addScriptTag with page.goto to your app, and the loop with real clicks.
Here is the output we got with Playwright 1.63.0 on Chromium 153, first for the leaky file and then for the fixed one.
== panel-leaky.js
before { nodes: 7, listeners: 14, heapMB: '1.0' }
after { nodes: 8027, listeners: 34, heapMB: '1.1' }
LEAK: counts grew after 20 open/close cycles
exit 1
== panel-fixed.js
before { nodes: 7, listeners: 14, heapMB: '1.0' }
after { nodes: 7, listeners: 14, heapMB: '1.0' }
OK: counts returned to baseline
exit 0Look at heapMB in the leaky run. 8,020 DOM nodes leaked, yet the JavaScript heap moved by only 0.1 MB. DOM nodes are kept in the browser's own heap, which Runtime.getHeapUsage reports separately as embedderHeapUsedSize.
That is why the test checks counts, not megabytes. Counts are exact, so a leak of one panel per cycle shows as a clean 401 nodes and one listener every time.
How the automated leak check decides pass or fail
Drag to exploreNotice the forced GC before both readings. Without it, garbage that has not been collected yet looks like a leak and the test flakes.
Diagram connections:
- Load page in Chromium to Force GC, read counters
- Force GC, read counters to Open and close 20 times
- Open and close 20 times to Force GC, read again
- Force GC, read again to Counts back to baseline?
- Counts back to baseline? to Pass (Yes)
- Counts back to baseline? to Fail the build (No)
- Load page in Chromium: Playwright opens the app and a CDP session
- Force GC, read counters: Baseline nodes and listeners
- Open and close 20 times: The user action under test
- Force GC, read again: HeapProfiler.collectGarbage first
- Counts back to baseline?: Compare nodes and listeners
- Pass: Exit code 0
- Fail the build: Exit code 1, take a snapshot next
Keep the check tolerant of your real app. Analytics scripts or lazy-loaded widgets may add a few nodes once. Run one warm-up cycle before the baseline so one-time setup is not counted.
What does a heap snapshot file reveal about detached nodes?
Each node in the snapshot file carries a detachedness value, and DevTools spreads that state to the node's children when it loads the file. That is how a whole removed subtree gets the Detached label.
How DevTools labels detached nodes
We read the DevTools frontend source, package chrome-devtools-frontend 1.0.1724140 (published 23 October 2026). Its propagateDOMState step first marks everything reachable from attached nodes as attached.
It then marks the remaining native objects reachable from detached nodes as detached. It skips weak, hidden and invisible links, and renames each detached node with a Detached prefix. JavaScript objects are never relabelled.
The raw file only flags some nodes. In our snapshot the raw flag sat on 206 nodes for five leaked panels, while the DevTools rule produced 2,011. Count with DevTools, or copy its rule, never with the raw flag alone.
Asking Chromium for detached trees directly
CDP also has DOM.getDetachedDomNodes, which backs the Memory panel's Detached elements profile in the same DevTools source. It returned 5 trees after 5 leaky cycles, 10 after 10, and 0 for the fixed version.
We also printed the full retaining path from the snapshot. It ran from Window, through the listener records and the V8 event listener, into the closure context, then along the edge named panel to the detached section.
Measuring memory outside DevTools
performance.memory is deprecated and non-standard, available only in Chromium-based browsers, and MDN calls its numbers unreliable. In our test it was also very coarse by default.
While we allocated four arrays, usedJSHeapSize read 10000000 four times in a row. With the --enable-precise-memory-info flag the same code gave four different, growing values.
The intended replacement is performance.measureUserAgentSpecificMemory. As of 25 October 2026 MDN marks it experimental, and it only works on a secure, cross-origin isolated page. In CI, prefer the CDP counters above.
Runtime.getHeapUsage is marked experimental in Chromium 153's own protocol file, so pin your browser version in CI. It returns usedSize, plus embedderHeapUsedSize for the browser's own garbage-collected heap.
How do you explain frontend memory leaks in a system design interview?
Frame it as a lifecycle problem: every component that starts something must stop it, and you prove it with counters and snapshots. This fits questions about dashboards, chat apps and other SPAs that stay open for hours.
Define a leak precisely
Memory that stays reachable after the user is done with it, so it grows with every repeat. Separate it from bloat, which is large but flat.
Name the usual holders
Listeners on window or document, uncleared timers, long-lived closures, unbounded caches and global stores. Explain that they often keep detached DOM subtrees.
Give the design rule
Every mount has a matching unmount. Use one AbortController per component, clear timers, cap caches or use WeakMap, and return cleanup from effects.
Explain how you would find it
Repeat one action, force GC, compare heap snapshots, filter for detached nodes and follow the retainers back to the code.
Show how you would prevent regressions
A CI test that repeats key flows and fails when node or listener counts do not return to baseline. Watch memory from real users as a later signal.
Questions people ask
- Do memory leaks still happen in modern browsers with garbage collection?
- Yes. Garbage collection only frees objects that are unreachable, so anything your code still references stays in memory. Modern collectors handle cycles well, but they cannot guess that a listener or cache entry is no longer useful. The leaks in this guide were all measured in Chromium 153, a current browser as of 25 October 2026.
- What is a detached DOM node?
- A detached DOM node is an element removed from the page while some JavaScript still references it. Chrome's DevTools docs call detached nodes a common cause of memory leaks. The node is invisible, but it stays in memory along with all its children. In a heap snapshot, type Detached in the class filter of the Summary view to find them.
- Does React clean up event listeners automatically?
- Not the ones your effects add with addEventListener. React only calls the cleanup function your effect returns, so that function must remove them. In our test with React 19.3.1, an effect without cleanup added one window listener per mount in production and two in Strict Mode development.
- Is a growing memory graph always a memory leak?
- No. Memory naturally rises between garbage collections and then drops. A leak shows a rising floor: after each forced collection, memory is higher than the time before. Chrome's docs suggest starting and ending a Performance recording with a forced collection, so you compare like with like.
- Should I use WeakRef to avoid memory leaks?
- Usually not. MDN advises avoiding WeakRef where possible, because when and whether garbage collection happens depends on the engine. A WeakRef may never clear, and FinalizationRegistry callbacks may run late or never. Remove references explicitly, and use WeakMap when you need data tied to an object's lifetime.
- Can I measure memory leaks from real users in production?
- Only roughly. performance.memory is deprecated, Chromium-only and coarse, and its replacement, measureUserAgentSpecificMemory, is experimental and needs a cross-origin isolated page as of 25 October 2026. Real-user numbers are a signal to investigate. Confirm and fix leaks with heap snapshots and a CI leak test.
- Why use AbortController instead of removeEventListener?
- AbortController removes every listener added with its signal in one call, including inline arrow functions you have no reference to. removeEventListener needs the exact same function and the same capture flag, and it fails silently on a mismatch. Both are correct; the signal option is simply harder to get wrong.
Sources and further reading
- Chrome DevTools: Fix memory problems (Task Manager, Performance memory, detached nodes)
- Chrome DevTools: Record heap snapshots (views, retainers, detached filter)
- Chrome DevTools: Memory terminology (shallow and retained size)
- MDN: Memory management in JavaScript
- MDN: EventTarget.addEventListener (signal and once options)
- MDN: EventTarget.removeEventListener (matching rules)
- DOM Standard: add an event listener
- MDN: WeakMap
- MDN: WeakRef
- MDN: FinalizationRegistry
- React docs: useEffect reference (cleanup and Strict Mode)
- React docs: useEffect reference (cleanup functions)
- React docs: ref callback reference (when React passes null)
- MDN: Performance.memory (deprecated)
- MDN: Performance.measureUserAgentSpecificMemory
- Chrome DevTools Protocol: HeapProfiler domain
- Chrome DevTools Protocol: Runtime domain (getHeapUsage)
- chrome-devtools-frontend on npm (HeapSnapshot.ts propagateDOMState, version 1.0.1724140)