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

// 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

  1. Step 1.

    Start the probe page. Stop it with Ctrl-C when you are finished.

    node detach-server.mjs
    
    detached node probe on http://localhost:8894/

    The page exposes three functions the next script calls in order: build renders the panel, clearPanel removes it while keeping a reference, release drops the reference.

  2. 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 16

    Line 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 = null returns 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.

  3. 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 objects

    The id on 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.

  4. 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 Detached in 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

Sign: The count does not drop after remove(), so removal looks broken.Cause: Removal worked. On the probe page the panel is out of the document at line 3 and the count is still 1017, because window.cache points at it. What you are measuring is the reference, not the removal, and line 4 proves it by dropping to 16 on the same collection.
Sign: The heap snapshot reports 501 detached objects but the node counter dropped by 1001.Cause: Both numbers are right. The snapshot lists element objects, so 500 rows plus the panel; the node counter also counts the 500 text nodes inside those rows. Compare snapshot counts against snapshot counts and counter readings against counter readings, never one against the other.
Sign: The handlers were never removed with removeEventListener, and no one can say whether that matters.Cause: It matters only while something holds the tree. The probe holds 500 listeners at line 3 and zero at line 4, with no removeEventListener call anywhere. Detaching a subtree that nothing references takes its listeners with it; detaching one that a variable holds keeps every one of them.
Sign: The same measurement gives a different answer on every run.Cause: A reading taken without HeapProfiler.collectGarbage includes objects that are already unreachable and merely not collected yet. That is the single most common false positive in this check, and the numbers that show it are on the memory leak page.

What to check next

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.

intermediate9 minpublished updated Maks Verny