Detached DOM Nodes
What detached DOM nodes are, how they accumulate in SPAs, the six patterns that create them — module-level variables, event listener closures, uncleaned React effects, observer callbacks, Map-keyed caches, and subtree retention — and how to find and fix each one.
Overview
A detached DOM node is a node that has been removed from the document tree but is still referenced in JavaScript memory. Because something holds a reference to it, the garbage collector cannot free it — causing a memory leak.
This matters because leaked nodes accumulate silently. In long-lived SPAs, dashboards, and rich editors, hundreds of leaked nodes can balloon memory usage, degrade performance, and eventually crash browser tabs.
How It Works
When you call element.remove() or parent.removeChild(child), the node is removed from the live DOM. If no JavaScript reference points to it, the GC collects it. If a reference still exists — in a closure, an event listener, a module-level variable, a Map, or a React ref — the node stays alive in memory, disconnected from the document.
Document Tree JavaScript Memory
───────────── ─────────────────
<body> let leak = detachedDiv ← still reachable
(no div here) detachedDiv { ... } ← not collected by GCCritical subtlety — subtree retention: When you hold a reference to a parent node, its entire subtree is retained. A reference to a <div> that contains 200 child nodes keeps all 200 nodes in memory. The retained size (visible in the DevTools retainer panel) can be orders of magnitude larger than the shallow size of the root reference.
Chrome DevTools labels these nodes as Detached <TagName> in heap snapshots, making them easy to spot once you know where to look.
Code Examples
Pattern 1: Module-Level Variable
// ❌ Module scope persists for the page lifetime — node is never released
let cachedBanner: HTMLDivElement | null = null;
function showBanner(message: string) {
const banner = document.createElement("div");
banner.textContent = message;
document.body.appendChild(banner);
cachedBanner = banner;
setTimeout(() => {
banner.remove(); // removed from DOM...
// cachedBanner still holds the reference — LEAK
}, 3000);
}
// ✅ Null the reference when no longer needed
function showBannerFixed(message: string) {
const banner = document.createElement("div");
banner.textContent = message;
document.body.appendChild(banner);
cachedBanner = banner;
setTimeout(() => {
banner.remove();
cachedBanner = null; // release — GC can now collect it
}, 3000);
}Pattern 2: Event Listener Closure
An event listener bound to a node keeps both the node and everything in the listener's closure scope alive:
// ❌ Closure captures `entries` array and `container`
// Removing the node from the DOM without removing the listener keeps both alive
function attachLogger(container: HTMLDivElement) {
const entries: string[] = [];
container.addEventListener("click", (e) => {
entries.push((e.target as HTMLElement).textContent ?? "");
});
container.remove(); // detached — but listener (and entries) still alive
}
// ✅ Remove the listener before or after removing the node
function attachLoggerFixed(container: HTMLDivElement) {
const entries: string[] = [];
const handleClick = (e: Event) => {
entries.push((e.target as HTMLElement).textContent ?? "");
};
container.addEventListener("click", handleClick);
// Remove listener first, then the node
container.removeEventListener("click", handleClick);
container.remove();
// Both container and entries are now eligible for GC
}
// ✅ Alternative: use AbortController for cleaner multi-listener cleanup
function attachLoggerAbort(container: HTMLDivElement) {
const controller = new AbortController();
const entries: string[] = [];
container.addEventListener(
"click",
(e) => {
entries.push((e.target as HTMLElement).textContent ?? "");
},
{ signal: controller.signal }, // auto-removed when aborted
);
function cleanup() {
controller.abort(); // removes ALL listeners registered with this signal
container.remove();
}
return cleanup;
}Pattern 3: Uncleaned React Effect
// components/PollingWidget.tsx
"use client";
import { useEffect, useRef } from "react";
export default function PollingWidget() {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Capture the node value synchronously — ref.current may be null after unmount
const node = containerRef.current;
if (!node) return;
const intervalId = setInterval(() => {
// ❌ Without cleanup: interval keeps running after unmount
// `node` is captured in the closure — now a detached node retained by the interval
node.textContent = `Updated at ${Date.now()}`;
}, 1000);
// ✅ Clear interval — drops the closure reference, allows GC of `node`
return () => clearInterval(intervalId);
}, []);
return <div ref={containerRef} />;
}Pattern 4: Observer Callbacks Outliving Elements
ResizeObserver, IntersectionObserver, and MutationObserver hold references to observed elements via their internal target lists. Always disconnect:
// sidebar/PanelManager.ts
// ❌ Observers stored in a Map — never disconnected on panel unmount
const observers = new Map<string, ResizeObserver>();
function mountPanel(id: string, el: HTMLElement) {
const ro = new ResizeObserver((entries) => {
console.log(`${id} resized to`, entries[0].contentRect.width);
});
ro.observe(el);
observers.set(id, ro);
}
function unmountPanelBad(id: string, el: HTMLElement) {
el.remove();
// ❌ Observer is still watching `el` — `el` is now detached but retained
}
// ✅ Disconnect the observer and delete the Map entry on unmount
function unmountPanel(id: string, el: HTMLElement) {
el.remove();
const ro = observers.get(id);
if (ro) {
ro.disconnect(); // releases the internal reference to `el`
observers.delete(id); // allows the observer itself to be GC'd
}
}Pattern 5: Map vs WeakMap for Element-Keyed Caches
// ❌ Map holds a strong reference to the element key
// Even after the element is removed from the DOM, the Map prevents GC
const tooltipData = new Map<HTMLElement, string>();
function attachTooltip(el: HTMLElement, text: string) {
tooltipData.set(el, text);
el.addEventListener("mouseenter", () => {
showTooltip(tooltipData.get(el) ?? "");
});
}
// ❌ Without explicit tooltipData.delete(el), the entry and element leak forever
// ✅ WeakMap — entry is automatically released when the element is GC'd
// No manual cleanup needed, no risk of forgetting to delete
const tooltipDataWeak = new WeakMap<HTMLElement, string>();
function attachTooltipFixed(el: HTMLElement, text: string) {
tooltipDataWeak.set(el, text);
el.addEventListener("mouseenter", () => {
showTooltip(tooltipDataWeak.get(el) ?? "");
});
}Pattern 6: Subtree Retention
A reference to any ancestor retains its entire DOM subtree:
// ❌ This table has 1,000 rows — all retained by a reference to the container
let leakedTable: HTMLTableElement | null =
document.querySelector("#data-table");
function refreshTable() {
leakedTable?.remove(); // detached from DOM...
leakedTable = buildNewTable(); // ...but old reference still holds 1,000 rows
document.body.appendChild(leakedTable);
}
// ✅ Null the old reference before reassigning
function refreshTableFixed() {
leakedTable?.remove();
leakedTable = null; // release the entire old subtree
leakedTable = buildNewTable(); // fresh reference to new table only
document.body.appendChild(leakedTable);
}Finding Detached Nodes in Chrome DevTools
1. Open DevTools → Memory → Heap snapshot → Take snapshot (baseline)
2. Perform the leaking action (open/close a modal, navigate a route, etc.)
3. Force GC: click the 🗑 "Collect garbage" icon
4. Take a second heap snapshot
5. In the second snapshot, type "Detached" in the class filter input
→ Shows all Detached HTMLDivElement, Detached HTMLButtonElement, etc.
6. Click a detached class → expand an instance
7. In the Retainers panel (bottom half):
- Follow the reference chain upward to find what holds this node alive
- Common culprits: "closure", "system / Context", a named variable in a module
8. The retainer path ends at a GC root (window, a module, a closure)
— that root's variable is where you need to add cleanupA small object with a very large Retained Size in the heap snapshot is a high-value target. Retained size includes everything only reachable through this object — a single reference to a <div> containing a complex subtree may show a retained size of megabytes.
Real-World Use Case
A dashboard app with a dynamic sidebar — panels are mounted and unmounted as users navigate. Each panel registers a ResizeObserver. Without disconnect() on unmount, each navigation cycle leaks one panel element (plus its entire child subtree) retained by its observer. After 20 navigation cycles: 20 detached panel subtrees. Heap snapshot shows Detached HTMLDivElement with high retained size. Retainer path: ResizeObserver → target list → panel div. Fix: call ro.disconnect() in the React useEffect cleanup. Second snapshot: zero detached panels.
Common Mistakes / Gotchas
1. Not clearing module-level DOM references. Module scope is a permanent GC root. Any node assigned to a module variable and then removed from the DOM lives for the page's lifetime unless explicitly set to null.
2. Inline arrow functions make listener removal impossible. removeEventListener requires the exact same function reference. Use AbortController with { signal } as the modern alternative — a single abort() removes all associated listeners.
3. Forgetting that subtrees are retained wholesale. A reference to a <section> retains every <p>, <img>, and <span> inside it. The shallow size of the root reference is tiny; the retained size may be enormous.
4. Leaving observers connected on unmount. ResizeObserver, IntersectionObserver, and MutationObserver all maintain internal references to their target elements. Always call disconnect() in effect cleanup.
5. Using Map<HTMLElement, ...> for element-keyed caches. Strong key references prevent collection. Use WeakMap<HTMLElement, ...> so entries are automatically released when elements are collected.
Summary
Detached DOM nodes are elements removed from the document but still referenced in JavaScript, preventing garbage collection. Six patterns create them: module-level variables holding removed nodes, event listener closures never cleaned up, React effects without cleanup returns, observer callbacks that outlive their targets, Map-keyed caches with strong element references, and ancestor references that retain entire subtrees. Find them in Chrome DevTools by filtering heap snapshots for "Detached" — the Retainers panel reveals exactly what holds the node alive. Fix each archetype: null module references after removal, use AbortController for listener cleanup, return cleanup functions from useEffect, call disconnect() on observers, replace Map<HTMLElement, ...> with WeakMap<HTMLElement, ...>.
Interview Questions
Q1. What exactly is a detached DOM node and why can't the garbage collector collect it?
A detached DOM node is an element that has been removed from the live document tree (via remove(), removeChild(), or innerHTML = "") but still has at least one live JavaScript reference pointing to it. The garbage collector uses mark-and-sweep reachability — it starts from GC roots (window, module-level variables, active stack frames) and marks everything reachable. As long as any reference chain from a root reaches the node, the GC marks it as live and cannot collect it, regardless of whether it's in the document. The node is correctly kept alive from the GC's perspective; the bug is the unintentional reference. The node shows up in DevTools heap snapshots as Detached HTMLDivElement (or similar) because the DevTools inspector can tell the node is not connected to the document, even though it's still in the JavaScript heap.
Q2. Why does a reference to a parent node retain its entire descendant subtree?
Each DOM node has childNodes, firstChild, lastChild, parentNode, and nextSibling references — a doubly-linked tree structure. When you hold a reference to a parent element, the GC marks the parent as live, then follows all its outgoing references: childNodes → each child → each grandchild, recursively. The entire reachable subtree is marked live. A <section> with 1,000 product cards inside it, if held in a module-level variable after being removed from the document, retains all 1,000 cards and their text nodes, images, event listeners, and associated data. The retained size in a heap snapshot for the root reference reflects this total — it can be megabytes for a complex subtree even though the root reference itself is tiny in shallow size.
Q3. How does AbortController solve the problem of removing event listeners from detached nodes more cleanly than removeEventListener?
removeEventListener requires the exact same function reference as was passed to addEventListener. This means you must store every listener reference explicitly, which becomes error-prone with multiple listeners. AbortController provides an AbortSignal that can be passed as { signal } in addEventListener. When controller.abort() is called, the browser automatically removes every listener registered with that signal — regardless of how many there are or whether you stored references. A single abort() call in a cleanup function handles all listeners atomically. This is compositional: you can pass the same signal to fetch() (cancels the request), multiple addEventListener calls (removes all listeners), and custom cleanup code (signal.addEventListener("abort", cleanup)).
Q4. What is the difference between shallow size and retained size in a heap snapshot, and which is more useful for finding leak impact?
Shallow size is the memory the object itself occupies — for an HTMLDivElement JS wrapper, this is typically a few hundred bytes for the binding properties. Retained size is the memory that would be freed if this object were garbage collected — it includes everything reachable only through this object (objects with no other path to a GC root). For a detached parent node with a complex subtree, shallow size might be 200 bytes while retained size is 8MB (all the child nodes, their text nodes, data associated via closures, etc.). Retained size is the useful metric for prioritising leak fixes — a single detached node with 8MB retained size is a far higher-value fix than 100 detached nodes with 1KB retained each.
Q5. Why should Map<HTMLElement, V> be replaced with WeakMap<HTMLElement, V> for element-keyed caches, and what are WeakMap's limitations?
Map holds strong references to its keys. An element used as a Map key is reachable through the Map, which is reachable through the module-level variable holding the Map. After the element is removed from the DOM, the Map still prevents its collection indefinitely unless map.delete(element) is called explicitly — which is easy to forget. WeakMap holds weak references to keys — the key is not considered reachable through the WeakMap for GC purposes. When no other strong reference to the element exists after DOM removal, both the element and its WeakMap entry are automatically collected with no manual cleanup required. WeakMap limitations: keys must be objects (not primitives); it is not iterable (no .keys(), .values(), .entries(), .forEach()); there is no .size property. These limitations exist precisely because entries may disappear at any GC cycle — enumeration would produce inconsistent results.
Q6. In a React SPA, what is the most common sequence of events that creates a detached DOM node, and how does the useEffect cleanup function prevent it?
The sequence: (1) Component mounts — useEffect fires, attaches a setInterval, addEventListener, or ResizeObserver that captures a reference to a DOM node (via ref.current or document.querySelector). (2) User navigates — React unmounts the component, removing its DOM from the document. (3) No cleanup function was returned from useEffect — React has nothing to call. (4) The timer/listener/observer continues running, holding the closure reference to the now-detached DOM node. (5) The node cannot be collected. Multiplied across navigations: each mount cycle adds another unreleased reference. The useEffect cleanup function is called by React immediately before unmounting (and before re-running the effect if deps change). Returning () => clearInterval(id) or () => observer.disconnect() from useEffect breaks the reference chain at unmount time, allowing the node to be collected as soon as React removes it from the document.
Memory Leak Detection
How to identify, diagnose, and fix browser memory leaks — mark-and-sweep reachability, WeakMap vs Map for caches, WeakRef for optional references, the three-snapshot method, Allocation Timeline, AbortController for cleanup patterns, and the five leak archetypes.
Memory Profiling
A practical walkthrough of Chrome DevTools memory tooling — Heap Snapshot (Summary vs Comparison vs Containment vs Dominators), Allocation Timeline, Allocation Sampling, the Retainer tree, shallow vs retained size, performance.measureUserAgentSpecificMemory(), and a step-by-step debugging workflow.