Finding the Retaining Path
The Detective Work of Memory Debugging
You have taken a heap snapshot. You have found the leaked objects. You know what is leaking. But that is only half the story. The real question is: why is the garbage collector not collecting it?
Every object in memory is alive for one reason: something reachable from a GC root holds a reference to it. The retaining path is the chain of references from the GC root down to your leaked object. Find the path, and you find the bug.
Think of the retaining path like a chain of custody. The GC root is the police evidence locker (untouchable). Your leaked object is an item at the end of a chain of people. You cannot throw the item away because Person A is holding it, and Person A cannot be removed because Person B is holding Person A, all the way back to the evidence locker. Finding the retaining path means following the chain backward until you find the link that should not exist — the unexpected reference that is keeping the whole chain alive.
GC Roots: Where It All Starts
In V8, an object can only survive garbage collection if it is reachable from a GC root. These are the starting points the garbage collector considers "definitely alive":
If there is no path from any GC root to an object, V8 collects it. If there is even one path, no matter how convoluted, the object stays alive.
Reading Retaining Paths in DevTools
When you click on an object in a heap snapshot, the lower pane shows its Retainers — the objects that hold references to it. This is your retaining path, read from bottom to top.
A typical retaining path looks like this:
your_leaked_object
↑ in property "data" of Object @123456
↑ in property "cache" of Module @789012
↑ in variable "moduleCache" of Window @1
↑ GC Root: Window
Read this from bottom to top:
- The
Windowobject (a GC root) has a variablemoduleCache moduleCacheis aModuleobject with acachepropertycacheis anObjectwith adatapropertydatais your leaked object
The fix: somewhere in this chain, there is a reference that should have been cleared. Maybe moduleCache should have been cleaned up. Maybe cache.data should have been set to null after use.
Step-by-Step Debugging Workflow
Here is the process I use every time I need to find a retaining path:
Handling Multiple Retaining Paths
Sometimes an object has multiple retaining paths — it is kept alive by more than one root. DevTools shows all of them. You need to fix every path to make the object collectible.
const moduleCache = new Map();
const debugLog = [];
function loadWidget(id) {
const widget = createWidget(id);
moduleCache.set(id, widget);
debugLog.push({ widget, loadedAt: Date.now() });
return widget;
}
function unloadWidget(id) {
const widget = moduleCache.get(id);
widget.destroy();
moduleCache.delete(id);
// BUG: widget is still in debugLog!
}
After unloadWidget, the widget has one retaining path removed (moduleCache) but still has another (debugLog). Both must be cleaned up.
Common Retaining Path Patterns
Pattern 1: Module-Level Cache
leaked_object
↑ in value of Map entry
↑ in property "[[Entries]]" of Map @12345
↑ in variable "cache" of (closure) @67890
↑ in context of module scope
↑ GC Root: system / Context
The fix: implement cache eviction (LRU, TTL, or WeakMap if keys are objects).
Pattern 2: Forgotten Event Listener
leaked_component_data
↑ in variable "props" of (closure) @11111
↑ in property "handler" of EventListener
↑ in property "listeners" of Window @1
↑ GC Root: Window
The fix: remove the event listener in the cleanup function.
Pattern 3: Detached DOM via React Ref
Detached HTMLDivElement
↑ in property "current" of Object @22222
↑ in property "ref" of FiberNode @33333
↑ in property "stateNode" of FiberNode @44444
↑ ... (React internal fiber tree)
↑ GC Root: system / Context
The fix: ensure refs are cleaned up when components unmount. If you copy ref.current into an external variable, null it in a cleanup effect.
Pattern 4: Promise Chain Retention
large_response_data
↑ in variable "data" of (closure) @55555
↑ in property "handler" of PromiseReaction
↑ in property "reactions" of Promise @66666
↑ in variable "pendingRequest" of (closure)
↑ GC Root: system / Context
The fix: ensure promises resolve or reject (not left pending), and avoid storing large data in long-lived promise chains.
Chrome DevTools sometimes shows retaining paths through V8 internals like system / Context, (compiled code), or InternalNode. These are V8's internal bookkeeping and not directly actionable. Look for the path segments with recognizable property names, variable names, or constructor names — those are the ones you can fix in your source code.
Advanced: Distance from GC Root
In the Summary view, the Distance column shows the shortest number of reference hops from any GC root to the object. This is useful for triage:
- Distance 1-3: directly reachable from global state. Look for module-level variables or properties on
window. - Distance 4-10: typical for objects nested in application data structures.
- Distance 10+: deeply nested. Often means the object is retained through a long chain (like a linked list or deep component tree).
Objects with unexpectedly high distance values might be retained through convoluted chains that are hard to spot. Sort by distance to find these outliers.
Weak references and the retaining path
WeakRef, WeakMap, and WeakSet do NOT appear in retaining paths because they are weak references — they do not prevent garbage collection. If the only path from a GC root to an object goes through a WeakRef, the object is collectible. This is exactly why WeakMap is perfect for caches where you want the cached value to be collected when the key is no longer needed. In the heap snapshot, weak references appear greyed out or with a special notation to indicate they are not contributing to the object's liveness.
- 1The retaining path is the chain of references from a GC root to a leaked object — find the chain, find the bug
- 2Read retaining paths from bottom (GC root) to top (leaked object) — look for the unexpected link
- 3An object with multiple retaining paths requires ALL paths to be severed before it can be collected
- 4Weak references (WeakRef, WeakMap, WeakSet) do not appear in retaining paths and do not prevent collection
- 5V8 internal retaining path segments (system/Context, compiled code) are not directly actionable — focus on recognizable property and variable names
| What developers do | What they should do |
|---|---|
| Only fixing one retaining path when the object has multiple Even one surviving path from a GC root keeps the entire object alive | Check for ALL retaining paths — every one must be severed |
| Confusing retaining path with allocation path An object might be created in function A but retained by a reference in module B — the retaining path shows B | The retaining path shows what KEEPS the object alive now, not where it was created |
| Assuming all retaining paths through internal/system nodes are bugs V8 has internal references for compiled code, scope contexts, and built-in objects that are not actionable | Some V8 internal paths are normal for live objects — focus on paths through your application code |