Memory and leaks
What causes leaks in browser JavaScript, how to read a heap snapshot, and the detached-DOM trap that catches every team.
You open a single-page app and use it for an hour. You navigate between pages, open dialogs, close them, scroll long lists. The tab's memory use, which started at 50MB, is now at 800MB. The page is sluggish. A reload fixes it instantly.
That's a memory leak. Not the kind that crashes a process — JavaScript runs in a garbage-collected environment, so you don't forget to free memory. The leak comes from the opposite direction: you keep holding references to objects that should have been thrown away. The garbage collector does its job perfectly; it just can't collect things you're still pointing at.
This lesson is about three patterns that cause leaks in browser JavaScript, and the devtools workflow for finding them: heap snapshots in the Memory panel.
What a leak is
The garbage collector frees any object that's no longer reachable from a root — window, document, the call stack, any module-level variable. As long as a chain of references from a root reaches an object, it stays alive.
A leak is an object that should be unreachable but isn't, because some chain you forgot about is still pointing at it. The classic chains in browser code:
- A removed DOM element that JavaScript still has a reference to.
- An event listener attached to something that still references its closure.
- A
setIntervalwhose callback references state from a component that's been unmounted. - A module-level array (a cache, a log, a queue) that grows without bound.
Spotting these in code is hard because the bug is not an action — it's the absence of cleanup. Spotting them in devtools is much easier.
Heap snapshots — what's currently alive
A heap snapshot is a complete dump of every JavaScript object the page has alive at that moment. Devtools renders it as a sortable table: object type, count, total bytes, retained size.
To take one: open Memory → select Heap snapshot → click Take snapshot. The page pauses briefly while the snapshot is captured, then a new snapshot row appears in the sidebar.
The default view is Summary — a list of every object class (Array, Object, HTMLDivElement, your own classes) with counts and sizes. Click a class name to see every instance. Click an instance to see its retainers — the chain of references keeping it alive.
The two columns that matter most:
- Shallow size — bytes used by the object itself.
- Retained size — bytes that would be freed if this object were garbage-collected. This is the field for finding leaks: an array with 10,000 items has a much bigger retained size than its shallow size, because it retains all those items.
A common starting move: take a snapshot, sort by retained size descending, look at what's at the top. If it's a class you don't expect to see at all, or an instance count much higher than expected, you've found something.
The Memory panel can take three kinds of snapshot. Heap snapshot (the default) is a full dump — slow but complete. Allocation instrumentation records every allocation while you do something, then plays back the timeline. Allocation sampling is the lightweight version of that. For most leak hunting, snapshot-and-compare is the right tool.
Detached DOM — the canonical frontend leak
The single most common kind of leak in browser JavaScript: a DOM element was removed from the page, but JavaScript still has a reference to it. The browser can't reclaim the memory because your code is still pointing at the element.
const cache = [];
function showDialog() {
const dialog = document.createElement("div");
dialog.innerHTML = "<p>Important message</p>";
document.body.appendChild(dialog);
cache.push(dialog); /* ← the leak */
setTimeout(() => dialog.remove(), 3000);
}
button.addEventListener("click", showDialog);Walk through it. Every click creates a dialog element, attaches it to the page, and pushes a reference into the module-level cache array. Three seconds later, dialog.remove() takes it out of the DOM — the user no longer sees it. But cache still holds the reference. The dialog stays in memory. Click 100 times and you have 100 invisible dialogs taking up RAM.
In the heap snapshot, these show up as detached HTMLDivElement entries — DOM elements that exist in memory but aren't attached to the page. Devtools highlights "detached" in red because the most common reason a detached element exists is a leak.
To find them: take a snapshot, type Detached in the class filter at the top, expand any of them, and look at the Retainers view at the bottom. You'll see something like dialog in cache in window. That's the leaky chain — the array is holding the reference; cache is a module-level binding under window.
The fix is some combination of: don't cache DOM nodes at module level, clear the cache when you remove the elements, or use WeakRef / WeakMap so your reference doesn't itself keep the object alive.
Detached. You see 47 HTMLDivElement entries. What does this mean?Listeners and timers
Two more leak shapes worth recognizing.
Event listeners on long-lived objects. A listener attached to window or document keeps the closure alive — including everything the closure references. If your component attaches a listener but doesn't remove it on unmount, the component itself stays alive forever.
function mountChart(container, data) {
const big = preprocess(data); /* megabytes of stuff */
function onResize() {
redraw(big);
}
window.addEventListener("resize", onResize);
/* No cleanup — when container unmounts, onResize still attached.
onResize closes over 'big', so 'big' stays alive forever. */
}The fix is the matching removeEventListener call when the component (or whatever lifecycle owns this) tears down. React's useEffect cleanup, Vue's onBeforeUnmount, plain "before navigating away" — all of them exist for this reason.
setInterval callbacks. Same shape. setInterval(() => { /* references state */ }, 1000) keeps the callback alive forever, which keeps the state alive forever, even if the component that started the interval is long gone.
function startPolling(component) {
const id = setInterval(() => {
fetch("/status").then((r) => component.update(r));
}, 5000);
/* Don't forget: */
return () => clearInterval(id);
}The pattern is to return the cleanup and call it when the owning lifecycle ends. If you can't trust the cleanup to fire (the user navigated away with no unmount hook), AbortController and AbortSignal are the modern way to bundle cancellation across listeners and fetches.
An addEventListener on a removed element does get garbage-collected — the listener doesn't hold the element alive on its own. The bug is when the listener is attached to something else that outlives the element (window, document, a long-lived store), and the listener's closure references the element or its state.
Snapshot comparison — finding what grew
For most leaks, a single snapshot is hard to read: you see thousands of objects and have to guess which ones are surplus. The diff workflow gives you a sharper question: "what got allocated between these two moments and never freed?"
The recipe:
- Open the Memory panel and take a snapshot. This is your baseline.
- Do the action you suspect leaks — open and close a dialog, navigate away and back, run the suspicious flow.
- Force a garbage collection (the trash-can icon at the top of the Memory panel) to ensure anything can be collected has been.
- Take a second snapshot.
- In the second snapshot, change the dropdown from Summary to Comparison with the baseline selected.
The view now shows, for each class, how many instances were allocated, how many were freed, and how many remain. A leak shows up as a positive Delta in instances of objects that should have been freed when the action ended.
For example: open and close a dialog three times. In the comparison view, you should see roughly equal allocation and freeing — net delta near zero. If you see "+3 HTMLDivElement, +3 ChartController, +3 EventListener", you have a clear leak in the dialog flow.
A repeat-the-action multiplier helps the signal: if leaking one dialog is hard to spot, leaking thirty is obvious. Run the action thirty times before snapshotting, and the leaked objects stack up to a count you can't miss.
+20 ChartController with positive retained size. What does this tell you?resize listener inside a component, but you don't remove it on unmount. The component itself uses 5MB of preprocessed data. The user opens and closes the component 100 times. What happens?img in [12] in cache in window. Where do you look in the code?