Finding Leaks With Heap Snapshots

A support ticket lands: the dashboard “gets sluggish after a while.” You cannot reproduce it in a fresh tab, so you leave one open over lunch and come back to a tab holding 1.4 GB of memory, the scroll juddering, the fan audible. Nothing in your code calls malloc. You never freed a byte in your life, because JavaScript frees things for you.

So what filled 1.4 GB?

This is the uncomfortable truth about a garbage-collected language. It does not remove the possibility of a leak. It changes what a leak is. In C a leak is memory you forgot to free. In JavaScript a leak is a reference you forgot to drop, and the collector, doing its job perfectly, keeps the object alive because you told it to. The whole skill of this lesson is finding that reference and cutting it.

What actually leaks in a garbage-collected language

Start from reachability, because a leak is defined entirely in its terms. The collector keeps an object in memory for exactly as long as something can reach it from a root. A root is a value that is alive by definition: the window global, the DOM tree, the currently running functions. Follow references outward from the roots and everything you can touch survives. Everything you cannot touch is freed.

A leak, precisely stated, is this: an object stays reachable after you are done with it. Not “the collector missed it.” The collector cannot miss it, because from where the collector stands the object is still reachable. Some edge in the graph still points at it. You are done with a closed modal, a torn-down component, a cache entry from an hour ago, but one forgotten reference keeps a live path to it, so it stays. And usually it does not stay alone. It drags its whole subtree with it: the data it closed over, the DOM nodes it held, the other objects it pointed at.

one forgotten edge is a leakGC rootwindowcache Maplong-livedclosed Viewno longer neededshould be gonedatasubtreeretainsholdsThe View is reachable through the cache, so the collector cannot touch it.Cut that one red edge and the View, plus its data subtree, become collectable.
A leak is one live edge you meant to cut. The View is reachable through a long-lived cache, so the collector cannot free it or anything it holds.

That reframing is the whole game. You are never hunting for “lost” memory. You are hunting for the one edge you meant to remove and did not. Find it, cut it, the object falls off the graph, and the collector reclaims it on its next pass. Everything else in this lesson is technique for finding that edge fast.

The usual suspects

Before opening any tooling, it helps to know the handful of patterns that cause almost every leak. They are all the same bug wearing different clothes: something long-lived holds a reference to something short-lived.

Listeners and subscriptions you never remove. This is the big one. When you register a handler, the engine keeps that function alive so it can call it later, and a function keeps its whole closure alive with it, meaning every variable it captured. Add the listener when a component mounts, forget to remove it when the component unmounts, and the component (and the DOM and data it captured) can never be collected. The observer and pub/sub pattern leaks the same way: subscribe without a matching unsubscribe and the subject holds your callback forever.

function mountChart(node, store) {
  const data = loadHugeDataset();          // megabytes
  function onResize() { redraw(node, data); }
  window.addEventListener("resize", onResize);
  // no removeEventListener anywhere: window keeps onResize,
  // onResize keeps `data` and `node`, forever.
}

Timers and intervals you never clear. setInterval keeps its callback alive by design, tick after tick, and the callback keeps whatever it closed over. Start one per component and never call clearInterval, and you have both a leak and a pile of zombie timers still running.

Caches and Maps with no eviction. A Map or plain object you only ever write to is an unbounded leak with a friendly name. Every entry is a strong reference the collector must honor. This is the failure mode the caching layers lesson warns about: a cache without a bound and an eviction policy is just a memory leak you decided to keep.

const seen = new Map();
function remember(id, payload) {
  seen.set(id, payload);   // nothing ever deletes: seen grows without limit
}

Closures capturing more than they need. A closure retains its entire lexical scope, so a tiny callback stashed somewhere long-lived can pin a large object it happens to sit next to, even if it never touches it.

Detached DOM nodes. You remove a node from the document, but a JavaScript variable still points at it. The node and its entire subtree stay in memory, disconnected from the page but very much alive. This one gets its own section below, because it is common and slippery.

Module-level and global collections that only grow. An array at module scope that you push into on every request, a global registry nobody prunes. Global means rooted, and rooted means permanent.

