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.
Step-by-Step Fix
- Isolate what survived. DevTools path: Memory → Heap snapshot ×3 around two identical rounds → Comparison, 1 vs 3. Expected: a short list where
# Deletedis near zero. - 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.
- 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. - Read the context node. Expected: the node directly below the function is the captured scope, and its properties show exactly which bindings are held.
- 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);
}
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.
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.
Related
- Closure Memory Leaks in Modern JavaScript — what closures capture and why
- Timer and Interval Leaks in Long-Running Pages — the registration at the top of many chains
- Interpreting Heap Snapshots for Memory Analysis — the views the chain lives in