Debugging Detached DOM Nodes in React Components
A heap snapshot comparison flags Detached HTMLDivElement or Detached HTMLCanvasElement entries growing after every unmount, even though the component appears to have been removed cleanly from the page — a common failure mode covered generally under React component leaks within the broader Framework-Specific Memory Optimization reference.
Symptom-to-Fix Diagnostic Matrix
| Symptom | Root Cause | Immediate Action |
|---|---|---|
Detached HTMLCanvasElement grows per mount cycle |
Chart/map library caches a node reference in a module-scope Map or singleton |
Call the library’s destroy()/dispose() in cleanup and delete the map entry |
Detached HTMLDivElement tied to a portal |
createPortal container never removed from document.body |
Call container.remove() in the same useEffect cleanup that created it |
Retainer chain ends at a ref array |
Component pushes DOM refs into a parent-owned array without popping on unmount | Splice the ref out in the cleanup function, keyed by a stable id |
| Node retained by an event listener closure | addEventListener target is the node itself but the handler is never removed |
Pair every addEventListener with removeEventListener using the same handler reference |
| Detached tree survives 2+ forced GC passes | A WeakMap/WeakRef was expected but a plain Map or array was used instead |
Replace the strong collection with a WeakMap keyed by the DOM node |
Root Cause
React’s reconciler removes a component’s DOM subtree from the live tree during unmount by calling the host config’s removeChild on the fibre’s parent container. That step only detaches the node from the render tree — it does not, by itself, make the node eligible for collection. The mark-and-sweep algorithm V8 runs still finds the node reachable if any JavaScript value outside React’s own fibre tree points to it, and unmount is exactly when those extra references tend to surface.
Four patterns account for almost every case seen in production React codebases. First, imperative refs handed to third-party libraries — chart, map, and rich-text-editor libraries frequently store the DOM node you pass them in an internal registry so they can update it later; if you never call the library’s teardown method, that registry keeps the node alive indefinitely. Second, createPortal containers — a portal renders React children into a node you supplied, but if you created that node yourself with document.createElement and appended it to document.body, React never removes it; you own its lifecycle and must remove it explicitly. Third, manual DOM APIs inside useEffect — direct appendChild, insertBefore, or cloneNode calls create nodes outside the fibre tree that React has no knowledge of and therefore cannot clean up. Fourth, ref arrays or module-scope caches — a common pattern for measuring lists of items pushes each item’s ref into a shared array keyed by index or id, and the entry is rarely spliced out on unmount.
Because these retainers live outside React’s fibre tree, they are invisible to React DevTools’ Components panel — you cannot see the leak by inspecting props or state. It only shows up as a growing detached DOM node count in a heap snapshot, which is why the diagnostic workflow below starts in the browser’s Memory panel rather than in React’s own devtools.
The diagram below shows the two paths a node can take after unmount: the clean path, where every external retainer is released and the node is collected, and the leaking path, where one surviving reference — a library’s internal map, a portal container, or a ref array entry — keeps the same node reachable and visible as Detached in a comparison snapshot.
Step-by-Step Fix
Step 1 — Capture a baseline snapshot with the component unmounted
DevTools path: DevTools → Memory → Heap Snapshot → Take snapshot
- Load the page and navigate away from (or close) the suspect component so it is fully unmounted.
- Click the trash-can Collect garbage icon in the Memory panel toolbar.
- Click Take snapshot and rename it
Baselinein the sidebar.
Expected output: the snapshot lists a total heap size (for example 18.2 MB).
Verification checkpoint: the class filter shows zero or a stable low count for the component’s expected node type (e.g. HTMLCanvasElement).
Step 2 — Mount and unmount the component repeatedly
- Trigger the interaction that mounts the component — open a modal, navigate to its route, or toggle a tab — then trigger the interaction that unmounts it.
- Repeat this mount/unmount cycle 8–10 times to amplify a small per-cycle leak into a visible delta.
- Return the UI to the same idle state it was in for the baseline.
Expected output: the page looks visually identical to before the cycles ran. Verification checkpoint: no console errors or React warnings about updating unmounted components appeared during the cycles.
Step 3 — Capture a comparison snapshot
DevTools path: DevTools → Memory → Heap Snapshot → Comparison view
- Click Collect garbage 2–3 times to drain new-space allocations.
- Click Take snapshot to create Snapshot 2.
- Select Snapshot 2 in the sidebar and change the view dropdown from Summary to Comparison, choosing
Baselineas the comparison target.
Expected output: the Comparison table lists constructors with a non-zero # Delta.
Verification checkpoint: if this workflow is new to you, cross-check the mechanics against taking and comparing heap snapshots step by step before proceeding.
Step 4 — Filter for Detached entries and expand Retainers
- Type
Detachedinto the class filter bar above the Comparison table. - Sort the filtered list by Retained Size descending.
- Click the top row to expand it, then open the Retainers pane beneath the table.
- Expand the retainer chain one level at a time until you reach a name you recognise — a variable, a
Map, or a component’s closure.
Expected output: the # Delta for Detached HTMLDivElement or similar equals roughly the number of mount/unmount cycles you ran in Step 2.
Verification checkpoint: the Retainers pane shows an unbroken chain from the node up to a GC root (Window or a closure), not a dead end — a dead end usually means you need to expand one more level.
Step 5 — Trace the retainer to its source and release it
- Identify which of the four patterns from the Root Cause section matches the retainer: library instance map, portal container, ref array, or dangling event listener.
- Open the source file that creates that reference and add the matching teardown call in the component’s
useEffectcleanup function (see the Command & Code Reference below). - Repeat Steps 1–3 to confirm the fix.
Expected output: the Detached entry’s # Delta after the fix is 0 or within noise (±1–2 entries) across repeated cycles.
Verification checkpoint: Retained Size for the flagged constructor stays flat across 3 consecutive test runs rather than growing linearly.
Command & Code Reference
A chart library keeping a canvas alive via a module-scope cache — the most common pattern behind this leak:
// chartRegistry.js — module-scope cache (the bug)
// Strong Map keyed by string id: entries never
// get released once the owning component unmounts.
export const chartRegistry = new Map();
// ChartPanel.jsx
import { useEffect, useRef } from "react";
import Chart from "chart-lib";
import { chartRegistry } from "./chartRegistry";
function ChartPanel({ panelId, series }) {
const canvasRef = useRef(null);
useEffect(() => {
// Chart lib clones/wraps the canvas node into
// its own internal render graph on construction.
const chart = new Chart(canvasRef.current, series);
chartRegistry.set(panelId, chart); // never cleared
return () => {
// Missing teardown: chart.destroy() and the
// Map entry are never removed, so both the
// chart instance and the detached canvas node
// it wraps stay reachable after unmount.
};
}, [panelId, series]);
return <canvas ref={canvasRef} />;
}
The fix: destroy the library instance and delete the registry entry in the same cleanup function that created them.
function ChartPanel({ panelId, series }) {
const canvasRef = useRef(null);
useEffect(() => {
const chart = new Chart(canvasRef.current, series);
chartRegistry.set(panelId, chart);
// Cleanup runs on every unmount AND before each
// re-run triggered by a dependency change.
return () => {
chart.destroy(); // library releases
// its internal DOM
// and event refs
chartRegistry.delete(panelId); // drop our own
// strong reference
};
}, [panelId, series]);
return <canvas ref={canvasRef} />;
}
A manually created portal container, cleaned up correctly:
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
function TooltipPortal({ children }) {
const [container] = useState(() =>
document.createElement("div")
);
useEffect(() => {
document.body.appendChild(container);
// Cleanup removes the container element itself —
// createPortal only manages its React children,
// never the host node you supplied.
return () => {
container.remove(); // drops last DOM reference
};
}, [container]);
return createPortal(children, container);
}
Verification & Regression Prevention
Treat any non-zero Detached delta after 10 mount/unmount cycles as a regression, not noise. Set concrete targets before closing the fix:
- Retained Size of flagged Detached entries:
0 KBafter 3 forced GC passes, measured with the workflow in interpreting heap snapshots for memory analysis. # Deltafor the component’s node type: stays within ±2 across 10 identical cycles (accounts for transient new-space noise, not real retention).- Total heap growth per cycle: under 50 KB once the fix lands, down from whatever multi-hundred-KB-per-cycle figure the original bug produced.
Wire this into CI with a headless Puppeteer check that fails the build if retained size regresses:
// ci-detached-node-check.js — run in CI via node
const puppeteer = require("puppeteer");
async function checkLeak(url, selector, cycles = 10) {
const browser = await puppeteer.launch({
args: ["--js-flags=--expose-gc"],
});
const page = await browser.newPage();
await page.goto(url);
for (let i = 0; i < cycles; i++) {
await page.click(selector); // mount
await page.click(selector); // unmount
}
// Force GC via the exposed flag, then read heap size
const usedBytes = await page.evaluate(async () => {
await new Promise((r) => setTimeout(r, 200));
if (window.gc) window.gc();
return performance.memory.usedJSHeapSize;
});
await browser.close();
return usedBytes;
}
// Fail the pipeline if heap growth exceeds 2 MB
// across the mount/unmount cycles above.
checkLeak("http://localhost:3000/panel", "#toggle")
.then((bytes) => {
const mb = bytes / (1024 * 1024);
console.log(`Heap after cycles: ${mb.toFixed(2)} MB`);
if (mb > 2) process.exit(1);
});
Pair this with an ESLint rule such as react-hooks/exhaustive-deps to catch missing cleanup dependencies, and add a code-review checklist item requiring every useEffect that creates a Chart, portal container, or manual DOM node to include a matching teardown call in its return function.
Frequently Asked Questions
Why does setting a ref to null in useEffect cleanup not fix the leak?
Nulling the ref only removes React’s own pointer to the node. If a third-party library, a module-scope Map, or a portal container captured a separate reference to the same node during mount, that second reference keeps the node reachable regardless of what the ref points to. You must release every retainer, not just the ref.
Does React.StrictMode’s double-invoke of effects cause false positives here?
It can inflate the raw count temporarily because effects mount, clean up, and mount again in development, but it does not create a persistent leak by itself. If your cleanup function correctly destroys library instances and clears references, the second mount’s snapshot delta returns to the same baseline. A leak is confirmed only when Retained Size keeps growing across repeated real mount/unmount cycles, not the StrictMode double-invoke.
Why do detached nodes show up even though I use createPortal correctly?
createPortal renders into a DOM node you supply, but React only manages the portal’s children — it does not remove the container element you created with document.createElement. If that container was appended to document.body and never removed with container.remove() in a cleanup function, both the container and everything still referencing it stay retained after the component unmounts.
Related
- React Component Memory Leaks & Lifecycle Cleanup — the parent guide covering broader React leak patterns.
- Fixing useEffect Cleanup Memory Leaks — for the general cleanup-function checklist.
- Detached DOM Nodes and Memory Retention — the framework-agnostic version of this diagnostic.
- Why React Memory Grows on Every Route Change — a related retention pattern seen across route transitions.