Finding Detached DOM Nodes After Chart Teardown

You destroy a chart and create a new one — say on every route change or filter — and the heap climbs a few megabytes each cycle and never comes back down.

This is the classic detached-DOM-node leak: nodes removed from the document but still reachable from JavaScript, so the garbage collector can never free them. It is the central failure mode covered by the Memory Management in Heavy Charts guide; this page is the hands-on diagnosis with heap snapshots, retainer paths, and the cleanup patterns that close the leak.

Diagnostic checklist

How a node stays detached

A node is detached when node.parentNode is null (it left the document) but at least one live JS reference still points at it. The GC keeps the whole subtree alive. The usual culprits are listeners whose handler closures capture the node, observers that were never disconnected, and caches that outlive the chart.

The mechanism is worth understanding precisely because it explains why “I called .remove()” is not enough. JavaScript’s garbage collector frees an object only when it is unreachable from any GC root — roughly, the global object, the current call stack, and anything they transitively reference. Removing a node from the DOM severs its link to the document tree, but it does nothing to the JavaScript references pointing at the node. An addEventListener call, for instance, creates a link from the event target to the handler function, and the handler — if it is a closure or a bound method — links back to the node and everything that node’s subtree contains. Until you remove that listener, the chain root → listener registry → handler → node stays intact and the whole subtree is pinned. A removed node held this way is the very definition of a detached-node leak: invisible on the page, fully alive in memory.

What makes these leaks expensive is not the nodes themselves but what they drag along. A single leaked handler that closes over your chart’s full dataset retains that dataset too; a leaked D3 selection retains every bound datum via __data__. So a leak that shows up as a few dozen detached <div>s in the node count can correspond to tens of megabytes of retained size. This is why the diagnosis below looks at retainer paths and retained size, not just at how many detached nodes exist.

Why a detached node stays alive A removed chart node remains reachable through a listener closure and an undisconnected observer, blocking garbage collection. Detached node parentNode = null listener closure captures node ResizeObserver never disconnected GC root
The removed node is still reachable from a GC root through a listener closure and an undisconnected observer, so it cannot be collected.

Broken vs fixed

// ❌ BROKEN: listeners and observer outlive the chart; node stays detached.
class Chart {
  private el: HTMLElement;
  private ro: ResizeObserver;
  constructor(host: HTMLElement) {
    this.el = document.createElement("div");
    host.appendChild(this.el);
    this.el.addEventListener("mousemove", this.onMove); // never removed
    this.ro = new ResizeObserver(() => this.redraw());
    this.ro.observe(this.el);                            // never disconnected
  }
  destroy() {
    this.el.remove();   // detaches from DOM...
    // ...but listener + observer still reference this.el → leak
  }
  private onMove = () => { /* captures this → captures this.el */ };
  private redraw() {}
}
// ✅ FIXED: symmetric teardown removes every reference to the node.
class Chart {
  private el: HTMLElement;
  private ro: ResizeObserver;
  private rafId = 0;
  constructor(host: HTMLElement) {
    this.el = document.createElement("div");
    host.appendChild(this.el);
    this.el.addEventListener("mousemove", this.onMove);
    this.ro = new ResizeObserver(() => this.redraw());
    this.ro.observe(this.el);
    this.rafId = requestAnimationFrame(this.tick);
  }
  destroy() {
    // PERF: release everything that can retain the subtree, in reverse order.
    cancelAnimationFrame(this.rafId);
    this.ro.disconnect();                                    // drop observer ref
    this.el.removeEventListener("mousemove", this.onMove);  // drop listener ref
    this.el.remove();                                        // detach
    // null out so the closure-captured field can be collected
    (this.el as unknown) = null;
    // A11Y: also remove any aria-live region this chart injected so SR users
    // don't hear updates from a destroyed chart.
  }
  private onMove = () => {};
  private tick = () => { this.rafId = requestAnimationFrame(this.tick); };
  private redraw() {}
}

For caches that legitimately need to reference nodes without keeping them alive, use WeakRef / WeakMap:

// A cache that does NOT pin nodes: entries vanish when the node is collected.
const labelCache = new WeakMap<Element, string>(); // PERF: weak keys, no leak
labelCache.set(node, "Q3 revenue");
// When `node` is removed and otherwise unreferenced, the entry is GC-eligible.

