DOMGENERALBROWSER-SPECIFICENGINE-SPECIFIC

Detached Nodes and What Keeps Them Alive

Removing a node from the document frees one reference, not the node — and because nodes point at their parents, siblings and children, one surviving reference retains an entire subtree.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

The nodes are gone from the page, so why does memory keep growing?

The user intent

A person keeps a dashboard open all day, moving between views. They expect it to be as responsive at five in the afternoon as it was at nine.

The obvious build

Remove the element and the browser frees it. Garbage collection handles memory, so DOM memory is not something a frontend engineer has to think about.

Why it breaks

Removing a node drops exactly one reference — the parent's. If anything else still points at it, the node stays, and so does everything reachable through it.

How it breaks in a real browser
  • Removing a node drops exactly one reference — the parent's. If anything else still points at it, the node stays, and so does everything reachable through it.
  • Nodes reference their parent, their children and their siblings. Holding one row of a removed table keeps the table, its rows, its cells and every text node inside it alive (The DOM Is Not Your HTML).
  • A listener attached to window or document that closes over a component's root element outlives every navigation, because window never goes away (How an Event Is Dispatched).
  • The symptom is not a crash. It is a dashboard that is fine in the morning and stutters by the afternoon, on a machine you cannot reproduce it on (Long-Lived Clients and Version Skew).
  • The measurement trap: console.log(node) in devtools keeps that node alive for as long as the console entry exists, so the act of investigating creates the retention you are looking for.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • JavaScript memory is reclaimed by reachability, not by reference counting in the way people picture it: an object survives if there is any path to it from a GC root (Garbage Collection: Pause, Throughput, Footprint — Pick Two in Performance).
  • The GC roots that matter here are the global object, module scope, the current call stack, and — importantly — the internal registries the browser keeps: event target listener lists, live observers, pending timers and in-flight promise reactions.
  • A listener on a live target retains its handler function; the handler retains its entire closure scope; and the closure typically retains a DOM node. That is the standard chain, and its first link is the one that outlives your component.
  • A listener attached to a node that is itself removed and unreferenced is collected with it. This is why "remove the listener" is not always necessary and is always safe — the cases where it matters are the ones where the target outlives the node.
  • A closure retains its whole scope, not only the variables it uses. A handler defined next to a large array in the same function retains that array too, which is why leaks are so often larger than the node that caused them.
  • WeakMap, WeakSet, WeakRef and FinalizationRegistry are the escape hatches: a WeakMap keyed by a node holds no strong reference to it, so per-node metadata does not keep the node alive.
  • Detached nodes appear in a heap snapshot as their own category precisely because they are so common a leak shape, and the snapshot names the retaining path — which object, in which closure, in which file.

What this makes the browser do

And which of it is avoidable.

  • Keeping every detached node object allocated, along with its attributes, its text content and its listener list.
  • Retaining any computed style and layout data associated with those nodes until they are collected, and re-checking reachability on every major GC.
  • Running longer and more frequent collections as the heap grows, which is main-thread time that shows up as unexplained pauses (Long Tasks).
  • Under memory pressure, discarding the tab entirely and rebuilding it — the platform's answer to an application that will not release memory (The Multi-Process Browser).
  • Avoidable: essentially all of it. Retention here is an application bug, not a browser characteristic.

What is holding the subtree

A leak is always a path from a GC root to a node. Drawing that path is the whole diagnosis, and heap snapshots exist to draw it for you — the retainers pane is literally this diagram, generated from your heap.

Note that the node in the middle is doing something people rarely picture: it retains its parent, which retains every sibling, which retains their children. The unit of retention is not a node, it is the tree the node belonged to.

  • Break any single edge and the whole subtree is collectable. The cheapest edge to break is almost always the first one — remove the listener.
  • The data node is why leaks are bigger than they look: a closure retains its entire scope, not the variables it happens to reference.
  • The same shape applies with a timer, an observer, an event bus, a module-level Map or a framework store in place of the listener list.
A retaining path, root to subtree
never removedcloses overretained even if unusedparentNodechildNodeswindow (GC root)window listener listonResize handlerClosure scope — the whole enclosing scopeOne removed <tr>The dataset captured in the same scope<table> (via parentNode)Every other row + cells + text nodes
UserLLMAgentToolDataDecisionHumanGuardrail

Where retention actually comes from

