How to find detached DOM nodes
Force a garbage collection, read the node count, tear the subtree down, force another collection and read again. A subtree that a variable still holds keeps its nodes alive after removal. On the probe page the count stayed at 1,017 after the panel was removed and fell to 16 when the reference was dropped.
Why check this
A detached node is an element that no longer sits in the document but that some JavaScript value still points at. The browser cannot reclaim it, so its nodes, its attributes and its event listeners stay in memory for the life of the tab. Run this check after any change to how a view is torn down: a modal, a route change in a single page app, a table that rerenders on filter.
The failure it prevents is the session that degrades over an hour. A support agent opens and closes a ticket panel two hundred times, each close leaves a rendered subtree behind a cached reference, and by the afternoon the tab holds hundreds of thousands of dead nodes and scrolls at a crawl. Nothing in the interface says so, and a reload hides it from whoever reproduces the report.
Prerequisites
- Chrome. This page used version 152.0.7977.76 on Windows. The figures below are one capture on one machine, taken on 2026-09-11, and they move between machines and Chrome versions.
- Node 22 or later and
puppeteer-core, installed withnpm i puppeteer-core. It drives the Chrome you already have. See the puppeteer API. - The Chrome path in each script is the Windows default. On macOS it is
/Applications/Google Chrome.app/Contents/MacOS/Google Chrome. - Save this as
detach-server.mjs. It serves a page with the bug planted:clearPanelremoves a 500 row panel from the document and stores it inwindow.cacheso it can be reused, which is exactly how the reference outlives the removal.
// detach-server.mjs run: node detach-server.mjs stop: Ctrl-C
import http from 'node:http';
const page = `<!doctype html><meta charset="utf-8"><title>detached node probe</title>
<h1>detached node probe</h1>
<div id="host"></div>
<script>
window.cache = null;
function build() { // render a panel of 500 rows
const panel = document.createElement('div');
panel.id = 'panel';
for (let i = 0; i < 500; i++) {
const row = document.createElement('div');
row.textContent = 'row ' + i;
row.addEventListener('click', () => console.log('row', i));
panel.appendChild(row);
}
document.getElementById('host').appendChild(panel);
}
function clearPanel() { // the bug: the panel is kept for reuse
const panel = document.getElementById('panel');
window.cache = panel;
panel.remove();
}
function release() { window.cache = null; }
<\/script>`;
http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(page);
}).listen(8894, () => console.log('detached node probe on http://localhost:8894/'));
Steps
- Step 1.
Start the probe page. Stop it with Ctrl-C when you are finished.
node detach-server.mjsdetached node probe on http://localhost:8894/The page exposes three functions the next script calls in order:
buildrenders the panel,clearPanelremoves it while keeping a reference,releasedrops the reference. - Step 2.
Read the node count at four points, with a forced collection before every reading. Save this as
detached.mjs.// detached.mjs run: node detached.mjs import { launch } from 'puppeteer-core'; const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe'; const browser = await launch({ executablePath: CHROME, headless: true }); const page = await browser.newPage(); const cdp = await page.createCDPSession(); await cdp.send('Performance.enable'); await page.goto('http://localhost:8894/', { waitUntil: 'networkidle2' }); async function measure(label) { await cdp.send('HeapProfiler.collectGarbage'); // force a full GC first const { metrics } = await cdp.send('Performance.getMetrics'); const v = (n) => metrics.find((m) => m.name === n).value; const dom = await cdp.send('Memory.getDOMCounters'); console.log(label.padEnd(34), 'Nodes', String(v('Nodes')).padStart(5), ' listeners', String(v('JSEventListeners')).padStart(4), ' DOMCounters.nodes', dom.nodes); } await measure('1 loaded, nothing rendered'); await page.evaluate(() => build()); await measure('2 panel of 500 rows rendered'); await page.evaluate(() => clearPanel()); await measure('3 panel removed, reference kept'); await page.evaluate(() => release()); await measure('4 reference released'); await browser.close();1 loaded, nothing rendered Nodes 16 listeners 0 DOMCounters.nodes 16 2 panel of 500 rows rendered Nodes 1017 listeners 500 DOMCounters.nodes 1017 3 panel removed, reference kept Nodes 1017 listeners 500 DOMCounters.nodes 1017 4 reference released Nodes 16 listeners 0 DOMCounters.nodes 16Line 3 is the evidence. The panel is out of the document, a full collection has run, and not one node was reclaimed. Line 4 is the control: the same collection after
window.cache = nullreturns the page to its opening figure of 16, which proves the collection in line 3 was real and the retention was the reference, not the removal. - Step 3.
Name what is being retained. A heap snapshot labels every element the browser knows to be detached, and counting those labels answers the question the node count only implies. Save this as
detached-snapshot.mjs.// detached-snapshot.mjs run: node detached-snapshot.mjs import { launch } from 'puppeteer-core'; const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe'; const browser = await launch({ executablePath: CHROME, headless: true }); const page = await browser.newPage(); const cdp = await page.createCDPSession(); await page.goto('http://localhost:8894/', { waitUntil: 'networkidle2' }); async function detachedCounts(label) { await cdp.send('HeapProfiler.collectGarbage'); const parts = []; const collect = ({ chunk }) => parts.push(chunk); cdp.on('HeapProfiler.addHeapSnapshotChunk', collect); await cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false }); cdp.off('HeapProfiler.addHeapSnapshotChunk', collect); const snap = JSON.parse(parts.join('')); const f = snap.snapshot.meta.node_fields; const width = f.length, name = f.indexOf('name'), det = f.indexOf('detachedness'); const tally = {}; for (let i = 0; i < snap.nodes.length; i += width) { const n = snap.strings[snap.nodes[i + name]]; if (n.startsWith('Detached ') || snap.nodes[i + det] === 2) tally[n] = (tally[n] || 0) + 1; } const total = Object.values(tally).reduce((a, b) => a + b, 0); console.log(label, '-', total, 'detached objects'); Object.entries(tally).sort((a, b) => b[1] - a[1]).slice(0, 6) .forEach(([k, v]) => console.log(' ', String(v).padStart(5), k)); } await page.evaluate(() => { build(); clearPanel(); }); await detachedCounts('panel removed, reference kept'); await page.evaluate(() => release()); await detachedCounts('reference released'); await browser.close();panel removed, reference kept - 501 detached objects 500 <div> 1 <div id="panel"> reference released - 0 detached objectsThe
idon the root of the detached tree is what makes this usable on a real page: it names the component that was torn down. The 500 anonymous rows hang off it, and releasing one reference takes all 501 with it. - Step 4.
Reproduce the same finding by hand, for a page you cannot script. Open DevTools, select the Memory panel, select the Heap snapshot radio button and press Take snapshot. Then type
Detachedin the Class filter box above the list, and expand a row to see which value holds it. Chrome documents the route in fix memory problems. Compare a snapshot taken before the interaction with one taken after it.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Node count unchanged after a forced collection | Something still references the removed subtree | Take a snapshot and read the retainer of the detached root, as in step 3. |
| Node count falls to the opening figure | The subtree was reclaimed | Nothing to fix. Repeat the interaction several times before calling it clean. |
| Detached <div id="panel"> in the snapshot | The root of a dead tree, named | Search the code for the variable that holds that element. The id is the fastest way in. |
| Listener count stays high while nodes stay high | Handlers bound per row are held with the tree | One delegated listener on the container removes the whole class of leak. |
| Node count drops without a forced collection | Nothing is proved either way | Collections run when the browser chooses. Force one before every reading. |
Common mistakes
What to check next
- How to check memory leak in a web page: the same counters read over several rounds, which is what turns one reading into a trend.
- How to check DOM size of a page: what the node count means while the nodes are still attached.
- How to check console errors on a website: a teardown that throws halfway leaves the subtree behind, and the console is where that shows.
- How to check which third party scripts a page loads: chat widgets and tag managers are a common source of trees that never go away.
FAQ
Do removed DOM elements keep their event listeners in memory?
Only while something still references them. In step 2 the removed panel keeps all 500 listeners at line 3 and drops to zero at line 4, when the last reference goes. No removeEventListener call was involved in either reading.
What counts as a detached DOM node?
A node with no connected ancestor that is still reachable from a JavaScript value: a variable, an array, a closure, a map key, a framework cache. Chrome labels these in a heap snapshot, and step 3 counts the labels.
Why does the count stay high even after a garbage collection?
Because the collection worked and found the nodes reachable. A forced collection reclaims everything unreachable, so a figure that survives one is held by live code. Find the holder through the retainer chain of the detached root.
Can I find detached nodes without writing a script?
Yes. DevTools, Memory panel, Heap snapshot, then Detached in the Class filter, as in step 4. The scripted version records the before and after pair and runs in CI.
Verified
Verified by Maks VernyChrome 152.0.7977.76node 22.23.2puppeteer-core 25.10.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate9 minpublished updated Maks Verny