What this usually means
When you remove a DOM element from the document (via removeChild, innerHTML replacement, or framework unmount), the browser marks it as 'detached'. If a JavaScript variable, closure, event listener, or framework internal still holds a reference to that element (or any of its children), the entire subtree stays in memory. The garbage collector cannot reclaim it because there is a live reference from the JS heap. Common culprits: unremoved event listeners on detached elements, React refs not nullified, Angular component destroy not cleaning up, closure references in callbacks, or libraries that cache DOM nodes. The result is a growing number of detached nodes that consume memory and slow down layout/paint operations.
The first ten minutes — establish facts before touching code.
- 1Open Chrome DevTools > Performance, record user interactions, and observe the JS Heap line — if it grows and never drops to baseline, suspect a leak.
- 2Take a heap snapshot (Memory tab > Take snapshot) after initial load, then after several add/remove cycles, then force a GC (garbage can icon) and take another snapshot. Compare the number of Detached DOM nodes.
- 3In the heap snapshot, filter by 'Detached' — if you see a class of detached elements, expand to see what retains them (the 'Retainers' path).
- 4Search for the specific element tag or class name that appears detached — often a component's root element.
- 5Check event listeners: use getEventListeners(window) in console or the 'Event Listeners' tab to see listeners attached to detached nodes.
- 6If using React, enable React DevTools profiler and check component unmount — a component that stays in memory after unmount is a clue.
- 7For Angular, check if ngOnDestroy is called; if not, the component might not be cleaning up subscriptions.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchChrome DevTools Memory tab > Heap snapshots (compare multiple snapshots)
- searchChrome DevTools Performance tab > Memory checkbox to record JS heap, documents, nodes, listeners
- searchChrome Task Manager (Shift+Esc) — watch Memory footprint column for the tab
- searchEvent Listeners panel in Elements tab (select a node, see 'Event Listeners')
- searchReact/Angular DevTools — check component tree for orphaned components
- searchSource code: look for document.createElement, addEventListener inside components without corresponding cleanup in useEffect unmount/destroy
- searchThird-party libraries that cache DOM references (e.g., D3, chart libraries) — inspect their internal references
Practical causes, not theory. These are the things you will actually find.
- warningEvent listeners added to a DOM element but never removed before the element is removed from the document
- warningReact refs or Angular ViewChild references that keep a reference to the detached element in a component that outlives the element
- warningClosure in a callback that captures a DOM node (e.g., in setTimeout, requestAnimationFrame, or Promise) preventing GC
- warningThird-party library that stores a reference to a DOM node internally and never releases it on component destroy
- warningjQuery or similar library that caches DOM nodes in an internal array
- warningCircular references between JS objects and DOM nodes (though modern GC handles cycles, weak references are still a risk)
- warningFramework bug: React's ref forwarding or Angular's Renderer2 not cleaning up properly
Concrete fix directions. Pick the one that matches your root cause.
- buildUse WeakRef or WeakMap for DOM node references that should not prevent GC (though browser support varies).
- buildEnsure all event listeners are removed before removing the DOM node (use AbortController or manual removeEventListener).
- buildIn React, clean up refs in useEffect cleanup: set ref.current = null; for class components, null refs in componentWillUnmount.
- buildIn Angular, implement ngOnDestroy to unsubscribe from observables, clear timers, and nullify ViewChild references.
- buildUse the 'Retainers' path in heap snapshot to identify the exact reference holding the node, then remove that reference in the code.
- buildConsider using a DOM mutation observer to detect when nodes are removed and force cleanup if needed.
- buildFor third-party libraries, check if they provide a destroy/dispose method and call it on component unmount.
A fix you cannot prove is a guess. Close the loop.
- verifiedTake a heap snapshot before and after a fix, compare the count of detached nodes — should drop to near zero.
- verifiedUse Performance memory recording: memory should stabilize after several add/remove cycles, not grow unbounded.
- verifiedRun Chrome's 'Memory' panel's 'Record allocation timeline' while interacting — look for nodes that are allocated but never freed.
- verifiedTest on a mobile device or with throttled CPU/ memory to stress the leak — if tab doesn't crash after 50+ add/remove cycles, fix works.
- verifiedUse the 'Detached DOM nodes' built-in tool in Chrome Canary (Experiments) to get a live count.
- verifiedAutomate with Puppeteer: take heap snapshots before and after a loop of add/remove operations, assert detached nodes < threshold.
Things that make this bug worse or harder to find.
- warningAssuming a single heap snapshot is enough — always compare two snapshots (before and after interactions).
- warningRelying only on the Performance memory graph without taking snapshots — it shows memory consumption but not what is leaking.
- warningForgetting that detached nodes can be retained by other detached nodes (a subtree retained by one reference).
- warningThinking that setting innerHTML = '' or removeChild alone is enough — you must also release all JS references.
- warningIgnoring third-party libraries: they might create detached nodes internally that you don't control.
- warningNot testing on the target browser (different GC algorithms may hide leaks in Chrome but appear in Safari).
The Infinite Scroll Tab Crash on Black Friday
Timeline
- 09:15Pager: reports of tab crashes on product listing page after scrolling ~50 items.
- 09:18Check New Relic browser — JS heap size climbing linearly with scroll depth.
- 09:22Chrome DevTools heap snapshot — 2,300+ detached HTMLDivElement nodes.
- 09:25Filter by 'detached' and find a product card component retained by an IntersectionObserver callback.
- 09:28Check code: each product card adds an IntersectionObserver entry, but never calls unobserve.
- 09:32Fix: add useEffect cleanup with .unobserve(ref.current) for each observed product.
- 09:35Deploy fix, take heap snapshot after scrolling 100 items — zero detached nodes.
- 09:38Monitor memory for 10 minutes — stable, no crash.
Black Friday traffic was peaking. Our infinite scroll product listing was the main page, and users on mobile were reporting the tab crashing after scrolling through about 50 items. I opened Chrome DevTools on my machine, replicated the scroll, and watched the memory graph climb like a rocket. A quick heap snapshot revealed over 2,300 detached HTMLDivElement nodes — all product cards that had been removed from the DOM but were still in memory.
I filtered by 'detached' and expanded one of the divs. The retainers path showed a closure in an IntersectionObserver callback. We had implemented intersection-based analytics: every product card that came into view would fire an event. But the code never called .unobserve() on the observed element. Even after the card was scrolled away and removed from the DOM, the observer still held a reference to it, preventing GC. The callback itself captured the card element in its closure.
I added a useEffect cleanup function that stored the element ref and called observer.unobserve(ref.current) when the component unmounted. Also set the ref to null. After deploying, I took another heap snapshot after scrolling through 100 items — zero detached nodes. Memory stabilized. The lesson: any native API that takes a DOM reference, like IntersectionObserver, MutationObserver, or ResizeObserver, must be cleaned up in the component's teardown phase.
Root cause
IntersectionObserver instance held a reference to the observed product card element via its callback closure, and the observer was never disconnected or unobserved when the card was removed from the DOM.
The fix
In the React component's useEffect, store the observer and element ref, and call observer.unobserve(ref.current) in the cleanup function. Also set ref.current = null.
The lesson
Any DOM API that accepts a node reference (observer, event listener, animation) must be explicitly cleaned up in componentWillUnmount or useEffect cleanup. Always check the retainers path in heap snapshots for closure references.
When a DOM node is removed from the document tree via removeChild or replaceChild, the browser flags it as 'detached'. The node is still alive if any JavaScript variable, event listener, or closure references it. The garbage collector traverses the object graph from root (global object, current stack). If a detached node is reachable from root, it stays in memory. The key insight: a single reference to a parent detached node keeps the entire subtree alive. That means one forgotten event listener on a parent div can leak thousands of child elements.
Modern browsers use generational GC and incremental marking, but they cannot collect cyclic references between JS objects and detached DOM nodes if the cycle is reachable from root. However, the real issue is rarely cycles — it's unintended references from closures or global caches. The 'Retainers' path in DevTools shows why the node is alive. For example, a detached node might be retained by a 'system/Context' object, which is retained by a React component instance, which is retained by a fiber, etc. Understanding this chain is critical to fixing the leak.
In React, the most common cause of detached DOM leaks is failing to clean up refs and event listeners in useEffect or componentWillUnmount. When a component unmounts, React removes the DOM nodes, but if a ref (useRef) is still stored somewhere in a parent component or a global store, that ref keeps the node alive. Similarly, if you attach an event listener to window or document from a component, you must remove it on unmount. The React DevTools profiler can show unmounted components still in the component tree — a sign of a leak.
A subtle case: using refs in a closure inside a useEffect dependency array. If the effect runs multiple times, each run creates a new closure capturing the current ref.current. If you don't clean up the previous listener, you accumulate listeners that each reference the old element. Use AbortController to batch cleanup or store the listener function to remove it later. Also, never store DOM nodes in a useState or Redux store — they will keep nodes alive indefinitely.
To effectively find detached DOM leaks, you must compare snapshots. Take a baseline snapshot after initial load. Then perform the action that might cause a leak (e.g., open and close a modal 10 times). Force a garbage collector (the trash icon in the Memory tab) and take a second snapshot. Switch to the 'Comparison' view and filter by 'Detached'. The delta column shows how many new detached nodes were created and not freed. Focus on the ones with a high delta.
Expand a detached node and look at the 'Retainers' section. It shows the shortest path from a GC root to the node. Common retainers: 'window', 'document', 'system/Context', React fiber objects, or a module-level variable. If you see 'system/Context' followed by a React component, that's a clue that a React component is holding a reference. Double-click the retainer to see the actual JS object. Use the search bar to find the variable name. This is the fastest way to pinpoint the exact line of code that needs fixing.
Angular applications are prone to detached DOM leaks if subscriptions, timers, or ViewChild references are not cleaned up. The Angular compiler creates a direct reference from the component instance to its template elements. If you store a ViewChild reference in a service or a parent component, that reference prevents the child component's DOM from being garbage collected. Always use the @ViewChild decorator with the 'static' option correctly, and nullify the reference in ngOnDestroy if it's stored elsewhere.
For event listeners added via Renderer2.listen, Angular provides a return function (a listener removal function). Store it and call it in ngOnDestroy. Similarly, any Subscription from HttpClient or store must be unsubscribed. A practical tip: use the 'ngx-take-until-destroy' library or the async pipe to auto-unsubscribe. For third-party libraries that manipulate the DOM, call their destroy method in ngOnDestroy. The Angular DevTools profiler can help detect components that are not destroyed after navigation.
Catching detached DOM leaks before they reach production requires automated tests. Use Puppeteer or Playwright to open a page, perform a series of operations (e.g., open/close modal 20 times), take a heap snapshot, and assert that the number of detached nodes is below a threshold. You can also use the 'performance.memory' API (Chrome only) to monitor heap size. A more reliable approach: write a custom test that forces GC with global.gc() (requires --expose-gc flag) and then checks the heap snapshot for detached nodes.
Another method: use Lighthouse performance audits in CI. Lighthouse includes a 'detached-dom-nodes' audit that flags excessive detached nodes. Set a budget for maximum detached nodes (e.g., < 100). Integrate this into your CI pipeline to block PRs that introduce leaks. Finally, consider using the 'detached-dom' Chrome extension or the built-in 'Detached DOM nodes' experiment in Chrome Canary for real-time monitoring during manual testing.
Frequently asked questions
How can I tell if a detached DOM node is actually causing a memory leak or just temporary?
Take a heap snapshot, then force garbage collection, then take another snapshot. If the detached nodes are still there after GC, they are leaking. Temporary detached nodes are cleaned up by GC shortly after removal. Also, if the count of detached nodes increases with each action and never decreases, it's a leak.
Can event listeners on detached nodes cause memory leaks even if the listener is on a parent that is still in the DOM?
Yes. An event listener on a parent element that references a child (e.g., via event.target) can keep the child alive if the listener's closure captures the child. However, the most common leak is a listener directly attached to the detached node. Use event delegation on a stable ancestor to avoid attaching listeners to dynamic elements.
Does the React key attribute help prevent detached DOM leaks?
No, the key attribute is for reconciliation, not memory management. If you have a leak, changing keys won't fix it. The cause is always a retained reference in JavaScript. Use keys to help React identify which components changed, but clean up references in useEffect or componentWillUnmount.
Why do I see detached nodes even when I use React and never directly manipulate the DOM?
React itself creates and removes DOM nodes. If you store a ref to a DOM node (useRef), attach event listeners via addEventListener, or use third-party libraries that cache DOM references, those references keep the nodes alive even after React unmounts the component. Always clean up refs and listeners in the component's cleanup phase.
What is the difference between a detached DOM node and a zombie component in React?
A detached DOM node is an HTML element removed from the document but still referenced in JS. A zombie component is a React component instance that remains in memory after its DOM was removed, often because its state or subscriptions are still active. Zombie components often hold references to detached nodes, so they often appear together. Fixing the zombie component (by cleaning up subscriptions) can also fix the detached nodes.