Step-by-step fix

Verification

// Optional runtime probe: a FinalizationRegistry tells you when (if) the node
// is actually collected after destroy(). Use only for debugging.
const reg = new FinalizationRegistry((label: string) => {
  console.log(`collected: ${label}`); // logs once GC reclaims the node
});
reg.register(chart["el"], "chart root"); // register before destroy()
chart.destroy();
// In DevTools console, run: __collectGarbage?.() or use the Memory panel's GC button.
console.assert(true, "watch console for the 'collected' line after forcing GC");

The authoritative check remains the heap snapshot: detached-node count must not grow across repeated create/destroy cycles after forcing GC. The FinalizationRegistry callback firing confirms the node was reclaimed; its absence after GC confirms a remaining retainer.

A disciplined snapshot workflow removes the guesswork. Take a baseline snapshot before the chart exists. Mount and destroy the chart ten times, then click the trash-can icon in the Memory panel to force garbage collection, and take a second snapshot. Use the “Comparison” view between the two snapshots and sort by the delta in # Delta for Detached entries. If the count climbed by roughly ten per node type, you are leaking one chart per cycle. Select one detached node, expand its Retainers tree, and follow the chain upward until you hit a name you recognize from your own code — an array, a Map, a class field, or an event-listener entry. That name is the line you need to change. Re-run the whole cycle after the fix; a clean teardown shows the detached count returning to its baseline rather than growing.

Building teardown that cannot drift

The durable fix for detached-node leaks is structural, not a one-time bug hunt: make it impossible for a setup action to exist without a matching teardown action. The most reliable pattern is a disposer list. Every time setup adds a listener, observer, timer, or animation frame, it also pushes a closure that undoes exactly that registration onto an array. Teardown then walks the array in reverse and calls each disposer. Because the registration and its cleanup are written on adjacent lines, they cannot drift apart over months of edits the way a far-away destroy() method does, and adding a new subscription forces you to add its cleanup in the same place.

// A disposer list keeps setup and teardown symmetric by construction.
const disposers: Array<() => void> = [];
function on<K extends keyof HTMLElementEventMap>(
  el: HTMLElement, type: K, fn: (e: HTMLElementEventMap[K]) => void
) {
  el.addEventListener(type, fn);
  disposers.push(() => el.removeEventListener(type, fn)); // PERF: paired cleanup, no drift
}
function dispose() {
  // A11Y: also tear down any injected aria-live region here.
  while (disposers.length) disposers.pop()!(); // reverse order
}

This discipline matters most precisely where leaks are worst: components that mount and unmount repeatedly. A route that renders a chart, a modal that opens and closes, a filter that rebuilds the visualization — each cycle is a chance to leak one more chart. With a disposer list the cost of correctness is one extra line per subscription, and the heap returns to baseline on every teardown.

Edge cases & gotchas

  • React/Vue lifecycle. Strict Mode and HMR mount-unmount-remount components; if cleanup is missing, every reload leaks another chart. Put all teardown in useEffect’s cleanup or onBeforeUnmount.
  • D3 __data__ retention. D3 stores bound data on node.__data__. If you cache selections or nodes in a module scope, you retain both the nodes and their (possibly large) data — clear those references.
  • Closures over big datasets. A leaked listener that closes over the full dataset retains far more than the node; the heap delta can dwarf the DOM itself, so check retained size, not just node count.

Frequently Asked Questions

What is a detached DOM node?

A node whose parentNode is null because it was removed from the document, but which is still reachable from a live JavaScript reference such as an event listener closure, an undisconnected observer, or a cache. Because it is reachable from a GC root, the garbage collector cannot free it or its subtree, so the heap grows.

Why does the heap grow even though I called element.remove()?

remove() detaches the node from the DOM tree but does nothing to the JavaScript references pointing at it. If a listener, observer, timer, or cached selection still references the node, the whole subtree stays alive. You must also remove listeners, disconnect observers, cancel timers, and clear caches.

How do I find which reference is keeping a node alive?

Take a heap snapshot in DevTools after repeated create and destroy cycles, filter the class list for Detached entries, select a detached node, and read its Retainers tree. Follow the chain upward to the listener, array, map, or closure in your code that holds it, then sever that reference.