Every row here is a real chain from a real codebase shape. What they share is a target that outlives the component: window, document, a store, a library instance, a module-scope collection. The node is never the problem; the thing holding it is.

The last two rows are the ones that waste the most time, because they look exactly like a leak and are not. Confirming that a heap grows *after a forced collection*, across repetitions, is what separates them (Leak or Unbounded Cache? The Question That Picks the Fix in Performance).

Retention sources and what breaks the chain
TriggerSymptomCauseResponse
window.addEventListener('resize', h) with no removalHeap and node count rise on every route changewindow is a GC root and holds the handler, which closes over the component rootPass an AbortController signal to every listener and abort once in teardown.
A ResizeObserver or IntersectionObserver never disconnectedDetached nodes accumulate; callbacks fire for elements that are goneObservers hold strong references to their observed targetsobserver.disconnect() in the same teardown path as everything else.
setInterval polling that outlives its viewNetwork requests continue after leaving the page; memory climbsThe timer registry holds the callback, which holds the subtreeclearInterval in teardown; prefer a scheduler tied to component lifetime (Retries, and the Duplicate Order).
A chart or map library container removed without destroy()A large step in heap size per view visitThe library holds its own canvas, dataset and internal handlesCall the documented teardown. Removing the container is never sufficient for a library that manages its own resources.
A module-level Map of "rendered nodes"Monotonic growth proportional to how much the user has browsedAn unbounded cache keyed by id, holding DOMBound it with an eviction policy, or key a WeakMap by the node instead of storing the node.
An event-bus or store subscription without unsubscribeStale components react to updates and throw on detached nodesThe store holds the callback, which holds the componentReturn an unsubscribe from every subscribe, and call it in teardown (State Synchronization).
console.log(node) while investigatingThe node is retained and nothing you fix helpsDevtools holds references to logged objectsClear the console, force a collection, take a fresh snapshot before concluding anything.
Comparing two arbitrary heap readings"Memory is growing" with no reproducible leakHeaps grow between collections by designForce a collection between repetitions and compare like with like (Memory Leaks: Growth That Does Not Come Back in Performance).

Finding it: the three-snapshot method

This is the one reliable procedure, and its power comes entirely from the repetition: by returning to the same application state twice, you make everything that *should* have been released the difference between snapshot two and snapshot three.

The code below is the convention that prevents most of what the procedure finds. One controller per component instance, one signal passed everywhere, one abort() — so there is no per-subscription cleanup to forget, and a reviewer can verify teardown by reading a single line.

Isolating a retention bug
  1. 1
    Reach a stable state

    Load the app and navigate to the view under suspicion once, so lazy chunks, caches and pools are already warm.

    fails by Snapshotting on first load, where everything is legitimately new and nothing is comparable.

  2. 2
    Force a collection, snapshot 1

    Establishes the baseline the other two are compared against.

    fails by Skipping the forced collection, which makes every later comparison include ordinary heap growth.

  3. 3
    Exercise the suspect flow repeatedly

    Enter and leave the view five or ten times. Repetition turns a small leak into an unmistakable slope and averages out one-off allocation.

    fails by Doing it once — a single iteration cannot distinguish a leak from a cache filling up.

  4. 4
    Return to the baseline state, force a collection, snapshot 2

    Everything allocated by the flow should now be unreachable. What remains is what leaked.

    fails by Returning to a different state, so the diff includes whatever the new state legitimately allocated.

  5. 5
    Diff, filter to Detached, read the retainers

    Names the retaining path: the object, the closure and the source location holding each detached node.

    fails by Reading object counts instead of retaining paths. The count tells you there is a leak; only the path tells you where.

  6. 6
    Break the edge, then repeat the whole procedure

    Confirms the fix and catches the common case of a second retaining path behind the first.

    fails by Declaring victory on one iteration — leaks routinely come in pairs, because the same missing teardown covered several subscriptions.

The repetition in steps three and six is the method. A single before-and-after pair is not evidence about memory.

