Fixing Detached Nodes Cached in JavaScript Variables

Of the three things that keep a removed DOM node alive, the node cache is the one written on purpose — a Map of elements built to avoid re-querying, which then outlives every element it recorded. This page belongs to detached DOM nodes and memory retention inside Browser DevTools & Performance Profiling Workflows.

Symptom Root Cause Immediate Action
Retainer chain passes through a Map or array A node cache holds removed elements Delete on removal, or invert the key
Detached count equals rows rendered this session The cache is never trimmed Bound it, or use a WeakRef
Metadata lookups keep elements alive Map<element, meta> used as a strong map Switch to a WeakMap
Cache exists but is never read Written once, never used Delete the cache entirely
Detached nodes reappear after a fix A second cache elsewhere Search for every structure holding elements

Root Cause: A Strong Reference Written on Purpose

Caching a node is an ordinary optimisation: query once, keep the element, avoid the cost of finding it again. The cost it avoids is real but small, and the reference it creates is strong and permanent. When the element is later removed from the document, the cache entry is still there — a variable pointing at a node with no parent — which is precisely the definition of a detached node.

The direction of the reference is what confuses people reading a snapshot for the first time. The Retainers pane reads upward from the retained object, so a Map full of elements appears above the elements it holds, and the Map’s own retainer is the module scope or closure holding it. Following two more hops usually lands on a file you own.

There are three distinct shapes, and each has its own fix. A metadata mapMap<element, meta> — should be a WeakMap: the metadata dies with its element and no explicit cleanup is needed anywhere. An id indexMap<id, element> — cannot invert, so it needs either a WeakRef value or an explicit delete on removal. And a list of elements — an array captured for iteration — usually should not exist beyond the function that built it.

The fourth option is the one worth considering first: delete the cache. container.querySelector('#row-42') on a scoped root costs microseconds, and the layout work that follows any DOM interaction dwarfs it. Many node caches are habits carried over from engines that no longer exist.

Three cache shapes, three fixes A Map from element to metadata retains its keys and should be a WeakMap. A Map from id to element retains its values and needs a WeakRef or an explicit delete. An array of elements retains everything it lists and usually should not outlive the function that built it. Which structure you have decides which fix applies Map<element, meta> — the element is the KEY retains every element ever recorded, for as long as the map lives → WeakMap: the entry disappears with the element, no cleanup call anywhere Map<id, element> — the element is the VALUE cannot be inverted; a WeakMap keyed by id would not help → store a WeakRef, or delete the entry in the same teardown that removes the node Array<element> — captured for iteration retains everything it lists; frequently kept in module scope by accident → keep it local to the function, or re-query when the list is next needed

Step-by-Step Fix

  1. Confirm the retainer. DevTools path: Memory → collect garbage → Heap snapshot → filter Detached → select one → Retainers. Expected: a Map, Array or Object in the chain, with your module above it.
  2. Ask whether the cache earns its keep. Expected: for most lookups, a scoped querySelector is fast enough that the cache can simply be deleted.
  3. Invert where metadata is the point. Replace Map<element, meta> with a WeakMap. Verification: the map no longer appears in any retainer chain.
  4. Use a WeakRef where lookup by id is required. Verification: entries survive, but deref() returns undefined once the node is gone — handle that at every call site.
  5. Re-measure. Verification: the detached count reaches zero after a forced collection, and stays there across five cycles.

Command & Code Reference

The metadata case, which is the cleanest fix in the set.

// BEFORE: a strong Map keyed by element retains every row ever rendered.
const rowMeta = new Map();               // element → { id, lastSeen }
function attach(el, meta) { rowMeta.set(el, meta); }

// AFTER: a WeakMap holds the same association without holding the key.
// When the row is removed and nothing else references it, both the element
// and its metadata entry become collectable — with no cleanup call at all.
const rowMetaWeak = new WeakMap();
function attachWeak(el, meta) { rowMetaWeak.set(el, meta); }
function readMeta(el) { return rowMetaWeak.get(el); }

The id-index case, where inversion is impossible and a WeakRef is the tool.

// A registry that 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) {
  const ref = byId.get(id);
  const el = ref?.deref();
  if (!el) {
    byId.delete(id);                     // tidy the dead entry as we pass
    return null;                          // EVERY call site must handle this
  }
  return el;
}

// A periodic sweep keeps the Map itself from growing without bound, since
// the WeakRef wrappers and keys are still strongly held.
setInterval(() => {
  for (const [id, ref] of byId) if (!ref.deref()) byId.delete(id);
}, 60_000);

Deleting the cache altogether, which is often the correct answer.

// The cache this replaces existed to avoid a query that costs microseconds.
function getRow(container, id) {
  // Scoped to the container, so the engine searches a subtree, not the document.
  return container.querySelector(`[data-row-id="${CSS.escape(id)}"]`);
}

// Where a hot loop genuinely needs the elements, build the list locally and
// let it die with the function — never promote it to module scope.
function updateVisible(container, ids) {
  const rows = ids.map((id) => getRow(container, id)).filter(Boolean);
  for (const row of rows) row.classList.toggle('visible', true);
}   // `rows` is collectable the moment this returns
Ten renders of 1 200 rows, three caching strategies A strong Map keyed by id retains twelve thousand detached nodes after ten renders. A WeakRef registry retains none once collection runs, though its own key entries persist until swept. Removing the cache entirely retains nothing at any point. Detached nodes after ten renders of 1 200 rows 0 6 000 12 000 renders (0 → 10) Map<id, element> → 12 000 retained WeakRef registry, and no cache at all → 0 The two green strategies are indistinguishable in memory; they differ only in lookup convenience.

Verification & Regression Prevention

Verify across several cycles rather than one. A single render-and-clear can pass by accident if a collection happened to run at the right moment; five cycles with a forced collection between each is what distinguishes “released” from “not yet collected”.

For prevention, make the rule explicit in review: no structure that outlives a function may hold an Element strongly. WeakMap when the element is the key, WeakRef when it is the value, and nothing at all when a scoped query will do. That rule is short enough to remember and covers every shape in the table above — and it pairs naturally with the teardown ordering that handles the other two retainers.

Choosing how to reference an element If the element is a key, use a WeakMap. If it is a value looked up by id, use a WeakRef and handle undefined. If it is only needed within one function, keep the list local. If it is needed occasionally, re-query with a scoped selector. Four ways to reference an element, none of which retain it how is it looked up? by element WeakMap<el, meta> no cleanup needed by id, often Map<id, WeakRef> handle undefined by id, rarely scoped querySelector no cache at all within one function a local array dies on return If none of the four fits, the reference probably belongs to a component's lifetime rather than to a cache.

FAQ

Is caching DOM nodes ever worth it?

Rarely, at modern engine speeds. A scoped querySelector on a container with a few hundred children costs microseconds, which is invisible next to the layout and paint that follow. Cache only inside a hot loop, and let the cache die with the loop rather than living at module scope.

Does a WeakMap keyed by element solve everything?

It solves the direction that matters: metadata keyed by element disappears when the element does. It does not help when you need to look an element up by id, because the element is then the value rather than the key — that case needs a WeakRef or a re-query.

Why does the snapshot show my array as the retainer rather than the node?

Because that is the direction retention runs: the array holds the node. The Retainers pane always reads from the retained object upward to whatever is keeping it alive, so an array of elements appears above the elements, and the array’s own retainer is the module or closure that holds it.