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 map — Map<element, meta> — should be a WeakMap: the metadata dies with its element and no explicit cleanup is needed anywhere. An id index — Map<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.
Step-by-Step Fix
- Confirm the retainer. DevTools path: Memory → collect garbage → Heap snapshot → filter
Detached→ select one → Retainers. Expected: aMap,ArrayorObjectin the chain, with your module above it. - Ask whether the cache earns its keep. Expected: for most lookups, a scoped
querySelectoris fast enough that the cache can simply be deleted. - Invert where metadata is the point. Replace
Map<element, meta>with aWeakMap. Verification: the map no longer appears in any retainer chain. - Use a
WeakRefwhere lookup by id is required. Verification: entries survive, butderef()returnsundefinedonce the node is gone — handle that at every call site. - 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
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.
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.
Related
- Detached DOM Nodes and Memory Retention — the parent guide to detachment
- Why innerHTML Leaves Detached DOM Nodes Behind — the other two retainers and the teardown order
- WeakMap vs WeakRef vs FinalizationRegistry — choosing between the weak-reference tools