Observer APIs Keeping Detached Nodes Alive
Your heap snapshot shows detached <div> trees whose retainer paths run through ResizeObserver, IntersectionObserver or MutationObserver internals, even though the components that created them were unmounted long ago. This guide from Detached DOM Nodes and Memory Retention, in the Browser DevTools & Performance Profiling Workflows section, explains how observers hold on to elements and callbacks, and how to tear them down so removed DOM can be collected.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
Detached trees retained via ResizeObserver / ResizeObservation |
Element removed from DOM but never unobserved | Call unobserve(el) or disconnect() on teardown |
Detached count returns to zero after each unmount |
| Observer callbacks still firing for removed components | Observer kept alive by a module-level reference | Scope observers to the component, or unobserve per element | No callbacks after unmount; closures collectable |
Shared IntersectionObserver for lazy images grows its target list |
Images added but never removed from observation | Unobserve on load and on removal | Target list size bounded by visible images |
MutationObserver on document.body retains old subtrees via records |
takeRecords()/callback records hold removed nodes |
Process records promptly; avoid storing them | Old subtrees released after each batch |
| Leak disappears in one browser but not another | Implementations differ in how strongly targets are held | Always unobserve explicitly | Consistent behaviour across engines |
Root Cause: Observations Link Elements, Observers and Callbacks
Every observer API creates three linked pieces: the observer object, its callback (a closure that usually captures component state), and a list of observations — one per observed target. How strongly each link holds varies by API and by browser engine, which is exactly why observer leaks are confusing. What is consistent is the practical outcome: as long as an observer is reachable and still has an element in its observation list, you should assume that element, and everything the callback captures, can stay alive.
Observers become long-lived in two common ways. The first is sharing: for efficiency, applications create one ResizeObserver or IntersectionObserver at module level and call observe() for every element that needs it — every card, every lazy image, every chart container. If components call observe() on mount but never unobserve() on unmount, the shared observer’s list keeps growing with elements that are no longer in the document. Each removed element is now a detached DOM node, and because a detached element keeps its whole subtree and its parent chain up to the removed root, one forgotten unobserve() can retain hundreds of nodes.
The second is closure capture: a per-component observer whose callback references the component instance is kept alive by its targets and by any variable that stores it. If the component stores the observer on itself and the observer’s callback references the component, the cycle is harmless once nothing outside points to it — but a single outside reference, such as the element being cached in a module-level map, keeps the entire cycle alive.
MutationObserver adds one more path: its mutation records reference the nodes that were added and removed. Code that pushes records into an array for later processing, or that never drains takeRecords(), keeps removed subtrees alive through those records. The general detached-node workflow in fixing detached nodes cached in JavaScript variables applies; this page covers the observer-specific links.
Step-by-Step Fix
- Count detached nodes across mounts. In DevTools → Memory, choose Heap snapshot, mount and unmount the suspect component ten times, take a snapshot, and type
Detachedin the Class filter. Newer Chrome versions also offer a Detached elements profile type that lists detached trees directly. Verification: detachedHTMLDivElement(or similar) counts are a multiple of ten. - Read the retainers of a detached root. Select the root element of one detached tree and read the Retainers pane. Verification: the path includes
ResizeObservation,IntersectionObservation, aMutationRecord, or the observer object itself. - Find where the observer lives. Continue up the path until you reach a named variable. Verification: you identify either a module-level shared observer or an observer stored on an object that outlives the component.
- Add explicit teardown. On unmount, call
observer.unobserve(el)for shared observers, orobserver.disconnect()for per-component observers. ForMutationObserver, calldisconnect()and do not keep records. Verification: everyobserve()call in the codebase has a matchingunobserve()ordisconnect()in the teardown path. - Unobserve early where the job is done. Lazy-loading observers can unobserve an image as soon as it loads, and one-shot visibility tracking can unobserve after the first intersection. Verification: the shared observer’s target count equals the number of elements still pending.
- Re-run the ten-mount scenario. Repeat step 1. Verification: the detached filter shows no growing element types, and the observer no longer appears in any detached node’s retainer path.
Command and Code Reference
Use case: a shared ResizeObserver with per-element callbacks and proper teardown. A WeakMap maps elements to handlers so the shared observer does not need its own strong registry.
// resize.js — one observer for the whole app, but every observe has an unobserve
const handlers = new WeakMap(); // element → callback; entries vanish with elements
const ro = new ResizeObserver((entries) => {
for (const entry of entries) handlers.get(entry.target)?.(entry.contentRect);
});
export function watchSize(el, onResize) {
handlers.set(el, onResize);
ro.observe(el);
return function stop() { // call from the component's teardown
ro.unobserve(el); // removes the observation (and its hold on el)
handlers.delete(el); // drops the closure immediately
};
}
Use case: lazy images that unobserve as soon as they load. Keeping only pending images in the target list keeps it small even on endless feeds.
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
const img = e.target;
img.src = img.dataset.src;
io.unobserve(img); // work done: stop observing this element
}
}, { rootMargin: '200px' });
export function lazy(img) {
io.observe(img);
return () => io.unobserve(img); // also unobserve if removed before loading
}
Use case: MutationObserver without retaining removed subtrees. Process records in the callback and keep only derived data.
const counts = { added: 0, removed: 0 }; // derived data, no node refs
const mo = new MutationObserver((records) => {
for (const r of records) {
counts.added += r.addedNodes.length;
counts.removed += r.removedNodes.length; // do NOT push r into an array
}
});
mo.observe(container, { childList: true, subtree: true });
// teardown: mo.disconnect();
Verification and Regression Prevention
After adding teardown, repeat the ten-mount scenario and confirm three things: no growing Detached element types in the snapshot, the observer’s callback no longer runs after unmount (a console.count in development makes this obvious), and the retained size of the module that owns the shared observer stays flat. For lazy-loading observers, scroll a long feed and check that the number of observed targets tracks only the images still waiting to load.
To prevent regressions, wrap observer usage in small helpers like watchSize and lazy above that return their cleanup function, and make returning or calling that function part of your component conventions — a framework effect that calls watchSize must return its stop. Add an automated unmount test using the approach in finding leaks with Memlab scenarios, which reports detached DOM with retainer traces and will flag an observer path immediately.
Edge Cases and Gotchas
disconnect() versus unobserve()
disconnect() stops observing all targets. Calling it on a shared observer when one component unmounts silently breaks every other component that relies on it. Use unobserve(el) for shared observers and reserve disconnect() for observers owned by a single component.
Observers created inside render functions
Creating a new observer on every render — common when an effect lacks dependencies — produces many observers observing the same element, each with its own closure. The DOM holds all of them. Create observers once per component lifetime, not per render.
Elements re-parented rather than removed
Moving an element to a different container keeps its observations. That is usually desired, but if a component moves elements into a hidden “recycling” container instead of removing them, those elements stay observed and never become detached. Unobserve when moving elements out of active use.
Framework wrappers
Hooks and directives such as useResizeObserver or v-intersect usually handle teardown — but only if they are used inside the component’s lifecycle. Calling the underlying observer API directly from a utility function bypasses that protection, so audit direct uses when a detached-node leak points at an observer.
Frequently Asked Questions
Do observers keep observed elements alive?
Treat them as if they do. Specifications and engines differ in how strongly the observation list holds targets, and the callback closure often references the element or its component anyway. Explicitly unobserving or disconnecting on teardown makes behaviour correct in every browser.
Is one shared observer better than many small ones?
For many elements, a shared observer is more efficient because the browser batches notifications. It concentrates responsibility for teardown, though: every element must be unobserved when removed. Per-component observers are simpler to reason about because disconnecting them on unmount cleans up everything at once.
How can I tell which observer retains a node?
Select the detached root in a heap snapshot and follow its retainers. Entries such as ResizeObservation, IntersectionObservation or MutationRecord identify the API, and following further up leads to the variable holding the observer, which tells you which module or component owns it.
Related
- Detached DOM Nodes and Memory Retention — the parent topic
- Finding Detached DOM Nodes in Heap Snapshots Fast — the detection workflow
- Event Listener Leaks and AbortController Cleanup — the equivalent teardown for listeners
- Browser DevTools & Performance Profiling Workflows — the section overview