Finding Detached DOM Nodes in Heap Snapshots Fast
The tab has been open for twenty minutes, scrolling is sluggish, and document.querySelectorAll('*').length looks fine — but the heap keeps climbing, which almost always means detached DOM nodes are piling up somewhere inside a workflow covered by browser DevTools performance profiling. This page skips the conceptual background and gets you straight to the filter, the sort, and the retainer walk that names the exact line of code holding the memory.
Symptom-to-Fix Diagnostic Matrix
| Symptom | Root Cause | Immediate Action |
|---|---|---|
| Heap grows after every route change, never shrinks | Detached subtree kept alive by a JS reference | Filter constructor list for Detached, sort by Retained Size |
Detached HTMLDivElement entries with 0 retainers shown |
Snapshot taken mid-collection, node already unreachable | Force GC first, then re-snapshot before inspecting |
Retainers pane shows only (closure) and system rows |
Retainer chain not fully expanded | Click the expand arrow on each row until a yellow user frame appears |
Multiple Detached rows share one huge Retained Size |
One shared array/cache holds many nodes | Inspect the shared object’s property list, not each node individually |
| Retained Size stays non-zero after code fix | A second, weaker retainer still exists | Re-run Retainers walk on same node after forcing GC again |
| Filter box shows nothing despite visible UI lag | Nodes still attached but referenced by growing arrays | Search for large Array/Map constructors sorted by Retained Size instead |
Root Cause
A DOM node becomes detached the instant removeChild(), replaceChildren(), or a framework’s unmount routine severs it from the render tree — but severing the render-tree link does nothing to the JavaScript heap. As explained in the parent guide on detached DOM nodes and memory retention, V8’s tracer only collects an object once no path exists from a GC root to it. If a closure, a module-level array, a Map, or a stale event-listener registry still points at the element’s JS wrapper, the wrapper — and everything it in turn references, including child nodes, attached data, and any objects those hold — stays resident.
The reason this is slow to diagnose by eye is that a detached subtree is invisible to document.body.contains() and to the Elements panel; it produces no DOM query hits and no visual artefact. The only place it shows up is a heap snapshot, where V8 labels it with a constructor name prefixed Detached (for example Detached HTMLUListElement). Because a single detached root can hold hundreds of descendant nodes, the fastest diagnostic move is never to scroll through the raw object list — it’s to filter for that exact prefix, then sort by Retained Size rather than Shallow Size. Shallow size only counts the node’s own fields; retained size counts everything that would be freed if this one object were removed, which is what actually matters for the leak.
Once you have the heaviest detached row, the Retainers pane (bottom-left of the Memory panel when a row is selected) shows the reverse-reference chain: who points at this object, who points at that, all the way up to a GC root such as Window or a (global) entry. DevTools renders retainer rows for genuine user-defined objects — component instances, closures your code created, caches you defined — in yellow, and internal V8 bookkeeping frames like system / Context or (closure) in grey. The yellow row nearest the detached node is almost always the one to fix.
The diagram below shows this exact triage path end to end.
Step-by-Step Fix
Step 1 — Capture the snapshot right after the leak fires
Reproduce the mount/unmount cycle you suspect first — open the modal, close it; navigate to the route, navigate away.
DevTools → Memory → Heap snapshot → Take snapshot
Expected output: A new snapshot appears in the left rail labelled Snapshot 1 with a summary view showing constructor groups and their aggregated shallow/retained sizes.
Step 2 — Force GC, then take a second snapshot
Click the trash-can icon (top of the Memory panel) to run a full collection before capturing again — this removes anything already unreachable so only genuinely retained detached nodes remain.
DevTools → Memory → 🗑 (Collect garbage) → Take snapshot
Verification checkpoint: The second snapshot’s total heap size should be smaller than the first if any short-lived garbage existed; whatever remains in the Detached group after this point is a true retention candidate, not collection lag.
Step 3 — Filter the constructor list for Detached
In the Summary view of the newest snapshot, type into the class filter field just above the constructor table.
Class filter field → type: Detached
Expected output: The list narrows to rows like Detached HTMLDivElement, Detached Text, Detached HTMLUListElement — each with its own count, shallow size, and retained size columns.
Step 4 — Sort by Retained Size, descending
Click the Retained Size column header. Click it again if it sorted ascending first.
Expected output: The heaviest detached constructor group is now row one. Expand it (▶) to see individual instances, each also sortable by retained size within the group.
Verification checkpoint: If retained size and shallow size are nearly identical for a node, the leak is isolated to that node alone. If retained size is dramatically larger, the node is the root of a large orphaned subtree — worth fixing first for the biggest win.
Step 5 — Expand Retainers to the GC root
Select the heaviest instance. In the bottom pane, open Retainers and expand each row’s disclosure triangle upward.
Retainers pane → expand each row → stop at
first yellow (user-code) frame before a root
Expected output: A chain like Detached HTMLDivElement → (closure) handleScroll → cachedNodes @1234 → Window. Grey rows ((closure), system / Context) are V8 scaffolding; the yellow row — here cachedNodes — is the object your code controls.
Verification checkpoint: If you see the property name and an @ object id next to a yellow row, that is the exact array, Map, or module-level variable to trace back in source.
Step 6 — Fix the reference and re-verify
Remove the entry from the identified array/Map/closure (see code reference below), reload, repeat the same mount/unmount interaction, force GC, and take a third snapshot.
Expected output: Filtering for Detached again either returns zero rows for that constructor, or shows Retained Size at 0 bytes for the previously heavy instance.
Command & Code Reference
A stale module-level cache is the most common holder found at the yellow retainer frame — here’s the leak and the fix.
// Anti-pattern: a module-level Map keyed by a numeric id
// keeps a strong reference to the DOM node long after the
// component unmounts.
const rowCache = new Map(); // lives for the module's life
function renderRow(id, el) {
rowCache.set(id, el); // el retained even after removeChild
}
function removeRow(id) {
const el = rowCache.get(id);
el.remove(); // detaches el from the DOM tree only
// rowCache still holds `el` — the yellow retainer frame
}
// Fix: delete the cache entry at the same point the node is
// removed, or use a WeakMap so entries drop automatically
// once nothing else strongly references the key.
const rowCache = new WeakMap(); // collectible once el drops
function renderRow(id, el) {
rowCache.set(el, id); // keyed by the node, not a numeric id
}
function removeRow(id, el) {
el.remove(); // detaches from the render tree
rowCache.delete(el); // explicit delete, deterministic
// even without delete(), Map->WeakMap prevents retention
}
# Automate the check headlessly with chrome-launcher + CDP:
# dump a heap snapshot after a scripted interaction and grep
# for Detached counts. Requires remote debugging enabled:
chrome --remote-debugging-port=9222 --headless=new
# Then drive Runtime.evaluate + HeapProfiler.takeHeapSnapshot
# over the CDP websocket from your test harness; parse the
# .heapsnapshot JSON for names starting with "Detached ".
Verification & Regression Prevention
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
Detached rows persist after fix |
Cache/closure not cleared on unmount | Swap Map to WeakMap or add explicit delete() |
Detached count → 0 rows |
| Retained Size shrinks but not to 0 | Secondary retainer (listener, observer) | Re-run Retainers walk after second GC | Retained Size → 0 bytes |
| Detached count grows per route change | No cleanup hook on unmount lifecycle | Add teardown in useEffect cleanup or beforeUnmount |
Flat count across 10 route changes |
| Heap trends upward over a long session | Slow leak invisible in single snapshot | Compare 3 snapshots 60s apart via Comparison view | Delta between snapshots < 5 MB |
Set a target of zero Detached constructor rows surviving two forced-GC cycles after any unmount interaction, and keep long-session heap growth under roughly 5 MB per 60 seconds of steady-state use as a monitoring threshold. Wire this into CI with a scripted Puppeteer check:
// Puppeteer regression check: fails CI if any Detached node
// survives two GC passes after the mount/unmount test case.
const client = await page.target().createCDPSession();
await client.send('HeapProfiler.enable');
await client.send('HeapProfiler.collectGarbage'); // 1st GC
await page.click('#open-modal');
await page.click('#close-modal'); // interaction under test
await client.send('HeapProfiler.collectGarbage'); // 2nd GC
const { profile } = await client.send(
'HeapProfiler.takeHeapSnapshot', {}
);
const detachedCount = countDetachedNodes(profile); // parser
if (detachedCount > 0) {
throw new Error(
`CI regression: ${detachedCount} nodes retained`
);
}
For the wider workflow this check plugs into, see taking and comparing heap snapshots step by step and the broader approach to interpreting heap snapshots for memory analysis generally.
Frequently Asked Questions
Why does the class filter show no results even though I know nodes are detached?
The filter is case-sensitive and matches the constructor name exactly as V8 labels it, which is Detached <TagName> with a capital D. If you typed a lowercase detached in older Chrome builds it still matches, but if the subtree hasn’t been orphaned yet — the removal happened after your snapshot was taken — nothing will appear. Retake the snapshot immediately after the unmount, not before it.
The Retainers pane shows dozens of rows — which one is the actual leak?
Ignore internal V8 frames like system / Context or (closure); those are implementation scaffolding, not your code. Scan for the first row rendered in yellow, which marks a user-defined object — a component instance, a module-level array, or a Map. That row’s property name (shown in the second column) is the exact reference to remove.
Retained Size dropped but did not reach zero after my fix — what does that mean?
A non-zero residual after forcing GC usually means a second, weaker retainer still exists — commonly a stale event listener, an IntersectionObserver entry, or a second cache keyed by a different property. Re-run the Retainers walk on the same node; DevTools will now show the next-strongest reference chain.
Related
- Detached DOM Nodes & Memory Retention — parent guide: full mechanism, framework cleanup patterns, and symptom reference
- Take & Compare Heap Snapshots in Chrome Step by Step — sibling workflow: capturing and diffing snapshots
- Interpreting Heap Snapshots for Memory Analysis — main section on snapshot analysis
- Closure Memory Leaks in Modern JavaScript — related: closures are a frequent detached-node holder
- Browser DevTools & Performance Profiling Workflows — main section