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
| Step | Do this | Expectation |
|---|---|---|
| 1 | Force a clean baseline and take snapshot A | Stable starting size |
| 2 | Open and close the suspect view 5–10 times | Same action each time |
| 3 | Trigger GC, then take snapshot B | Short-lived allocations should vanish |
| 4 | Compare B against A by retained size | Growing constructor names are leads |
| 5 | Follow the retaining path | Find 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.