LISTENERS
holds it  the event target keeps the handler and its closure
the cut  removeEventListener, or an AbortController signal
TIMERS
holds it  the timer keeps its callback alive every tick
the cut  clearInterval / clearTimeout on teardown
CACHES / MAPS
holds it  a strong reference per entry, kept forever
the cut  a size bound and eviction, or a WeakMap key
DETACHED DOM
holds it  a JS variable still points at a removed node
the cut  null the variable that pins the subtree
CLOSURES
holds it  a stored callback pins its whole scope
the cut  drop the callback, or capture less
GLOBALS
holds it  module-scope collections are rooted, so permanent
the cut  keep them bounded, or scope them narrower
Every common leak is the same shape: a long-lived holder keeps a short-lived object reachable. The fix is always cutting that specific edge.

Notice the symptom first

You usually feel a leak before you profile it. The tell is the shape of the memory graph over time. A healthy heap sawtooths: memory climbs as you allocate, a young-generation collection drops it straight back down, and it repeats above a flat floor of genuinely long-lived objects. That shape is the collector working well, covered in the heap lesson, and it is not a problem to chase.

A leak has a different silhouette. The teeth are still there, but the floor keeps rising. Do the same thing ten times, come back to the same screen, and each round leaves a little more behind that never comes down. Left running, that trend ends in the slowdowns and eventual crash from the opening. In Chrome’s Performance panel you can watch this live by recording with the memory track on. When you want to know what the rising floor is made of, you reach for a heap snapshot.

Shallow size, retained size, and which one matters

Open the Memory panel in DevTools, take a heap snapshot, and the default Summary view groups every live object by its constructor with two size columns. Getting these two straight is the difference between fixing a leak and chasing a decoy.

Shallow size is the memory the object holds by itself. Its own fields, nothing more. A closure or a small wrapper object has a tiny shallow size.

Retained size is the memory that would be freed if this object were deleted and everything that becomes unreachable as a result went with it. It is the object plus its exclusive subtree. This is the number that matters, because it answers the only question you care about: how much do I get back if I cut this?

The gap between them is where leaks hide. A single event handler might have a shallow size of a few dozen bytes and a retained size of twelve megabytes, because through its closure it is the only thing keeping a giant dataset and a pile of DOM nodes alive. Sort by shallow size and that handler is invisible, lost among the noise. Sort by retained size and it jumps to the top of the list, which is exactly where you want it.

shallow vs retained sizehandler fshallow: 56 Btiny on its ownretained size: 12 MBcomponentdataset10,000 rowscached DOM refscaptured stateclosed-over scopeTriage by retained size. The object worth fixing is rarely the biggest by itself.
Shallow size is what the object weighs alone. Retained size is everything freed when it goes. A tiny handler can retain a huge subtree.

The byte counts in that diagram are illustrative, not measured. The point is the ratio. DevTools also shows a Distance column, the number of references on the shortest path from the window root to the object. Distance helps you spot the odd one out: if most objects of a type sit at distance 8 and one sits at distance 3, that short-distance instance is being held closer to a root than its siblings, which is often the retained one.

Retainers: who is holding this object

Knowing an object is retained is half an answer. The other half is who, and that is the actual detective work. Select any object in a snapshot and the Retainers pane shows the objects that point at it, and their retainers, and so on up the chain until you reach a GC root. This chain is the leak, spelled out. It is the exact path the collector walks to decide the object is still alive, read in reverse.

the retaining pathWindowGC rootapp (AppRoot)state.cachesdetailCache MapDetailViewleaked.app.state.caches.detail.get(42)reading itThe Retainers pane showsthis chain. Walk upwardfrom the leaked objectuntil you hit an edge youown. Here: drop the entryfrom detailCache.
The retainers chain: from a leaked object up through each reference that holds it, back to a GC root. Read it bottom-up and cut the highest edge you control.

Reading a retainers chain is a skill you build fast once you know what you are looking at. Start at the leaked object at the bottom. Each step up names the property that holds it: this DetailView is value 42 in a detailCache Map, which lives on state.caches, which hangs off app, which is on window. Now you have a menu of edges to cut, and you pick the highest one you own. You do not control window, but you own the code that writes to detailCache, so the fix is a matching delete when the view closes. One edge, gone, chain broken.

