TL;DR / Detached DOM nodes are elements removed from the document tree that remain in memory because JavaScript still holds a reference to them, causing memory leaks that grow with user interaction.
How It Works
┌────────────────┐ ┌──────────────────────┐
│ Live DOM │ │ JS References │
│ <div> │ │ const ref = elem │
│ <ul> │ │ closures, listeners │
│ <li>... │ └──────────────────────┘
└────────────────┘ │
│ │
│ removeChild() still holds ref │
└──────────────┌─────────────────┘
│
│
↓
┌──────────────────┐
│ Detached <div> │
└──────────────────┘
GC cannot collect!
When you call removeChild(), replaceChild(), or set innerHTML on a parent, the removed elements are disconnected from the document tree. The browser's rendering engine no longer needs to paint or lay them out. But the garbage collector cannot reclaim their memory if any JavaScript object still holds a reference to them. The detached subtree -- the removed node plus all its descendants -- stays in heap memory indefinitely.
How References Accumulate
The most direct case is storing a DOM reference in a variable or data structure: const row = document.getElementById('row-5'). If you later remove that row from the table but never null out the variable, the entire row element (and all its children, text nodes, and attributes) stays in memory.
Closures are more insidious. An event handler attached to a parent might close over a reference to a child element. When the child is removed from the DOM, the closure in the parent's event handler still references it. The child is detached but retained. This pattern is especially common in component-based architectures where event handlers are defined inline with references to sibling components.
Event listeners on the removed node itself also retain it. Modern browsers are better about cleaning up listeners when nodes are garbage collected, but the node cannot be garbage collected in the first place if something else references it. Additionally, if the detached node has event listeners that close over large data structures or other DOM nodes, the retained memory graph can be much larger than the visible DOM subtree suggests.
The Subtree Problem
Detaching a single parent node retains the entire subtree. Removing a <table> element with 10,000 rows means all 10,000 <tr> elements, their <td> children, and all text nodes remain in memory if any single reference exists to the table or any of its descendants. This is why memory leaks from detached DOM nodes tend to grow proportionally to the complexity of the removed UI.
Single-page applications are particularly vulnerable. When navigating between views, the outgoing view's DOM is removed, but if the router, a global store, or an analytics library holds references to elements in that view, the entire view's DOM tree persists. Over many navigation cycles, memory usage climbs steadily.
Detection with DevTools
Chrome DevTools' Memory panel provides two ways to find detached DOM nodes. The Heap Snapshot feature lets you filter by "Detached" in the class filter -- search for "Detached HTMLDivElement" or similar. Each result shows the retaining path: the chain of references from a GC root (window, closures, event listeners) to the detached node. This retaining path tells you exactly what code is keeping the node alive.
The Allocation Timeline records heap allocations over time. If you take an allocation timeline while navigating between views, you can see if DOM node allocations from previous views persist. A healthy SPA should show DOM allocations being collected after navigation; persistent bars indicate leaks.
Prevention Patterns
The core principle is: when you remove DOM, remove all references to it. In React, this happens automatically for refs and state when components unmount, but class component patterns like storing DOM refs in instance properties (this.tableRef) without cleanup in componentWillUnmount create leaks. Functional components with useRef are cleaned up automatically when the component unmounts, but useEffect closures that capture DOM refs and forget to return a cleanup function will retain those refs.
For vanilla JavaScript, the WeakRef and WeakMap APIs are the structural solution. A WeakRef to a DOM node does not prevent garbage collection. Storing DOM nodes as keys in a WeakMap (rather than values in a Map) means the entry is automatically removed when the node is collected. For caches or lookup tables indexed by DOM elements, WeakMap should be the default choice.
MutationObserver with childList: true on a parent can detect when children are removed, providing a hook to clean up associated data. This is how some frameworks implement automatic cleanup for directives and bindings.
Gotchas
- Removing a node from the DOM does not free it -- any remaining JavaScript reference (variable, closure, event listener, Map entry, array element) prevents garbage collection of the node and its entire subtree.
- Event listeners on detached nodes are not automatically removed -- the node must first be garbage-collectable, but it cannot be collected if the listener itself (or something referencing the listener) keeps a reference to it. This is a circular retention.
innerHTML = ''removes children from the tree but does not null out external references -- if you storedchildren[0]in a variable before clearing, that child is now detached but retained.- Framework cleanup only works if you use the framework's patterns -- storing DOM refs in module-level variables, global arrays, or third-party library registrations bypasses component lifecycle cleanup entirely.
WeakRefdoes not guarantee timely collection -- the garbage collector runs on its own schedule. A detached node behind aWeakRefmay persist for an indeterminate time. Do not rely onWeakReffor deterministic cleanup.