One teardown path, no partial cleanup
1function mountPanel(root: HTMLElement, url: string) {
2 // One controller for everything this component subscribes to.
3 const ac = new AbortController()
4 const { signal } = ac
5
6 // Listeners on targets that outlive the component: the leak-prone case.
7 window.addEventListener('resize', onResize, { signal })
8 document.addEventListener('visibilitychange', onVisible, { signal })
9
10 // Delegated: one listener on a stable container, unaffected by row churn.
11 root.addEventListener('click', onRowClick, { signal })
12
13 // The same signal aborts in-flight work, so a late response cannot
14 // re-attach anything to a subtree that is being discarded.
15 fetch(url, { signal })
16 .then((r) => r.json())
17 .then(render)
18 .catch((e) => { if (e.name !== 'AbortError') report(e) })
19
20 // Observers are NOT covered by the signal — they need explicit disconnect.
21 const ro = new ResizeObserver(onResize)
22 ro.observe(root)
23
24 // Per-node metadata without retaining the node.
25 const measured = new WeakMap<Element, DOMRect>()
26
27 return function unmount() {
28 ac.abort() // every listener above, plus the fetch, in one call
29 ro.disconnect() // the one thing that has to be said separately
30 root.replaceChildren()
31 // `measured` needs no cleanup: its keys are weak.
32 }
33}

The value of the pattern is that teardown is auditable. A reviewer checks that unmount is called and that nothing subscribed without the signal, rather than checking that every addEventListener has a matching removeEventListener somewhere.

How to build it

Most important first.

  • Give every component one teardown path and route every subscription through it. An AbortController per component instance, whose signal is passed to every addEventListener and every fetch, makes cleanup a single abort() call that cannot be partially forgotten (Cancelling a Request Nobody Is Waiting For).
  • Disconnect observers explicitly. IntersectionObserver, ResizeObserver, MutationObserver and PerformanceObserver all hold their targets, and none of them stop when the target is removed.
  • Clear timers and intervals in teardown. A repeating timer whose callback touches a removed node retains it forever and does work on every tick.
  • Never store nodes in module-level arrays, maps or caches. If per-node metadata is genuinely needed, key a WeakMap by the node so the entry disappears with it.
  • Prefer one delegated listener on a stable container to one listener per row. Rows come and go; the container does not (Event Delegation).
  • Do teardown on the events that actually fire. visibilitychange and pagehide are reliable; unload is not, and does not run at all when a renderer is discarded (Persistent Client State).
  • Treat a growing "cache" as suspect until it has a bound. A cache with an eviction policy is a cache; a cache without one is a leak with a nicer name (Leak or Unbounded Cache? The Question That Picks the Fix in Performance).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A detached subtree is not in the accessibility tree, so a screen reader will not read it — but a live region that was detached and re-attached is treated as new, and can re-announce its entire contents (Live Regions and Announcement).
  • The user-facing consequence of retention is the same for everyone and worse for some: as the heap grows, GC pauses lengthen, the main thread stalls, and assistive technology reads a tree that is no longer current (What the Main Thread Owns).
  • Modals and menus are the most common retention source and the most focus-sensitive components on the page. A dialog removed from the DOM but retained by a listener can still hold the focus reference it captured on open, so restoring focus lands on a node that is no longer in the document — and focus() on a detached node silently does nothing (Focus Management).
  • Store the element to return focus to as a reference you re-validate with isConnected before using it, and fall back to a sensible container when it is stale.

What can go wrong

Failure modes
  • A component that adds a resize or scroll listener to window in setup and removes it in a teardown path that never runs on the route it is used from.
  • A framework ref or an event-bus subscription holding a callback that closes over the component root, so unsubscribing is required and forgetting it is invisible.
  • A chart or map library that must be explicitly destroyed. Removing its container element leaves the library's internal handles, its canvas and its full dataset alive.
  • Keeping removed nodes deliberately as a render cache — "we might show it again" — with no bound on how many are kept.
  • A setInterval for polling that survives the view it belonged to, doing work and retaining a subtree on every tick (Retries, and the Duplicate Order).
  • The mitigation failing: cleanup registered on unload, which does not run when the tab is discarded or restored from the back/forward cache.
  • Chasing a leak that is devtools holding console references, or a heap that grows normally between collections. Compare after a forced collection, not between arbitrary moments (Leak or Unbounded Cache? The Question That Picks the Fix in Performance).
What can arrive out of order
  • A response arriving after teardown can re-attach a listener or re-insert a node into a subtree that was being discarded, resurrecting the retention the teardown just removed (Cancelling a Request Nobody Is Waiting For).
  • Observer callbacks are delivered asynchronously, so a ResizeObserver or MutationObserver callback can run after its target was detached and hand your code a node that is no longer in the document.
  • Cleanup and re-initialisation can interleave during a fast route change, leaving two live subscriptions where the code reads as if there is one (Client-Side Routing).
