← Debugging Guides

阅读中文版本

Browser Memory Leak — Detached DOM Nodes

Identify and fix memory leaks caused by references to removed DOM nodes that prevent garbage collection when event listeners, closures, or data caches maintain strong references.

intermediatedebuggingperformancememorybrowserdom

Symptoms

  • Increasing memory usage in DevTools Performance/Memory tabs over time
  • Page becomes sluggish after repeated navigation or dynamic content replacement
  • Heap snapshot shows growing number of 'Detached HTMLElement' entries
  • Memory graph in DevTools never drops after removing DOM elements

Possible causes

  • JavaScript reference to a DOM node that has been removed from the document
  • EventListener added to a DOM node that remains in memory after removal
  • Closure capturing a DOM reference (timer, callback, or external registry)
  • Map or Set using DOM nodes as keys without WeakMap/WeakSet
  • Framework state stores caching component instances bound to DOM

Diagnosis steps

Open Chrome DevTools → Memory tab

Press F12, navigate to Memory panel

Interpretation

Take a heap snapshot baseline, perform the suspected leak action (e.g., open/close a modal 10 times), take another snapshot. Compare: if 'Detached' nodes increase, you have a leak.

Use Allocation instrumentation on timeline

Memory tab → select 'Allocation instrumentation on timeline' → record

Interpretation

Perform the action that causes leaks. Blue bars are new allocations. If bars grow continuously and never get reclaimed (no downward slope), the leaked objects are still referenced.

Inspect detached node retaining tree

In a heap snapshot, find a 'Detached HTMLDivElement' and expand its retaining tree

Interpretation

The retaining tree shows what keeps the detached node alive. Common culprits: 'closure', 'array', 'listener', or framework-internal 'Fiber'/'vnode' references.

Check for stale event listeners

Chrome DevTools → Elements panel → Event Listeners sidebar

Interpretation

Expand the Event Listeners pane on a removed element. If listeners persist after node removal, removeEventListener was never called or a different function reference was used.

Root cause

A reachable JavaScript object (array, Map, closure, or event listener) holds a strong reference to a DOM node that has been removed from the document tree. The browser cannot garbage-collect the node or its associated event handlers because the reference chain is still live.

Fix

Remove all JavaScript references to the DOM node after removal. Use WeakRef or WeakMap for caches keyed by DOM nodes. Remove event listeners before removing nodes. For framework code, ensure cleanup functions destroy references in useEffect/componentWillUnmount.

Risk note

Using WeakRef requires checking .deref() for null as the target can be collected at any GC cycle — never assume the reference is still valid.

Verify the fix

After applying the fix, repeat the DevTools Memory profiling workflow. The detached node count should remain stable (flat line) across repeated open/close or navigation cycles.

What Causes Detached DOM Leaks

A detached DOM node is an element removed from the document tree but still referenced by JavaScript. Because the reference chain is still reachable from a GC root, the browser cannot reclaim the memory.

The Reference Chain Problem

// ❌ Leaked: nodeCache keeps removed nodes alive
const nodeCache = [];

function createWidget() {
  const el = document.createElement('div');
  nodeCache.push(el); // stored reference prevents GC
  document.body.appendChild(el);
}

function removeWidget() {
  const el = nodeCache[0];
  el.remove(); // removed from DOM BUT still in nodeCache
}

Common Leak Patterns

1. Closures Capturing DOM References

function setupHandler() {
  const button = document.getElementById('myBtn');
  const heavyData = new Array(10000).fill('payload');
  
  button.addEventListener('click', function() {
    console.log(heavyData); // closure captures both button AND heavyData
  });
  
  // Even after removing button, the listener (and closure) keeps it alive
}

Fix: Remove listeners before removing the DOM node:

button.removeEventListener('click', handler);
button.remove();

2. Map/Set Using DOM Keys

// ❌ Strong references prevent GC
const metadata = new Map();
metadata.set(domNode, { created: Date.now() });

// ✅ Weak references allow GC when node is no longer reachable
const metadata = new WeakMap();
metadata.set(domNode, { created: Date.now() });

3. Framework Effect Cleanup (React)

useEffect(() => {
  const handler = (e) => console.log(e);
  const el = ref.current;
  el.addEventListener('mousemove', handler);
  
  // ✅ Cleanup removes listener when component unmounts
  return () => el.removeEventListener('mousemove', handler);
}, []);

DevTools Workflow

  1. Open Memory tab → Take Heap Snapshot baseline
  2. Perform the suspected leak action (open/close modal) 10 times
  3. Take another Snapshot
  4. Switch view to Comparison between snapshot 1 and 2
  5. Filter by “Detached” in the constructor list
  6. Expand a detached node and examine its Retaining Tree

Framework-Specific Notes

  • React: Check useEffect cleanup functions and state that stores DOM refs
  • Vue: Review beforeUnmount hooks for listener cleanup
  • Svelte: Check for missing destroy callbacks in actions

Prevention Guidelines

  • Always pair addEventListener with removeEventListener
  • Use WeakMap/WeakSet for metadata associated with DOM nodes
  • Null out large object references in cleanup/destroy phases
  • Profile memory after any feature that dynamically creates/destroys DOM
  • WeakRef: Hold a reference that doesn’t prevent GC
  • FinalizationRegistry: Get notified when an object is collected
  • IntersectionObserver: Self-cleaning pattern (callback fires then auto-disconnects)