Why innerHTML Leaves Detached DOM Nodes Behind
Setting innerHTML to an empty string looks like the most complete removal available — the container is visibly empty — which is exactly why it produces so many surprised engineers staring at a snapshot full of detached nodes. This page belongs to detached DOM nodes and memory retention inside Browser DevTools & Performance Profiling Workflows.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
Detached HTMLElement count rises per render |
Handlers registered in JS still reference children | Abort listeners before clearing |
Nodes persist after innerHTML = '' |
A node cache or observer holds them | Clear the cache; disconnect the observer |
| Detached count varies between snapshots | Snapshot taken without forcing a collection | Force collection first, then capture |
| Only some children survive | Only the referenced ones do | Follow the retainer of a survivor |
| Memory falls only on navigation | The container itself is retained | Check what holds the container |
Root Cause: Two Kinds of Reference, One Removed
A DOM node is reachable in two independent ways. The tree link is the parent-child relationship the document maintains. JavaScript references are everything else: a variable, an array entry, a Map value, an entry in the browser’s listener table, an observer’s target list.
innerHTML = '' removes the tree link and nothing else. The children are parsed out of the document, and if nothing else points at them they become collectable at the next major collection — which is the common case and why the pattern usually looks fine. When something does point at them, the node stays alive, now with no parent, and a snapshot reports it as Detached.
The three usual holders are predictable. A listener registered with addEventListener creates an entry in the browser’s listener table that references the node; the entry survives removal from the tree. A node cache — Map<id, element> built to avoid repeated queries — holds every element it ever recorded. An IntersectionObserver or ResizeObserver holds a strong reference to every observed target until unobserve or disconnect is called.
replaceChildren() and removeChild() behave identically in this respect. The choice of removal API affects clarity and reparsing cost, not retention: the retention is decided entirely by what JavaScript is still holding.
Step-by-Step Fix
- Measure after a forced collection. DevTools path: Memory → collect garbage (the bin icon) → Heap snapshot → filter
Detached. Expected: a stable count that does not change between two consecutive captures. - Follow one survivor’s retainer. Expected: the chain ends at a listener entry, a cache or an observer — the three usual holders.
- Abort listeners first. Register with
{ signal }and abort as step one of teardown. Verification:getEventListeners($0)in the console returns empty for a sample node before the clear. - Clear caches and disconnect observers. Verification: the cache’s
sizeis zero and the observer has no targets. - Clear the container last. Verification: the detached count returns to zero after a forced collection.
Command & Code Reference
Teardown in the order that actually releases the nodes.
function renderRows(container, rows) {
const controller = new AbortController();
const { signal } = controller;
const cache = new Map();
const observer = new ResizeObserver(onResize);
for (const row of rows) {
const el = document.createElement('tr');
el.addEventListener('click', onRowClick, { signal }); // aborts as a group
observer.observe(el);
cache.set(row.id, el);
container.appendChild(el);
}
// Teardown: release every JS-side reference BEFORE cutting the tree link.
return function teardown() {
controller.abort(); // 1. removes every listener registered with signal
observer.disconnect(); // 2. drops the observer's target references
cache.clear(); // 3. drops the application's own references
container.replaceChildren(); // 4. now the subtree is genuinely unreachable
};
}
A node cache that cannot outlive its nodes, which removes one of the three holders permanently.
// A WeakMap keyed by the element lets the entry disappear with the node,
// so no explicit clear is needed and a forgotten teardown costs nothing.
const rowMeta = new WeakMap(); // element → { id, lastSeen }
function attachMeta(el, meta) { rowMeta.set(el, meta); }
function readMeta(el) { return rowMeta.get(el); }
// If you must look up BY id, store the id → id mapping and re-query the DOM,
// or store a WeakRef so the cache never keeps a node alive on its own.
const byId = new Map(); // id → WeakRef<Element>
function remember(id, el) { byId.set(id, new WeakRef(el)); }
function lookup(id) { return byId.get(id)?.deref(); } // may be undefined
Counting detached nodes from the console, which is faster than a snapshot for a quick check.
// Requires a build started with --js-flags="--expose-gc".
// Walks the observed roots you care about rather than the whole heap.
function detachedCount(nodes) {
gc();
let detached = 0;
for (const ref of nodes) {
const el = ref.deref();
if (el && !el.isConnected) detached++; // alive but no longer in the tree
}
return detached;
}
// Collect WeakRefs at render time, then call detachedCount after teardown.
Verification & Regression Prevention
Verify with a forced collection before every capture. Detached nodes that are already unreachable still appear in a snapshot taken too early, which produces both false alarms and false all-clears; two consecutive captures agreeing is the sign that the number is real.
For prevention, make the teardown a single function returned by the render, as in the sample above, so there is one thing to call and nothing to enumerate. Where a cache is genuinely needed, prefer a WeakMap keyed by the element — an entry that disappears with its key cannot be the thing that keeps the key alive. Then assert the detached count in the CI leak gate, which is the cheapest place to catch the next one.
FAQ
Does innerHTML remove event listeners?
It removes the nodes from the document, and listeners attached through markup attributes go with them. Listeners registered from JavaScript with addEventListener are a reference held by the browser’s listener table pointing at the node, and that reference keeps the node alive after it leaves the tree.
Is replaceChildren better than innerHTML?
Marginally, and for different reasons — it avoids reparsing markup and is clearer about intent. Neither API removes JavaScript-side references, so if a handler, cache or observer holds a child, both leave exactly the same detached subtree behind.
Why do detached nodes sometimes disappear on their own?
Because the last reference was dropped and a major collection happened to run. Detached nodes are only a leak while something still points at them; a snapshot taken before a collection can list nodes that were already unreachable, which is why every measurement should follow a forced collection.
Related
- Detached DOM Nodes and Memory Retention — the parent guide to detachment and its retainers
- Fixing Detached Nodes Cached in JavaScript Variables — the cache holder in detail
- Event Listener Leaks & AbortController Cleanup — the listener holder in detail