The JavaScript engine

How browser memory leaks happen

Recognize listeners, timers, closures, collections, and detached DOM nodes that keep unwanted objects reachable.

A memory leak in a browser app is rarely a bug in the garbage collector. It’s almost always your program keeping a reference to something it no longer needs. The collector is doing its job: the object is reachable, so it stays.

Here’s the classic case:

const removedPanels = []

function closePanel(panel) {
  panel.remove()
  removedPanels.push(panel)
}

panel.remove() takes the element out of the document. Visually it’s gone. But the array still holds it, so the element stays in memory, together with its children, its event listeners, and any data attached to it. We call this a detached DOM node: a node that isn’t in the page anymore but is still reachable from JavaScript.

The usual suspects

Most leaks come from a short list:

  • event listeners added to window, document, or another long-lived object and never removed
  • setInterval() timers, IntersectionObserver or MutationObserver instances, and subscriptions that were never stopped
  • closures: a callback that captures a big object keeps that object alive as long as the callback exists
  • caches that only grow, with no size or age limit
  • arrays or maps that collect detached DOM nodes, like the example above

The common thread is a resource that lives longer than the feature that created it.

Give every resource an owner

The fix is a habit. Whenever you create something long-lived, decide who cleans it up and when. My favorite tool for this is AbortController, because it removes many listeners with one call:

function mountPanel(panel) {
  const controller = new AbortController()

  window.addEventListener('resize', updatePanel, {
    signal: controller.signal
  })

  return () => controller.abort()
}

mountPanel() returns a cleanup function. Whoever mounts the panel calls it when the panel closes. controller.abort() removes the listener, and updatePanel plus anything it captured can be collected.

Do the same for timers with clearInterval(), for observers with disconnect(), and for caches with a maximum size.

How to check for a leak

Don’t stare at one memory number. Repeat a lifecycle instead: open a view, close it, and do that five or ten times. Take a heap snapshot before and after. If the instance count of your component, or the number of detached nodes, grows with each cycle, you have a leak.

Then select one of the leaked objects and look at its retaining path, the chain of references that keeps it alive. That path points straight at the cleanup code you forgot to write.

Be careful with false alarms. Some growth is an intentional cache, and garbage collection timing is not deterministic. A real leak shows repeatable growth after the feature should have released everything.

Lesson completed