How to Find the Closure Retaining an Object in DevTools

Knowing that a closure leaks is easy; knowing which closure, on which line, is the part that takes practice. This page belongs to closure memory leaks in modern JavaScript inside Browser DevTools & Performance Profiling Workflows, and walks the chain end to end.

Symptom Root Cause Immediate Action
Object survives every cycle but no owner is obvious A closure context holds it Read the Retainers pane, not the Summary row
Every function in the chain is (anonymous) Minified build Re-profile a source-mapped build
Chain passes only through framework internals Your callback was handed to the framework Walk up to the first file you own
Several chains lead to the same object Multiple retainers Fix the shortest one you control, then re-measure
Chain ends in (GC roots) immediately A timer, listener or global holds it Look for the registration, not for a variable

Root Cause: The Chain Is There, It Just Reads Backwards

A heap snapshot’s Retainers pane answers exactly the question you have — who is keeping this alive — but it reads bottom-up, from the object toward the roots, which is the opposite of how the code reads. Learning to read it is mostly learning three node types.

A context node is a closure scope: V8 allocates one Context per scope that a closure captures, holding every binding in that scope. Seeing a context in the chain means “a function closed over this”. The context’s own retainer is the function object, and that function usually has a name you can search for.

A system / JSFunction node is the function itself. Its name is the single most valuable piece of information in the chain — with a source-mapped build, it maps straight to a line.

An internal or synthetic node — (GC roots), InternalNode, Window — marks the end of the chain. If the chain reaches a root in one or two hops through something you did not write, the retainer is a registration rather than a variable: a timer, an event listener, or a property on a global.

The chain is also frequently plural. The same object can be reachable several ways, and DevTools lists them all. The one worth fixing is the shortest path that passes through a file you own; the others usually disappear when it does.

Reading a retainer chain bottom-up Starting from a detached table element, the chain runs to a closure context holding the captured bindings, then to a named function called startAutoRefresh, then to the timer list, and finally to the window root. The named function is the actionable frame. Read upward: the object is at the bottom, the root at the top Window @1 — the GC root, end of the chain timer list — a registration, not a variable startAutoRefresh() — dashboard.js:88 ← fix here context — the captured scope bindings Detached HTMLTableElement · 4.2 MB retained the only frame you can edit

Step-by-Step Fix

  1. Isolate what survived. DevTools path: Memory → Heap snapshot ×3 around two identical rounds → Comparison, 1 vs 3. Expected: a short list where # Deleted is near zero.
  2. Open Retainers on one instance. DevTools path: click the object; the Retainers pane appears below. Expected: one or more chains, each ending at a root.
  3. Pick the shortest chain through your own code. Expected: a frame whose file you recognise; ignore (GC roots)-only paths and library internals above it.
  4. Read the context node. Expected: the node directly below the function is the captured scope, and its properties show exactly which bindings are held.
  5. Confirm with an allocation stack. DevTools path: Memory → Allocation instrumentation on timeline → record → select a blue band → Allocation stack. Expected: the source line that created the object, which pairs with the retainer to give both ends of the problem.

Command & Code Reference

Making the chain readable before you start: named functions survive minification far better than anonymous ones.

// Anonymous: appears as (anonymous) in every retainer chain.
el.addEventListener('scroll', () => this.recompute());

// Named: appears as onDashboardScroll, which is greppable and maps to a line
// even in a production build with source maps.
function onDashboardScroll() { this.recompute(); }
el.addEventListener('scroll', onDashboardScroll);

A console helper that answers “is this specific object still alive” without reading a snapshot at all.

// Hold a weak reference before the interaction, then check after a forced
// collection. Requires a build started with --js-flags="--expose-gc".
const watch = new WeakRef(document.querySelector('#report-table'));

function stillAlive() {
  gc();                                  // needs --expose-gc
  return watch.deref() !== undefined;    // true means something still holds it
}
// Run the unmount, then stillAlive(). A true answer justifies the snapshot;
// a false one saves you from taking it.

Reducing what a closure captures, which is the fix at the end of nearly every chain.

// Captures `component`, so the context retains the whole instance and its DOM.
function startAutoRefreshBad(component) {
  return setInterval(() => component.refresh(), 30_000);
}

// Captures a bound function and an id. The context holds two small values,
// and the component becomes collectable the moment nothing else holds it.
function startAutoRefreshGood(refresh, viewId) {
  return setInterval(() => refresh(viewId), 30_000);
}
Three chains to the same object, ranked Chain A runs four hops through a DevTools wrapper and is synthetic. Chain B runs seven hops ending in router internals and is true but not editable. Chain C runs two hops through the application's own module and is the one to fix. DevTools lists every path — you only act on one of them Chain A · 4 hops · through a DevTools debugger wrapper synthetic — exists only while the panel is open. Ignore entirely. Chain B · 7 hops · ends inside the router's internals true, but the fix would be upstream of code you own. Note it and move on. Chain C · 2 hops · context → startAutoRefresh (dashboard.js:88) shortest path through your own module — this is the one to fix. Fixing chain C usually removes chain B as well, because both depended on the same capture.

Verification & Regression Prevention

Verify by re-running the three-snapshot comparison after the change: the object should no longer appear, and not merely appear with a smaller retained size. A smaller size usually means one of several retainers was removed and the rest are still there.

For prevention, the two habits that pay for themselves are naming functions — so future chains are readable in production builds — and destructuring primitives at capture boundaries, so a closure holds identifiers rather than objects. The WeakRef probe above is also worth keeping in a development helper: it turns a five-minute snapshot investigation into a boolean any engineer can check in the console.

Node types in a retainer chain Context means a captured scope and is not directly actionable. A named JSFunction is the actionable frame. Window and GC roots mark the end of the chain. InternalNode and synthetic nodes should be ignored. A framework class means walk further up. Five node types, and what each one tells you Node Means Actionable? context a captured scope's bindings look one hop up named JSFunction the closure that captured it yes — fix here Window / (GC roots) end of the chain look for a registration framework class the library holds your callback walk further up InternalNode / synthetic engine or DevTools bookkeeping ignore

FAQ

What does a node named context mean in the Retainers pane?

It is a closure scope object — V8’s Context — holding every binding a closure captured. Its own retainer is the function that closes over it, and that function’s name is usually the fastest route back to the source line responsible.

Why are all my functions called anonymous?

Minified builds strip names, and arrow functions assigned to nothing never had one. Profile a development or source-mapped build for the investigation; the retention behaviour is the same and the names make the chain readable.

The retainer chain runs through framework internals — what now?

Keep walking up until you reach a frame in a file you own. Frameworks hold your callbacks; the fix is nearly always at the point where your code handed the callback over, not inside the library.