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 nodes are gone from the page, so why does memory keep growing?
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.
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.
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.
- 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
windowordocumentthat closes over a component's root element outlives every navigation, becausewindownever 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.
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,WeakRefandFinalizationRegistryare the escape hatches: aWeakMapkeyed 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
datanode 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
Mapor a framework store in place of the listener list.
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).
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
window.addEventListener('resize', h) with no removal | Heap and node count rise on every route change | window is a GC root and holds the handler, which closes over the component root | Pass an AbortController signal to every listener and abort once in teardown. |
A ResizeObserver or IntersectionObserver never disconnected | Detached nodes accumulate; callbacks fire for elements that are gone | Observers hold strong references to their observed targets | observer.disconnect() in the same teardown path as everything else. |
setInterval polling that outlives its view | Network requests continue after leaving the page; memory climbs | The timer registry holds the callback, which holds the subtree | clearInterval 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 visit | The library holds its own canvas, dataset and internal handles | Call 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 browsed | An unbounded cache keyed by id, holding DOM | Bound it with an eviction policy, or key a WeakMap by the node instead of storing the node. |
| An event-bus or store subscription without unsubscribe | Stale components react to updates and throw on detached nodes | The store holds the callback, which holds the component | Return an unsubscribe from every subscribe, and call it in teardown (State Synchronization). |
console.log(node) while investigating | The node is retained and nothing you fix helps | Devtools holds references to logged objects | Clear the console, force a collection, take a fresh snapshot before concluding anything. |
| Comparing two arbitrary heap readings | "Memory is growing" with no reproducible leak | Heaps grow between collections by design | Force 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.
- 1Reach 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.
- 2Force 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.
- 3Exercise 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.
- 4Return 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.
- 5Diff, 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.
- 6Break 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.
1function mountPanel(root: HTMLElement, url: string) {2 // One controller for everything this component subscribes to.3 const ac = new AbortController()4 const { signal } = ac5 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 cannot14 // 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 call29 ro.disconnect() // the one thing that has to be said separately30 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
AbortControllerper component instance, whosesignalis passed to everyaddEventListenerand everyfetch, makes cleanup a singleabort()call that cannot be partially forgotten (Cancelling a Request Nobody Is Waiting For). - Disconnect observers explicitly.
IntersectionObserver,ResizeObserver,MutationObserverandPerformanceObserverall 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
WeakMapby 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.
visibilitychangeandpagehideare reliable;unloadis 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
isConnectedbefore using it, and fall back to a sensible container when it is stale.
What can go wrong
- A component that adds a
resizeorscrolllistener towindowin setup and removes it in a teardown path that never runs on the route it is used from. - A framework
refor 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
setIntervalfor 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).
- 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
ResizeObserverorMutationObservercallback 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).
- 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).
- "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
- 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
PerformanceObserveron long tasks in the field: lengthening GC pauses over a session are the production signature of a heap that keeps growing (Real User Monitoring).
- 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).
- Routing every subscription through one
AbortControlleradds 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. WeakMapandWeakRefremove 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.
- — 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.