Most JavaScript memory leaks come down to one pattern: something is still holding a reference to an object that every other part of the program has already forgotten about. In the browser, the classic culprit is a detached DOM node kept alive by a stray event listener or closure. In a long-running Node process, it is usually an unbounded cache or a listener never removed. Both are found the same way: take a heap snapshot, grow the suspected leak under load, and diff what is left behind.
What changed in 2026
- WeakRef and FinalizationRegistry are now widely supported and used, giving JavaScript a standard way to hold a reference that does not itself prevent garbage collection, which used to require awkward workarounds.
- Chrome DevTools' three-snapshot technique is the standard diagnostic method, replacing the older habit of eyeballing a single snapshot, because it reliably filters out normal allocation noise from an actual leak.
- React's strict mode double-invokes effects in development specifically to surface missing cleanup functions, catching a large share of subscription and listener leaks before they ever reach production.
- AbortController became the default way to remove event listeners and cancel fetches, replacing manual
removeEventListener bookkeeping scattered across a component's lifecycle.
The classic causes
| Cause |
What is happening |
Typical fix |
| Detached DOM nodes |
A node is removed from the page, but a variable or closure still references it |
Null out the reference, or use WeakRef if you need to keep a non-owning pointer |
| Forgotten event listeners |
addEventListener is called repeatedly without a matching removal |
Pass a shared AbortController signal so all listeners are removed in one call |
| Closures over large scope |
A callback captures an entire outer scope, keeping everything in it alive |
Capture only the specific values needed, not the whole enclosing object |
| Accidental globals |
A missing declaration keyword (or a leaked this) attaches data to the global object |
Enable strict mode; lint for implicit globals |
| Unbounded caches and maps |
Entries are added on every call with no eviction or size limit |
Use an LRU cache with a maximum size, or a WeakMap keyed by object identity |
| Uncleared timers and intervals |
setInterval or setTimeout keeps a callback, and its closure, alive indefinitely |
Store the interval id and clear it in a cleanup path |
| Missing React effect cleanup |
A subscription or listener set up in useEffect has no matching teardown |
Return a cleanup function from every effect that subscribes to anything |
Finding a leak in the browser: the three-snapshot technique
- Load the page and let it settle, then open the DevTools memory panel and take snapshot 1 as a baseline.
- Perform the suspected leaking action once — open and close a modal, navigate to a route and back — then force garbage collection and take snapshot 2.
- Repeat the same action again, force garbage collection again, and take snapshot 3.
- Filter snapshot 3 by objects allocated between snapshots 1 and 2, and still present in both 2 and 3. Anything showing up consistently across all three, growing each time, is your leak — a single repeated action should not leave new objects behind after garbage collection runs.
- Inspect the retainer path on the flagged objects. DevTools shows exactly what is holding the reference: usually a detached node, a closure, or a listener you can now trace to a specific line.
// Classic detached-node leak
let cachedHeader;
function cacheHeader() {
cachedHeader = document.getElementById("header"); // kept alive even after removal
}
// Fix: drop the reference once the node is gone, or hold it weakly
let cachedHeaderRef = new WeakRef(document.getElementById("header"));
// Missing React effect cleanup — subscription outlives the component
useEffect(() => {
const sub = eventBus.subscribe(handleEvent);
// no return here — sub is never released
}, []);
// Fixed
useEffect(() => {
const sub = eventBus.subscribe(handleEvent);
return () => sub.unsubscribe();
}, []);
Common mistakes
Trusting a single heap snapshot. One snapshot shows what currently exists, not what is growing. A leak only shows up as a difference across repeated actions, which is why the three-snapshot technique matters more than any single capture.
Assuming closures are inherently a problem. A closure capturing what it actually needs is normal and cheap. The leak risk is specifically capturing more than needed — an entire large object when only one field is used.
Forgetting that removing a DOM node does not release its listeners automatically in every case. If a listener closure still references the node, both the node and the listener stay alive together.
Reaching for a memory profiler before confirming growth is unbounded. Some memory growth is normal caching behavior that plateaus. Watch the trend first; profile only once growth looks monotonic.
FAQ
Are memory leaks possible in a garbage-collected language like JavaScript?
Yes. Garbage collection frees objects nothing references anymore; it cannot free an object your code is still, even accidentally, holding a reference to.
What is the fastest way to check if my page has a leak?
Perform the suspected action many times in a loop and watch the DevTools memory panel's heap size after forcing garbage collection each time. A heap that keeps climbing instead of returning to baseline indicates a leak.
Does WeakMap fully solve memory leak problems?
No, but it solves a specific one: it lets you associate data with an object without that association keeping the object alive. It does not help with listeners, timers, or closures unrelated to keyed lookups.
Do single-page applications leak more than traditional multi-page sites?
They are far more exposed to it, since the page never fully reloads to reset state. A component mounted and unmounted repeatedly without proper cleanup accumulates leaks that a classic page-per-request site would clear on every navigation.
Where to go next
For the mechanism that decides when JavaScript's engine can actually reclaim these objects, see generational garbage collection explained in 2026. For diagnosing a leak once it has already reached production, see how to debug production incidents in 2026, and for how the underlying runtime model shapes this behavior, compiled vs interpreted languages in 2026 is a useful primer.