Memory

Find the four ways a web app leaks memory, and write the two structures that stop it: a bounded cache and a listener you can take back.

12of 16
≈ 12Min read
5Parts

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.

the same component, both ways
/* 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); };
}
CauseWhat keeps it alive
Detached DOM nodesA variable still holds the node you removed — and the node holds its whole subtree
Listeners never removedThe target holds the handler; the handler’s closure holds everything it captured
Unbounded cachesA Map you only ever add to. It is not a leak by accident — it is a leak by design
Timers and observerssetInterval, 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.

js
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 old performance.memory was 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.

Practice