Skip to main content
JavaScript

JavaScript memory leaks: the reference that won't die and how to find it

JavaScript has garbage collection, not magic. Track retained references with heap snapshots, then fix the listeners, timers, caches, and closures keeping objects alive.

Thien Nguyen
By Thien Nguyen
Updated July 21, 2026 · 4 min read

Garbage collection does not mean you can't leak memory — it means an object survives exactly as long as something reachable still references it, and not one millisecond less. "We unmounted the component" is not evidence it was freed. A setInterval callback, a module-level cache, or an event listener registered on window can each hold a live path to that component and everything it closed over, and the collector will dutifully keep all of it alive because, by the rules, it's still reachable.

The way you find the culprit is mechanical: reproduce one user action several times, take a heap snapshot before and after, and inspect what grew and what's retaining it. The leak is almost never the big object at the end of the chain — it's the reference chain itself, the one link that should have been broken and wasn't. MDN's memory-management guide covers the reachability model this all rests on.

The four repeat offenders

Event listeners that outlive their owner

const controller = new AbortController();
window.addEventListener("resize", redraw, { signal: controller.signal });
// component teardown
controller.abort();

An AbortController makes the cleanup relationship obvious. Anonymous listeners are not inherently bad; anonymous listeners you cannot remove later are.

Timers and subscriptions

setInterval, WebSocket handlers, RxJS subscriptions, and observer callbacks all keep functions—and whatever those functions close over—reachable. Return a cleanup function at the creation site; do not rely on someone remembering it in a distant lifecycle hook.

Caches with no eviction rule

A Map keyed by request URL is a memory leak wearing a performance badge unless it has an upper bound, TTL, or invalidation path. WeakMap helps only when the key is an object and the value should disappear with that key; it is not a general cache replacement.

Detached DOM nodes

Removing a node from the document does not free it if application state or a listener retains it. Heap snapshots often show this as a detached HTMLDivElement with a surprisingly mundane retainer.

A repeatable snapshot loop

StepDo thisExpectation
1Force a clean baseline and take snapshot AStable starting size
2Open and close the suspect view 5–10 timesSame action each time
3Trigger GC, then take snapshot BShort-lived allocations should vanish
4Compare B against A by retained sizeGrowing constructor names are leads
5Follow the retaining pathFind the owner that should have cleaned up

If a constructor's instance count climbs by exactly the number of times you repeated the action — open the view 8 times, see 8 detached nodes — that's your leak, and the count is the proof.

Do not start by blaming the garbage collector. It is doing exactly what the reachable object graph tells it to do.

FAQ

My memory keeps climbing even though I removed the component. Why?

Removal from the UI tree isn't removal from the object graph. Something still references it — a subscription that was never unsubscribed, a callback captured in a still-live closure, an entry in a global cache. The heap snapshot's "Retainers" pane shows the exact path keeping it alive; that path is the bug.

Does setting a variable to null free the memory?

Only if it was the last reference. null-ing one pointer does nothing if a listener, timer, or another object still points at the same value. Garbage collection is about reachability, not about any single assignment.

WeakMap or Map for a cache?

Neither is a bounded cache by itself. A WeakMap only lets entries be collected when the key object becomes otherwise unreachable — useful for attaching metadata to objects, useless when your keys are strings like URLs. For an actual cache you still need a size cap or TTL; the collection type doesn't give you eviction.

Will a leak actually crash the tab?

Eventually — the tab hits its heap limit and the process is killed with an out-of-memory error. Long before that, though, you get the symptom users notice: growing GC pauses, jank, and a UI that gets slower the longer the session runs.

How do I tell a leak from normal cache warm-up?

Normal growth plateaus; a leak is monotonic under a repeated action. Do the same thing ten times and force GC between rounds — a healthy app returns to roughly its baseline, a leaking one steps up a little each round and never comes back down.

The fix is always the same shape: make ownership explicit. Whatever starts a listener, interval, subscription, or cache entry should also define when it ends. That single rule prevents more leaks than any profiler trick.

Cover photo by Daniil Komov on Pexels.

References

Primary documentation and specifications checked when this article was last updated.

JavaScriptPerformanceDebugging

Related articles

All articles