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.

What innerHTML removes and what it leaves The container's tree link to its children is cut, shown as a broken line. Three other references remain: the browser's listener table, an application node cache and a resize observer, each pointing at a child that is now detached. One link cut, three left intact container innerHTML = "" tree link cut Detached <tr> × 1 200 still alive · 9.6 MB retained browser listener table addEventListener('click', …) rowCache: Map<id, el> built to avoid re-querying ResizeObserver holds every observed target Removing the tree link is the one thing innerHTML does, and the one thing that was never holding the memory. Each remaining arrow has its own release call — abort, delete, disconnect — and all three must run.

Step-by-Step Fix

  1. 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.
  2. Follow one survivor’s retainer. Expected: the chain ends at a listener entry, a cache or an observer — the three usual holders.
  3. 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.
  4. Clear caches and disconnect observers. Verification: the cache’s size is zero and the observer has no targets.
  5. 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.
Detached nodes after five render cycles Clearing with innerHTML alone leaves six thousand detached nodes after five cycles. Aborting listeners as well reduces it to two thousand four hundred. Also disconnecting the observer reduces it to one thousand two hundred. Clearing the cache too brings it to zero. Each holder removed, measured after five renders of 1 200 rows innerHTML only 6 000 detached — every referenced row from every cycle + abort listeners 2 400 + disconnect observer 1 200 + clear the node cache 0 — fully released Three holders, three fixes: removing any two of them still leaves a leak.

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.

Teardown order Abort listeners first, then disconnect observers, then clear application caches, and only then clear the container. Clearing the container first hides the problem visually while leaving every reference intact. The order is not cosmetic — clearing the container first hides the evidence 1 · abort() removes every listener registered with the signal 2 · disconnect() drops observer targets for every observed node 3 · cache.clear() drops your own element references 4 · clear the DOM replaceChildren() — now genuinely unreachable Doing step 4 first is what makes this bug so common: the screen looks correct immediately, and the remaining three references are invisible until someone opens a snapshot weeks later. Returning the teardown from the render function keeps all four steps in one place and in this order.

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.