The three-snapshot technique

One snapshot tells you what is in memory now. It cannot tell you what is leaking, because you cannot eyeball which of a million live objects are the ones that should have died. The technique that turns a snapshot into a leak finder is comparison, and the classic recipe, worn smooth by years of use on large web apps, goes like this.

  1. Get to a steady state. Load the app, let it settle.
  2. Take snapshot 1. This is your baseline.
  3. Perform an action that should end exactly where it started. Open a view and close it. Open a modal and dismiss it. Do it several times, say ten or twenty, so any per-cycle leak is multiplied into something obvious.
  4. Take snapshot 2.
  5. Switch the view from Summary to Comparison, comparing snapshot 2 against snapshot 1, and sort by the delta.

The logic is simple and strong. You returned to the same UI state, so in a clean app the two snapshots should hold roughly the same objects, and the delta should hover near zero. Anything with a big positive delta was allocated during your cycles and never released. Twenty opens produced twenty leaked views, twenty orphaned listeners, twenty copies of whatever they held. The comparison view isolates exactly those, by constructor, so instead of a million objects you are looking at the few dozen that accumulated.

same screen, more memorySnapshot 1baselineheapSnapshot 2same UI stateheap, higheropen+ closex20Comparison (2 vs 1)sorted by deltaDetailView+20(closure)+20EventListener+20Array+41,900Detached div+60accumulated, never releasedthis is your leakDelta counts are illustrative. Twenty cycles, twenty of everything that should have been freed once.
Return to the same UI state, but memory grew. The comparison view isolates the objects that accumulated across cycles and were never freed.

Two refinements make this sharper. Before taking snapshot 2, click the collect-garbage button (the trash-can icon) in the Memory panel, so a forced collection removes everything that genuinely can be freed. Whatever survives that is retained for real, not just sitting in the young generation waiting to be swept. And DevTools itself is careful here: taking a heap snapshot triggers a collection first, so a snapshot only ever contains reachable objects. That is convenient, because it means anything you see in a snapshot is, by definition, still reachable from a root. If a closed view shows up, something is holding it.

Now watch the whole loop in miniature. Below is a simulated app. Open a view, close it, and either the close cleans up or it does not, depending on the mode. The memory readout climbs with every open. In leak mode, closing does nothing to bring it back, and running the collector cannot help, because the reference is still there. Flip to fixed mode and closing cuts the edge, so the next collection reclaims the view.

interactiveLeak hunt: open, close, collect, and read the retainers

The lesson in the toy is the lesson in production. In leak mode the collector is powerless, not broken. It refuses to free the closed views because the registry still reaches them, which is the correct behavior given the graph you built. Fixed mode changes the graph, not the collector.

Detached DOM: the subtree one variable keeps alive

DOM leaks deserve special attention because the retained subtree can be enormous and the holding reference is often a single innocent-looking variable. A DOM node is “detached” when it has been removed from the document but JavaScript still points at it. It is off the page, invisible, doing nothing, and fully resident in memory, because from a root there is still a path to it.

The trap is that you only have to hold one node of a subtree. The DOM is a tree, and holding a node keeps its parent chain and all of its descendants alive with it. Cache a reference to a list element to avoid re-querying it, remove the panel that contains the list, and you have pinned the panel, the list, every row, and every node inside every row.

const cache = {};
function showDetail(id) {
  const panel = document.createElement("div");
  panel.innerHTML = renderBigDetail(id);      // hundreds of nodes
  document.body.append(panel);
  cache[id] = panel.querySelector(".rows");   // keep a handy reference
}
function hideDetail(id) {
  document.body.removeChild(cache[id].closest(".detail"));
  // panel is off the page, but cache[id] still points into it:
  // the whole detached subtree stays in memory.
}
detached DOM: one reference pins the whole subtreeattached (on the page)documentbody#appremovedxdetached subtree (still in memory)panel.rowsrowrowcache[id]direct JS referenceYellow node has a JS reference. Red nodes are detached but held because their ancestor is.
A detached subtree stays alive because one JS variable points into it. The referenced node holds its ancestors and every descendant.