Security
  • A retained subtree retains its content. A form the user filled in, a rendered account number, or a token embedded in a data- attribute stays in the heap after logout, reachable by any script in the page (Third-Party Scripts and the Supply Chain).
  • This is the concrete reason "clear sensitive state on logout" means more than navigating away: navigation within a single-page application does not unload the document, so nothing is discarded automatically (Session Expiry and the Refresh Race).
  • Memory growth is a denial-of-service surface for a long-lived tab. An attacker-influenced input that causes unbounded retention — an unbounded notification list, say — degrades the tab until the browser discards it.
  • Heap snapshots taken from a user's session contain everything in memory, including personal data. Treat them as sensitive artifacts and handle them like session recordings (Session Replay and the Privacy It Costs).
Misreads
  • "Removing an element frees it." It drops the parent's reference. Everything else pointing at it still counts.
  • "Garbage collection means no leaks." A GC reclaims *unreachable* objects. A leak in a managed language is reachable memory nobody will ever use again, which the collector cannot distinguish from memory you need (Memory Leaks: Growth That Does Not Come Back in Performance).
  • "Memory going up is a leak." Heaps grow between collections by design. The signal is the level after a collection, across repetitions of the same workload.
  • "The listener leaked, so remove listeners everywhere." A listener on a node that is itself collected goes with it. What leaks is a listener on a target that outlives the node: window, document, a store, an event bus.
  • "It only holds one node." One node holds its parent, its children and its siblings — which is to say, the whole tree it was part of.

Measuring it, and what changes in the field

How you would see this
  • DOM node count in the Performance panel's memory track, recorded across several repetitions of the same navigation. A sawtooth that returns to baseline is healthy; a staircase that only rises is retention (Debugging Memory).
  • Heap snapshots, filtered to the Detached category, which lists detached nodes and — the part that solves the bug — the retaining path to each one.
  • Allocation sampling to find what is allocating and never releasing, when the leak is not node-shaped (Allocation Rate Is a Cost Even Without a Leak in Performance).
  • A PerformanceObserver on long tasks in the field: lengthening GC pauses over a session are the production signature of a heap that keeps growing (Real User Monitoring).
Slow device, slow network, large data, old tab
  • On a memory-constrained device the same leak reaches the discard threshold far sooner, and the user experiences it as the tab reloading itself and losing their work.
  • In a long session with many navigations, retention compounds linearly with route changes — which is why a single-page application is far more exposed than a page that unloads on every navigation (MPA vs SPA).
  • With a large dataset, one retained node can hold megabytes, because the retained subtree includes every row rendered into it.
  • In a background tab, timers are throttled but not stopped, so a leaking poller leaks more slowly rather than not at all (The Multi-Process Browser).
What this costs
  • Routing every subscription through one AbortController adds ceremony to every component, including the many that would never have leaked. The uniformity is the point: a rule applied selectively is a rule you will forget exactly once.
  • WeakMap and WeakRef remove the retention and remove your guarantee: entries can vanish whenever the collector decides, so anything built on them must tolerate a miss.
  • Aggressively discarding cached DOM avoids retention and costs you rebuild time when the user navigates back. The right bound is a product decision about how far back "back" usually goes (Scroll Restoration).
  • Investigating retention is slow, non-obvious work that produces no visible feature. It is the clearest case in the domain for fixing a class of bug with a convention rather than one instance at a time.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALReachability-based collection, the retaining effect of node parent and sibling pointers, and the listener-closure-node chain are properties of the platform and hold in every engine. Only the timing of collection is implementation-defined.
  • BROWSER-SPECIFICThe tooling is not portable. Chromium's heap snapshots have a Detached category and a retainers pane that names the retaining path; Firefox's memory tool groups by allocation site with a different dominator view; Safari's instrument differs again. The three-snapshot method works in all of them, but the panel names and the filters do not transfer.
  • ENGINE-SPECIFICWhen collection actually runs is up to the engine, so a snapshot taken immediately after a navigation frequently shows retention that a later collection clears. Force a collection before comparing, and never treat a single snapshot as evidence.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Computer Architecturepointer-chasing
Domains that do not exist yet
  • Programming Languages & Runtime Internals — reachability, generational collection and why a pause lengthens as a heap grows are the runtime's side of this same story.
  • Testing & Reliability Engineering — retention is only detectable by repeating a flow and comparing, which makes it one of the few frontend properties that needs a soak test rather than an assertion.