Skip to content

Finding the Retaining Path

advanced15 min read

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.

Mental Model

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:

  1. The Window object (a GC root) has a variable moduleCache
  2. moduleCache is a Module object with a cache property
  3. cache is an Object with a data property
  4. data is 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.

Quiz
You find a leaked HTMLDivElement in a heap snapshot. Its retaining path shows: HTMLDivElement, in property 'element' of Object, in index 3 of Array, in property 'listeners' of EventTarget, in property 'onresize' of Window. What is keeping the div alive?

Step-by-Step Debugging Workflow

Here is the process I use every time I need to find a retaining path:

Execution Trace
Identify the Leak
Use heap snapshot comparison to find leaked objects
You know WHAT is leaking (e.g., 500 Detached HTMLDivElements)
Pick One Instance
In the Summary view, expand the constructor and click any single instance
Do not try to analyze all instances — pick one representative
Read Retainers
Look at the Retainers section in the lower pane
Read from bottom (GC root) to top (leaked object)
Follow the Chain
Identify each link: property name, index, variable name
Look for the link that should not exist
Find the Culprit
The unexpected link is your bug
Usually: a module-level cache, a forgotten listener, or a closure
Fix and Verify
Remove the reference, retake snapshots
The leaked objects should no longer appear in the comparison

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.

Quiz
A leaked object has two retaining paths: one through a module-level Map and one through a closure in an event listener. You clear the Map entry but the object still appears in the next snapshot. Why?

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.

Common Trap

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.

Quiz
You see a retaining path: leaked_object, in property current of Object, in property __reactFiber of HTMLDivElement, in property firstChild of HTMLDivElement, in property body of HTMLDocument, GC Root: Window. The leaked object is attached to a React fiber on a DOM node. What is the most likely cause?
Key Rules
  1. 1The retaining path is the chain of references from a GC root to a leaked object — find the chain, find the bug
  2. 2Read retaining paths from bottom (GC root) to top (leaked object) — look for the unexpected link
  3. 3An object with multiple retaining paths requires ALL paths to be severed before it can be collected
  4. 4Weak references (WeakRef, WeakMap, WeakSet) do not appear in retaining paths and do not prevent collection
  5. 5V8 internal retaining path segments (system/Context, compiled code) are not directly actionable — focus on recognizable property and variable names
What developers doWhat 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