What a leak looks like on the web
Nothing crashes. The tab simply gets slower the longer it is open — garbage collection runs more often and takes longer, each pass pausing the main thread, and after twenty minutes of a single-page app the scrolling stutters and the typing lags.
A leak is not "memory in use". It is memory that is still reachable and will never be used again. The collector cannot free anything a live reference points at, so a leak is always, in the end, a reference somebody forgot.
The four causes
AbortController with a signal is the modern answer: one abort() removes every listener registered with it, however many there are.
/* leaks: the listener and the interval outlive the component */
function mount(el) {
window.addEventListener("resize", onResize);
setInterval(poll, 1000);
}
/* does not: everything registered is something you can undo */
function mount(el) {
const controller = new AbortController();
window.addEventListener("resize", onResize, { signal: controller.signal });
const timer = setInterval(poll, 1000);
return () => { controller.abort(); clearInterval(timer); };
}
| Cause | What keeps it alive |
|---|---|
| Detached DOM nodes | A variable still holds the node you removed — and the node holds its whole subtree |
| Listeners never removed | The target holds the handler; the handler’s closure holds everything it captured |
| Unbounded caches | A Map you only ever add to. It is not a leak by accident — it is a leak by design |
| Timers and observers | setInterval, IntersectionObserver, a subscription: each keeps its callback, forever, until you stop it |
Weak references
A WeakMap holds its keys weakly: if nothing else refers to the key object, the entry disappears and the collector takes both. It is the right structure for data about an object you do not own — per-node state, per-request metadata, a cache keyed on a DOM element.
The price is that it is not enumerable: no size, no keys(), no iteration. That is not an oversight — being able to list the keys would prove they are still alive, which would defeat the point.
const state = new WeakMap();
state.set(node, { scrolled: 0 }); // when the node goes, so does this
const cache = new Map();
cache.set(node, { scrolled: 0 }); // this keeps the node alive forever
Bound every cache
A cache without a limit is a memory leak with good intentions. The simplest fix is an LRU: keep at most N entries, and when a new one arrives evict the one used longest ago. In JavaScript this is four lines, because Map iterates in insertion order — delete a key and set it again and it moves to the end.
Finding one
- Three snapshots. Heap snapshot, do the thing twenty times, snapshot, do it again, snapshot. Compare 1 → 3 with "objects allocated between snapshot 1 and 2 that are still alive". What survives is the leak.
- Detached nodes. The memory panel filters for them by name — a detached
<div>is almost always a listener or a stray variable. performance.measureUserAgentSpecificMemory()for the whole-tab number in production. The oldperformance.memorywas Chrome-only and lied about workers.- A flat line is the goal. Record the heap over a minute of use: sawtooth is healthy, a staircase that never comes back down is the leak.