That yellow-and-red split is exactly how DevTools draws it. In a snapshot, filter the constructor list by typing Detached and you get every detached node group. The node highlighted yellow is the one with a direct reference from your JavaScript, and it is almost always the culprit. Nodes in red are detached too, but they carry no direct JS reference; they survive only because they hang under the yellow one. Fix the yellow node, cut the reference your code holds, and the whole red subtree collects with it. Current Chrome also ships a dedicated Detached elements profiling type that lists these directly, without the constructor filtering, which is the fastest route when you already suspect a DOM leak.

Cutting the retainer

Every fix is the same move made specific: find the edge, remove it. The diagnosis tells you which edge.

For listeners, remove what you added, or let the platform do it. An AbortController unsubscribes a whole batch of listeners with one signal, which is far harder to forget than a pile of matching removeEventListener calls.

function mountChart(node, store) {
  const data = loadHugeDataset();
  const ac = new AbortController();
  window.addEventListener("resize", () => redraw(node, data), { signal: ac.signal });
  const unsubscribe = store.subscribe(update);
  return () => { ac.abort(); unsubscribe(); };   // teardown cuts every edge
}

For timers, keep the id and clear it on teardown. For caches, put a bound on the thing: a maximum size with least-recently-used eviction, or a time-to-live, so entries leave on their own. And where you want to associate data with an object without keeping that object alive, reach for a WeakMap. Its keys are held weakly, so they do not count toward reachability. The moment the key object becomes otherwise unreachable, the entry vanishes on the next collection, no eviction logic required.

// strong Map: every element you touch is pinned forever
const strong = new Map();
strong.set(element, metadata);

// WeakMap: metadata lives only as long as the element does anyway
const weak = new WeakMap();
weak.set(element, metadata);   // element removed from DOM and code -> entry auto-clears

WeakRef and FinalizationRegistry go a step further, letting you hold a reference that does not retain and observe when the collector reclaims a value. They are sharp tools with real caveats (a WeakMap still holds its values strongly, and cleanup timing is never guaranteed), and they get their own treatment in WeakRef and FinalizationRegistry. For most leaks you never need them. You need to remove one listener.

What this means for your code

You will not think about any of this most days, and you should not. The engine handles memory well enough that reaching for the profiler is a response to a symptom, not a routine. But when the symptom shows up, a slow tab, a rising floor, a crash after an hour, this is a bounded, learnable procedure rather than a mystery.

The mental model is small. A leak is a reference you forgot to drop. Heap snapshots show you what is reachable, retained size tells you which objects are worth chasing, and the retainers chain names the exact edges holding them. The three-snapshot technique turns “memory is growing” into “these twenty objects accumulated per cycle, held by this listener.” From there the fix is almost always one line in the teardown you skipped.

Summary

  • In a garbage-collected language a leak is not forgotten free, it is a forgotten reference: an object stays reachable after you are done with it, so the collector correctly keeps it.
  • The fix is always the same shape: find the retaining edge and cut it. The collector reclaims the object on its next pass.
  • Most leaks come from a handful of patterns: listeners and subscriptions never removed, timers never cleared, unbounded caches and Maps, closures capturing too much, detached DOM, and global collections that only grow.
  • In a heap snapshot, triage by retained size (what frees if the object goes), not shallow size (what the object weighs alone). A tiny object can retain a huge subtree.
  • The retainers chain shows who holds an object, from the leak up to a GC root. Read it, find the highest edge you own, cut it.
  • The three-snapshot technique: baseline, repeat an action that should return to the same state, snapshot again, compare, and look for objects that accumulated instead of returning to zero.
  • Detached DOM nodes stay alive when one JS variable points into a removed subtree. Filter by Detached; the yellow node is the reference your code holds.
  • What this means for your code: you steer memory through reachability, same as always. When the heap floor keeps rising instead of sawtoothing back down, a snapshot turns a vague slowdown into a specific edge to remove.