JavaScript frees memory for you, so you never call free(). That does not make leaks impossible. A leak in JavaScript is memory you no longer need that the engine cannot free, because something still refers to it. In a page that lives for hours, such as a dashboard or a single-page app, small leaks add up until the tab slows down or crashes.
Reachability
The garbage collector works from a simple rule: a value stays in memory while it is reachable. The starting points, called roots, include global variables, the variables of functions currently on the call stack, and things the browser holds on to, such as the DOM tree and registered callbacks. Anything that can be reached by following references from a root is kept. Everything else is garbage.
let course = { title: 'JavaScript', lessons: new Array(10_000).fill('lesson') };
let current = course;
course = null; // Still reachable through current.
current = null; // Now unreachable: the collector may free it.
Modern engines use a mark-and-sweep approach: mark everything reachable from the roots, then reclaim the rest. That handles cycles naturally. Two objects that refer to each other but are unreachable from any root are both collected. Engines also divide the heap into generations, collecting short-lived objects often and cheaply, and do much of the work incrementally or on other threads to avoid long pauses. You cannot control when collection happens, and you should not need to.
So every leak has the same shape: something reachable is still holding a reference you forgot about. The rest of this lesson is a list of the usual suspects.
Leak 1: event listeners that outlive their purpose
A listener's callback is reachable for as long as it is registered. Everything the callback's closure refers to stays reachable too.
function openCoursePanel(course) {
const bigData = loadDetails(course); // Imagine several megabytes.
function onResize() {
layoutPanel(bigData);
}
window.addEventListener('resize', onResize);
// Panel closes later, but the listener is never removed.
}
Every time a panel opens, another onResize is attached to window, and each one keeps its own bigData alive forever. The page also does more work on every resize.
The fix is to remove listeners when the component closes. An AbortController makes this tidy, because one abort() removes every listener registered with its signal:
function openCoursePanel(course) {
const bigData = loadDetails(course);
const controller = new AbortController();
const { signal } = controller;
window.addEventListener('resize', () => layoutPanel(bigData), { signal });
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closePanel();
}, { signal });
function closePanel() {
controller.abort(); // Removes both listeners.
}
return closePanel;
}
loadDetails and layoutPanel stand for your own functions. The signal option of addEventListener is supported in all current browsers and lets you skip keeping references to every handler for removeEventListener.
Leak 2: timers that never stop
An interval keeps its callback, and everything that callback closes over, alive until it is cleared.
function startClock(element) {
const id = setInterval(() => {
element.textContent = new Date().toLocaleTimeString();
}, 1000);
return () => clearInterval(id); // Call this when the clock is removed.
}
const clock = document.querySelector('#clock');
const stopClock = startClock(clock);
// Later, when removing the clock:
stopClock();
clock.remove();
If the clock element is removed without calling stopClock(), the interval keeps running and keeps the detached element in memory. Pair every setInterval, repeating setTimeout and requestAnimationFrame loop with a way to stop it.
Leak 3: detached DOM nodes
Removing an element from the page does not free it if JavaScript still refers to it. Such elements are called detached.
const cache = [];
function showResults(results) {
const list = document.querySelector('#results');
cache.push(list.cloneNode(true)); // Keeps every old version forever.
list.replaceChildren(...results.map(makeItem));
}
makeItem here is your own function that builds an li. Each detached clone holds its whole subtree. A detached node also keeps its parent and siblings alive, because it still points to them. Common causes are arrays or objects used as caches, and component objects that store element references and are themselves still reachable.
Leak 4: unbounded caches and collections
Any Map, array or object that only ever grows is a leak waiting to happen:
const priceCache = new Map();
function formatPrice(product) {
if (!priceCache.has(product)) {
priceCache.set(product, expensiveFormat(product));
}
return priceCache.get(product);
}
Once a product object is gone from the rest of the app, the Map still holds it as a key. Two fixes:
- Bound the cache. Limit its size and evict the oldest entries, or clear it when the data is reloaded.
- Use a WeakMap when the key is an object. A
WeakMapdoes not keep its keys alive. When nothing else refers to the product, the engine can collect it and its cache entry together.
const priceCache = new WeakMap();
Nothing else in formatPrice changes. The trade-off is that a WeakMap cannot be iterated and has no size, because its contents depend on the garbage collector.
Leak 5: closures that capture more than they need
A closure keeps alive the variables it uses. Avoid capturing a large value when you only need a small part of it:
function makeSummary(report) {
// Captures the whole report object:
// return () => `${report.title}: ${report.rows.length} rows`;
// Captures two small values instead:
const { title } = report;
const rowCount = report.rows.length;
return () => `${title}: ${rowCount} rows`;
}
This matters only when the closure lives a long time, for example as a listener or in a cache, and the captured value is large.
Accidental globals
Assigning to an undeclared variable in sloppy-mode code creates a global property that lives as long as the page:
function load() {
results = fetchEverything(); // Missing const: creates window.results.
}
ES modules and classes run in strict mode, where this throws a ReferenceError instead. That is one more reason to use modules for all new code.
Finding a leak in DevTools
The technique in Chrome and Edge DevTools is to take heap snapshots before and after an action and compare them. Firefox has a similar Memory tool.
- Open the page and DevTools, then open the Memory panel.
- Perform the action once to warm up caches. Take a Heap snapshot.
- Repeat the action several times, for example open and close the course panel five times, returning to the same starting state each time.
- Take a second heap snapshot. DevTools forces a garbage collection before each snapshot, so what remains is reachable.
- Select the second snapshot and switch the view to Comparison against the first. Sort by the change in object count.
- Look for objects whose count grew by a multiple of the number of repetitions, such as five more closures or five more arrays.
- Select an object and read its Retainers panel. It shows the chain of references from a root to the object, such as
Window, then a listener, then a closure. That chain names the line of code that needs clean-up.
To find detached elements, type Detached in the class filter of a snapshot's Summary view. Any detached elements are listed together with what retains them.
Before taking snapshots, check the trend. The Performance monitor panel (from the DevTools command menu) shows JavaScript heap size and the number of DOM nodes and listeners in real time. A graph that climbs with every repetition and never drops back is the sign of a leak. A sawtooth that rises and falls is normal garbage collection.
Do this in a private window or a profile without extensions, because extension code also appears in snapshots.
WeakRef and FinalizationRegistry
ES2021 added WeakRef, which holds a reference that does not keep its target alive, and FinalizationRegistry, which can call back after an object has been collected. They exist for specialised cases such as large caches. When and whether a callback runs depends on the engine, so they are not a tool for normal clean-up. Remove listeners, clear timers and drop references explicitly instead.
Summary
- Memory is freed when values are no longer reachable from roots such as globals, the stack, the DOM and registered callbacks.
- Most leaks are forgotten references: listeners, timers, detached DOM nodes, ever-growing caches and long-lived closures.
- Remove listeners with an
AbortControllersignal, and pair every timer with a way to stop it. - Use a
WeakMapfor caches keyed by objects, and bound other caches. - Find leaks by repeating an action and comparing heap snapshots, then follow the retainer chain to the cause.