In a browser application, what is a "detached DOM tree", and how can one variable still pointing at a single removed node keep megabytes of nodes alive?
answer
- removed from the document, not from memory
- JavaScript still holds one node
- nodes reference parents and children both ways
- one cell retains the whole table
- filter a snapshot for Detached
basics
~20 sA detached DOM tree is a group of nodes removed from the document but still referenced from JavaScript, so the browser cannot free them. Because every node references its parent and every parent its children, holding one node keeps its entire former tree in memory.
solid answer
~50 sRemoving a node from the document does not free it — it only unlinks it from the live document tree. The nodes still exist as objects, and if any JavaScript variable, array, `Map`, closure or pending callback still points at one of them, the whole cluster stays on the heap. That cluster is what a heap snapshot labels *detached*. The amplification comes from the DOM's bidirectional links: a node holds `parentNode`, and a parent holds all of its children. So a single reference to one deeply nested `<td>` walks up to the table root and back down over every row and cell — thousands of nodes, plus their attributes, event listeners and any JavaScript data hanging off them. Typical culprits are caches of "the element I measured last", a module-level array of rows, or a callback queued before the removal that still holds the node.
code
javascript · 11 linesconst root = document.createElement('div');
const child = document.createElement('span');
child.textContent = 'still here';
root.appendChild(child);
// Keep a reference to the child only, and drop the container:
const kept = child;
// The parent cannot be collected: the child points back at it.
console.log(kept.parentNode === root); // true
console.log(kept.parentNode.childNodes.length); // 1 - and every sibling with itgo deeper
Know that taking a node out of the page does not delete it, and that a variable still pointing at it keeps it in memory. Say that clearing the reference is what allows it to be freed.
Explain the bidirectional graph: parentNode and children are both real references, so retaining any node retains its whole former tree. Name the common holders — a cached last element, a module-level array, an element used as a Map key.
Show the diagnosis: repeat the interaction, snapshot after a forced collection, filter for detached roots, sort by retained size and read the retainers path to name the exact variable. Then argue for the structural fix over a defensive null assignment.
Frame it as an ownership problem: decide which layer is allowed to hold element references at all, push lookups toward WeakMap or id-based re-query, and set the policy for when detached-tree growth becomes a shipping blocker.
## What "detached" actually means DOM nodes are ordinary heap objects with a special property: while a node is part of the document, it is reachable from a garbage-collection root, because the document itself is a root. Calling `remove()`, reassigning `innerHTML`, or replacing a container's children does not destroy anything. It severs the link between the subtree and the document. The nodes are now *detached*: still perfectly valid objects, still holding their attributes, styles, listeners and any expando properties, but no longer reachable through the document. If nothing else points at them, that is the end of the story — the whole subtree becomes unreachable in one step and is collected. A leak happens only when something else *does* point at them. That surviving reference is what browser tooling reports as a **detached DOM tree**: nodes not in the document, but not collectable either. ## Why one reference retains so much The DOM graph is bidirectional. Every node exposes `parentNode` (and `parentElement`), and every element exposes `childNodes` / `children`. Reachability follows both directions, because they are real references in the object graph. So suppose you kept a reference to one `<td>` because you measured it once: ```js let lastMeasured = null; function measure(cell) { lastMeasured = cell; // one innocent-looking assignment return cell.getBoundingClientRect().width; } ``` The collector walks `lastMeasured` to the cell, then up through `parentNode` to the row, the section, the table, and any wrapper elements above it. Having reached the table, it walks *down* through `children` into every other row and every other cell — including the ten thousand you never touched. One pointer, the entire table, plus every listener attached to those nodes and every object those listeners can reach. This is why detached trees are usually the largest single item in a leaking snapshot: the retained size is enormously larger than the shallow size of the thing you actually kept. ## The usual sources - **A cache of "the last element".** The example above: last selected row, last hovered tooltip anchor, last focused field. - **Module-scope collections.** `const rendered = []` that setup pushes nodes into and teardown never trims. - **A closure held by something long-lived.** A handler registered on `window`, a pending timer callback, or a queued promise reaction that captured a node before it was removed. - **Third-party libraries** that keep an internal registry of elements and expect you to call their `destroy()`. - **Keys of a strong `Map`.** Using elements as `Map` keys pins them permanently. ## Finding them In Chromium DevTools, take a heap snapshot from the Memory panel (the snapshot forces a collection first, so anything you see genuinely survived one) and filter the summary view for `Detached`. Detached nodes appear grouped, with a **retained size** column that tells you how much memory freeing that root would actually reclaim — the number that matters, as opposed to shallow size, which is just the node object itself. Select one and read the **Retainers** pane: it shows the reference path back to a GC root, which names the variable, array or closure that is doing the retaining. That path is the bug report; the rest is a one-line fix. The most reliable way to make them visible is repetition. Detached trees appear in every snapshot of a busy app, and a single snapshot cannot tell a transient from a leak. Enter and leave the offending view ten times before snapshotting; a genuine leak shows roughly ten copies of the same detached root. ## The fixes Stop holding the reference — null it out in teardown, or restructure so you never store a node at all (store the value you measured, or an id you can re-query, instead of the element). Where a lookup table genuinely must be keyed by element, use a `WeakMap` so the entry does not pin the key. And prefer removing whole containers over surgically detaching inner nodes you also happen to have cached: the fewer places that hold element references, the fewer places a detached tree can hide. ## The mental correction most candidates need `element.remove()` is not `free()`. It is an unlink. Memory is released by reachability, and the DOM is a graph in which every node can reach every other node in its tree — which is why "I only kept one small element" and "I leaked ten megabytes" are routinely the same sentence.
- How do you tell a real detached-tree leak from a transient one in a heap snapshot?By repetition rather than by a single reading. Perform the same round trip — open the view, leave it — a fixed number of times, then snapshot; a transient shows up once or not at all, while a leak shows roughly one detached root per iteration. Comparing two snapshots taken after equal numbers of cycles makes the per-cycle growth obvious.
- Why does a snapshot show a much larger retained size than shallow size for a detached node?Shallow size is the memory of that one object; retained size is everything that would be freed if it became unreachable. For a detached root that includes the whole subtree reachable through its children, plus attributes, listeners and any JavaScript objects those listeners reference. Retained size is what you act on — it estimates the actual win from fixing the reference.
- Does setting innerHTML to an empty string guarantee the old nodes are freed?No. It detaches them from the document, which is enough only if nothing else refers to them. Any node you had cached in a variable, stored in an array, used as a Map key, or captured in a live callback keeps its entire former tree alive exactly as before. The fix is dropping those references, not the way you clear the container.
saying these in an interview costs you the question
- Says element.remove() frees the node's memory
- Thinks a node without a parent cannot retain anything
- Believes clearing innerHTML guarantees collection
- Assumes detached trees in a snapshot always mean a leak
- Confuses shallow size with retained size when judging impact