Fixing JavaScript Memory Leaks
A memory leak is memory your page keeps holding after it has finished with it. On a traditional site each click loads a fresh page and the browser throws the old memory away, so small leaks rarely matter. On single-page apps, dashboards and long shopping sessions the page never reloads, and every leaked listener, timer or detached element adds up.
The page gets slower to respond, then janky, and on phones the tab may be reloaded or crash. This guide shows how to prove a leak exists, find the exact code holding on to memory, and fix the common patterns behind it.
What a leak is, and what it is not
JavaScript frees memory automatically. The garbage collector periodically looks for objects that nothing
can reach any more and reclaims them. A leak is therefore not memory the browser forgot; it is memory
your code can still reach, usually by accident. An array that keeps growing, a listener on window
that refers to a removed component, or a timer that still runs after its view has gone all keep their
objects reachable, so none of it is collected.
Some patterns look like leaks but are not:
- A saw-tooth graph. Memory rising while you use the page and then dropping sharply is the collector doing its job.
- One-off growth. Memory that rises on the first visit to a route and then stays flat is usually a cache or code being loaded once.
- Large but stable. A page that uses a lot of memory but does not grow with use has a size problem, not a leak.
The signature of a leak is growth that repeats: do the same action ten times and memory climbs roughly ten steps, and a forced garbage collection does not bring it back down.
The usual causes
| Pattern | What keeps memory alive | Where it usually appears |
|---|---|---|
Listeners on window or document | The handler function and everything it refers to, including the component that added it. | Resize, scroll and key handlers added when a view opens and never removed. |
| Timers and intervals | The callback and its closure, for as long as the interval runs. | Carousels, polling for updates, countdowns. |
| Observers | Every observed element and the callback. | IntersectionObserver,
ResizeObserver and MutationObserver never disconnected. |
| Detached DOM | Elements removed from the page but still referenced from JavaScript. | Arrays or maps of elements, cached query results, modal and tooltip libraries. |
| Ever-growing caches | Every entry ever added. | A Map used to memoise
API responses or rendered rows, keyed by something unique. |
| Subscriptions | The subscriber and its component. | State stores, event buses, WebSocket and server-sent event handlers. |
| Third-party widgets | Whatever the widget keeps internally. | Chat, maps and video players created on each route without being destroyed. |
Closures are the thread running through all of these. A small function captures the variables around it, so a listener that only needs a number can end up holding an entire component, its data and its DOM.
Measuring memory over time
Start with a quick overview before using heap snapshots, which are slower.
The Performance panel with memory counters
Open DevTools, go to Performance, tick the Memory checkbox and start recording. Perform your journey several times, for example open and close a product quick-view ten times, click the bin icon to force garbage collection, then stop. The graph shows the JavaScript heap along with counts of DOM nodes and event listeners. If the heap, node count or listener count ends much higher than it started after the forced collection, and each repetition adds a similar step, you have a leak in that journey.
Chrome Task Manager
From the Chrome menu, open More tools, Task manager (Shift+Esc on Windows). Right-click the column headers to add JavaScript memory. Leave the page open while you repeat the journey and watch whether the figure keeps rising. This is useful for checking a long session without DevTools open.
console.log are kept alive while DevTools is open.Reading heap snapshots
A heap snapshot records every object in the page's JavaScript memory and what refers to what. Taking a snapshot forces garbage collection first, so what remains is genuinely reachable.
- In the Memory panel choose Heap snapshot and take one on the starting screen. This is your baseline.
- Perform the journey once, return to the starting screen and take a second snapshot. This includes one-off growth such as caches and lazily loaded code.
- Repeat the journey several more times, return to the starting screen and take a third snapshot.
- Select the third snapshot and switch the view from Summary to Comparison against the second. Sort by the delta or size delta column.
Objects whose count grows by roughly the number of times you repeated the journey are your leak. Typing
Detached in the class filter shows DOM elements that are no longer in the page but are still
held in memory; recent Chrome versions also offer a dedicated detached elements profile in the Memory
panel.
Select any suspect object and look at the Retainers pane underneath. It shows the chain of references
keeping the object alive, ending at a root such as Window. Somewhere in that chain is a
property name, array or closure variable from your own code, and that is the line to change.
The fix, step by step
Step 1
Choose a repeatable journey and watch memory
Pick one action visitors repeat often without reloading, such as moving between two routes, opening and closing a dialog, or filtering a product list. Record it ten times in the Performance panel with the Memory box ticked, forcing garbage collection at the end. If the JavaScript heap, DOM node count or listener count climbs with each repetition and stays high afterwards, keep that journey for the next steps; if memory returns to the start, try another.
Step 2
Take three heap snapshots and compare them
In the Memory panel, take a baseline heap snapshot, run the journey once and take a second,
then run it several more times and take a third, always finishing on the same screen. Open the third
snapshot in Comparison view against the second and sort by count or size difference. Note the object
types that grew by roughly the number of repetitions, and filter by Detached to check for
removed elements still in memory.
Step 3
Follow the retainers to the responsible code
Click one of the growing objects and read the Retainers pane from the top down. Look for
the first name you recognise from your own code or a library: an array, a map, a closure variable in
a named function, or a listener on window. Use a development build with source maps so names
are readable. That reference is what must be removed or cleared when the view or component is finished
with.
Step 4
Remove listeners and observers on teardown
Every listener added to window, document or a long-lived
element needs removing when its component goes. The simplest way is to create an AbortController
per component, pass its signal to each addEventListener call, and call abort()
in the teardown code. Call disconnect() on each observer at the same point, and use your
framework's clean-up hook so teardown always runs, even when the visitor leaves the view part-way through
loading.
Step 5
Clear timers, animation frames and connections
Store the ID returned by setInterval, setTimeout and
requestAnimationFrame and clear each in teardown with clearInterval, clearTimeout
or cancelAnimationFrame. Close WebSockets and event sources the view opened, unsubscribe
from stores and event buses, and abort pending fetch requests with the same AbortController.
For third-party widgets, call their destroy or dispose method rather than just removing their container,
since the widget may keep its own timers and listeners running.
Step 6
Put limits on caches and stores
Find
every Map, object or array that stores results, elements or history and decide how large
it may grow. Give caches a maximum number of entries and evict the oldest, or a time limit. Where a cache
is keyed by DOM elements or other objects, use a WeakMap so entries disappear when the key
is collected. Never keep references to removed elements in long-lived arrays, and clear stored history
such as undo stacks beyond a sensible depth.
Step 7
Retest the journey and guard against regressions
Repeat the recording and the three-snapshot comparison from steps 1 and 2 with the fixed build. Memory, node count and listener count should return close to the baseline after forced garbage collection. Then add a check to your test suite that runs the journey many times in a headless browser and fails if the heap grows beyond a set margin, and re-run the JavaScript Checker to confirm nothing else regressed.
A clean-up pattern that works anywhere
This plain JavaScript component adds listeners, an observer and a timer, and removes all of them in one place. The same structure fits any framework's mount and unmount hooks.
export function mountStockTicker(root) {
const controller = new AbortController();
const { signal } = controller;
const handleResize = () => layout(root);
window.addEventListener('resize', handleResize, { signal });
root.querySelector('.refresh').addEventListener('click', refresh, { signal });
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) refresh();
});
observer.observe(root);
const timer = setInterval(refresh, 30000);
async function refresh() {
const res = await fetch('/api/stock', { signal }).catch(() => null);
if (res) render(root, await res.json());
}
// Call this when the component is removed.
return function unmount() {
controller.abort(); // removes both listeners and cancels fetches
observer.disconnect();
clearInterval(timer);
};
}
The key point is that the function which sets things up also returns the function that tears them down, so the two cannot drift apart.
Framework and library notes
- React. Return a clean-up function from each effect that adds listeners, timers or subscriptions. The React and Next.js guide covers effect clean-up in detail.
- Vue. Remove global listeners and timers in
onUnmounted(orbeforeUnmount); listeners added with Vue's template syntax are removed for you. - Angular. Unsubscribe from observables, typically by completing a subject in
ngOnDestroyor using the operators designed for automatic clean-up. - jQuery-era code. Removing elements with jQuery's own methods clears the data and handlers it stored; removing them with plain DOM methods may not.
- Maps, charts and editors. Most have a
destroy(),remove()ordispose()method. Call it before the container is removed.
Preventing leaks
- Pair every setup with a teardown in code review: each
addEventListener,setInterval, observer and subscription should have a matching clean-up line. - Prefer listeners on the component's own element rather than on
window, since they go when the element goes. - Bound every cache when you create it, not when it causes trouble.
- Automate a soak test for your busiest journeys and track heap size over releases.
- Test on a phone. Phones have far less memory to spare, so leaks that are invisible on a desktop show up there first.
1. How do I know if my website has a memory leak?
Repeat one action many times without reloading, such as switching between two pages of a single-page app or opening and closing a dialog, while recording in the Chrome DevTools Performance panel with the Memory box ticked. Force garbage collection at the end. If the JavaScript heap, DOM node count or listener count climbs with each repetition and stays high after collection, you have a leak. Memory that rises and then drops back is normal behaviour.
2. What are detached DOM nodes?
They are page elements that have been removed from the document but are still referenced by JavaScript, so the browser cannot free them. Common causes are arrays or maps of elements kept after a list re-renders, event handlers or closures that refer to a removed component, and libraries that keep references to elements they created. In a heap snapshot, type Detached in the class filter to list them, then read the Retainers pane to see what holds them.
3. Do memory leaks affect SEO or Core Web Vitals?
Not directly for search crawlers, which load each page fresh and leave quickly. The effect is on real visitors in long sessions, whose experience does feed Core Web Vitals through field data. As a leak grows, garbage collection runs more often and takes longer, so interactions later in a session become slower and Interaction to Next Paint can worsen. On phones, heavy memory use can also cause the tab to reload or crash, losing the visitor's place.
4. Can a traditional multi-page website leak memory?
It can, but it rarely matters, because navigating to a new page usually discards the old page's memory. Leaks matter on pages people keep open and use for a long time: single-page apps, dashboards, infinite-scroll feeds, live chat, and product listings with many filter changes. They can also matter when a multi-page site restores pages from the back-forward cache, since the page returns with its memory, so repeated back and forward navigation can reveal growth.
5. Why does memory keep growing when DevTools is open?
Part of it may be DevTools itself. Objects you log with console.log stay reachable while the console holds them, and recording tools keep their own data. Clear the console, close panels you are not using, and compare with Chrome Task Manager with DevTools closed. Test in a private window with extensions disabled too, because extensions inject scripts that add their own memory use. If growth continues under those conditions, it is coming from the page.
6. Should I use WeakMap and WeakRef to fix leaks?
WeakMap is a good fit when you need to attach data to an object, such as a DOM element, without stopping that object being collected; the entry goes when the key does. WeakRef and FinalizationRegistry are more specialised, and their timing is unpredictable, so they should not be the main way to manage clean-up. Explicit teardown, removing listeners, clearing timers and bounding caches, remains the reliable fix, with weak references as a supporting tool.
7. How do I stop a third-party widget from leaking?
Check its documentation for a destroy, remove or dispose method and call it before removing the widget's container, particularly in single-page apps where the widget is created on each route. Create it once and reuse it rather than creating a fresh copy on every visit to the page. If memory still grows after proper clean-up, record a heap snapshot comparison showing the growing objects and send it to the vendor, or consider loading the widget only on demand.
The JavaScript Checker loads your page in headless Chromium, captures console errors and warnings, and lists every script with its size and parse and execution time, including third-party tags, so you can see what is running before profiling memory.
Open JavaScript Checker Fixes โ