Observer Teardown & Memory Safety
A custom element that observes anything — mutations, resizes, intersections, media queries, the network, the page’s visibility — has entered a contract the platform will not enforce for it. Observers and event listeners hold strong references from long-lived objects to your component instance. document outlives every component on the page. window outlives the document. A ResizeObserver you created in connectedCallback and never disconnected keeps its target, its callback, and every variable that callback closes over alive for as long as the observer itself is reachable.
Nothing in the custom element lifecycle cleans this up. disconnectedCallback runs when the element leaves the document, but it does not undo anything you did in connectedCallback; it merely gives you the moment to do so. Components that skip that work leak in a way that is invisible in a single page view and ruinous in a long-lived application: memory climbs across route changes, callbacks fire on detached nodes, and eventually the same handler runs a dozen times per event because a dozen copies were registered by a dozen reconnections.
This is the parent topic for lifetime management in Core Architecture & Lifecycle Management. It covers what actually retains a component, the four observer families and their teardown APIs, the AbortController pattern that collapses all of them into one call, and how to measure the result rather than hope for it.
AbortController/AbortSignal and the signal option on addEventListener, plus MutationObserver; the Resize Observer, Intersection Observer, and Performance Timeline specifications each define their own disconnect(); and the HTML Standard defines disconnectedCallback as a custom element reaction. Note what none of them define: automatic cleanup. No specification promises that removing an element releases anything it registered.
What Actually Retains a Component
Garbage collection is reachability, not intuition. An object survives if any path of strong references leads to it from a root — the global object, the document, an active execution context. Removing an element from the DOM severs one path; it does nothing about the others.
Four retention paths account for essentially every component leak in production:
- Listeners on objects you do not own.
window.addEventListener('resize', this.#onResize)stores your bound function — which referencesthis— in the window’s listener list. The element is now reachable from the global object. - Observer registrations.
MutationObserver,ResizeObserver,IntersectionObserver, andPerformanceObservereach hold their targets and their callbacks. The observer is reachable from the browser’s internal registry for as long as it has active observations. - Timers and animation frames.
setIntervalkeeps its callback alive indefinitely. ArequestAnimationFrameloop that reschedules itself never stops on its own. - Module-level collections. A
Mapkeyed by element, a registry of “all instances”, a memoisation cache holding rendered fragments — all module scope, all immortal.
The first three have explicit teardown APIs. The fourth needs discipline, or a WeakMap/WeakRef, whose entries do not prevent collection.
append runs disconnectedCallback then connectedCallback, so a component that registers on connect without removing on disconnect gains one listener per move. Anyone who has debugged a modal that submits a form four times has met this.
The Observer Families and Their Teardown
| API | Registers via | Tear down with | Retains | Fires after removal? |
|---|---|---|---|---|
addEventListener |
target, type, callback | removeEventListener or an aborted AbortSignal |
callback, its closure, the target | Yes, if the target is still in the tree |
MutationObserver |
observe(node, options) |
disconnect() |
node, callback, queued records | Yes, for the observed subtree |
ResizeObserver |
observe(element, options) |
unobserve(el) or disconnect() |
element, callback | Only while the element has a box |
IntersectionObserver |
observe(element) |
unobserve(el) or disconnect() |
element, root, callback | Emits a final non-intersecting record |
PerformanceObserver |
observe({ type }) |
disconnect() |
callback, buffered entries | Yes, globally |
matchMedia |
mql.addEventListener('change', …) |
removeEventListener or an AbortSignal |
callback, the query list | Yes, indefinitely |
setInterval / rAF |
id returned | clearInterval / cancelAnimationFrame |
callback | Yes, forever |
Two rows deserve emphasis. matchMedia list objects are commonly stored at module scope so the query is parsed once; every component that adds a listener to that shared object and never removes it is retained by it for the page’s lifetime. And IntersectionObserver deliberately delivers one last record when a target is removed, reporting isIntersecting: false — a callback that assumes a live parent will throw on a detached node.
Production Implementation Pattern
One controller per connection, aborted on disconnect, covering listeners on every target. Observers get an explicit disconnect() because they do not accept a signal.
const REDUCED_MOTION = window.matchMedia('(prefers-reduced-motion: reduce)');
class LiveChart extends HTMLElement {
#controller = null;
#resizeObserver = null;
#intersectionObserver = null;
#frame = 0;
connectedCallback() {
// A fresh controller per connection: reconnecting after a move must not reuse an aborted one.
this.#controller = new AbortController();
const { signal } = this.#controller;
// Listeners on objects that outlive this element — the classic retention path.
window.addEventListener('resize', () => this.#invalidate(), { signal, passive: true });
document.addEventListener('visibilitychange', () => this.#invalidate(), { signal });
REDUCED_MOTION.addEventListener('change', () => this.#invalidate(), { signal });
// Observers hold their targets: each needs its own teardown call.
this.#resizeObserver = new ResizeObserver(() => this.#invalidate());
this.#resizeObserver.observe(this);
this.#intersectionObserver = new IntersectionObserver((entries) => {
// A removed target delivers one final record — guard before touching the DOM.
if (!this.isConnected) return;
for (const entry of entries) {
if (entry.isIntersecting) this.#start();
else this.#stop();
}
}, { threshold: 0.1 });
this.#intersectionObserver.observe(this);
}
disconnectedCallback() {
// 1. Every signal-scoped listener, on every target, in one call.
this.#controller?.abort();
this.#controller = null;
// 2. Observers, which have no signal support.
this.#resizeObserver?.disconnect();
this.#resizeObserver = null;
this.#intersectionObserver?.disconnect();
this.#intersectionObserver = null;
// 3. Scheduled work.
this.#stop();
}
#start() {
if (this.#frame || REDUCED_MOTION.matches) return;
const tick = () => {
this.#draw();
this.#frame = requestAnimationFrame(tick);
};
this.#frame = requestAnimationFrame(tick);
}
#stop() {
if (!this.#frame) return;
cancelAnimationFrame(this.#frame);
this.#frame = 0;
}
#invalidate() { if (this.isConnected) this.#draw(); }
#draw() { /* paint into the component's canvas */ }
}
customElements.define('live-chart', LiveChart);
Three properties make this correct rather than merely tidy. The controller is created in connectedCallback and nulled in disconnectedCallback, so a moved element gets a fresh one instead of registering against an already-aborted signal — a silent no-op that would leave the component deaf. Observer fields are nulled after disconnecting, releasing the last reference the component holds to them. And the rAF loop is cancelled rather than left to notice that the element is gone, because it never would.
Common Failure Modes & Debugging Steps
1. The handler fires N times after N navigations. Registration happens on every connection, removal on none — or removal is attempted with a fresh bind() result that matches nothing. Diagnose by selecting the target in DevTools and reading the Event Listeners pane after several mount/unmount cycles; the count should be constant. Fix by scoping every registration to a per-connection AbortSignal.
2. The component goes inert after being moved once. The AbortController was constructed as a class-field initialiser, so the first disconnectedCallback aborted it permanently and every later registration against that dead signal was a silent no-op. There is no error to find — the console is clean and the component simply stops responding. Fix by constructing the controller inside connectedCallback.
3. A callback throws on a detached node. IntersectionObserver delivers a final non-intersecting record for a removed target, and MutationObserver may still hold queued records when you disconnect. Any callback that walks up to a parent or measures layout will throw on that last delivery. Guard with an isConnected check as the first statement, and disconnect the observer in disconnectedCallback so no further records are produced.
4. Memory climbs but listener counts look fine. The retention is not a listener; it is a module-level collection. A Map keyed by element, an “all instances” registry used for a broadcast API, or a memoisation cache holding rendered fragments will each hold the component and, through it, an entire detached subtree. Switch to a WeakMap, or clear the entry in disconnectedCallback alongside the abort.
5. The console reports a ResizeObserver loop error. This is not a leak at all — it is a layout feedback cycle, and disconnecting the observer will not fix it. The callback wrote a style that changed the box it observes. The diagnosis and the three structural fixes are in ResizeObserver loop errors in components.
Verifying Teardown Instead of Assuming It
Leaks are measurable, and the measurement belongs in CI. The cheapest reliable signal is a FinalizationRegistry probe in a test that forces collection:
// Run with a runtime that exposes GC (node --expose-gc, or Chrome with --js-flags="--expose-gc").
const collected = [];
const registry = new FinalizationRegistry((tag) => collected.push(tag));
let el = document.createElement('live-chart');
document.body.append(el);
registry.register(el, 'live-chart');
el.remove();
el = null;
await new Promise((resolve) => setTimeout(resolve, 0));
globalThis.gc();
await new Promise((resolve) => setTimeout(resolve, 0));
console.assert(collected.includes('live-chart'), 'component must be collectable after removal');
In Chrome DevTools the manual equivalent takes a minute: open Memory, take a heap snapshot, exercise the route that mounts and unmounts the component twenty times, take a second snapshot, and compare with “Objects allocated between snapshots”. Filter by the constructor name. Twenty retained instances with a retaining path through window or an observer registry names the exact listener you forgot. The Performance Monitor’s “DOM Nodes” counter climbing while the visible page stays constant is the same story with less setup, and is covered further in avoiding leaks from event listeners on window.
Performance & Memory Implications
Leaks are the headline cost, but observers have a running cost too, and it is easy to pay it many times over.
Observer count is not free. Every ResizeObserver instance participates in the browser’s post-layout delivery step. A hundred list rows each constructing their own observer means a hundred registrations gathered and compared on every frame that changes layout. One observer at the list level, observing all hundred targets and dispatching by entry.target, does the same work with a single registration — the standard supports many targets per observer precisely for this. The same applies to IntersectionObserver, where a shared observer with a shared root is measurably cheaper than one per item.
Callback cost lands in the frame budget. Resize and intersection callbacks run inside the rendering steps, after layout and before paint. Work done there delays the frame directly. Keep callbacks to reading entry fields and writing to descendants; defer anything expensive — network requests, large DOM construction, canvas redraws of off-screen content — to an idle callback or the next frame.
Detached subtrees are the expensive part of a leak. A leaked component is a few hundred bytes. The DOM subtree it holds through a cached querySelectorAll result, or through its own shadow root, is often tens of kilobytes with attached styles and layout objects. This is why a “small” listener leak shows up as tens of megabytes after an hour of navigation: the retained graph, not the retained object, is what fills the heap.
Measure the count, not the feeling. Chrome’s Performance Monitor exposes live counters for JS heap size, DOM nodes, and event listeners. Exercising a route ten times and watching all three return to their starting values is a thirty-second test that catches almost every teardown defect. When one of them ratchets upward, the heap snapshot’s retaining path names the culprit exactly, as described in avoiding leaks from event listeners on window.
Browser Compatibility & Support Floor
| API | Chromium | Firefox | Safari |
|---|---|---|---|
AbortController / AbortSignal |
66 | 57 | 12.1 |
signal option on addEventListener |
90 | 86 | 15 |
AbortSignal.any() |
116 | 124 | 17.4 |
ResizeObserver |
64 | 69 | 13.1 |
IntersectionObserver |
51 | 55 | 12.1 |
FinalizationRegistry / WeakRef |
84 | 79 | 14.1 |
The one member worth a fallback is AbortSignal.any(), and it is three lines: register a once: true abort listener on the outer signal that aborts the inner controller. Everything else in the teardown toolkit is available everywhere a component library realistically ships. FinalizationRegistry matters only in tests, and its callbacks are explicitly non-deterministic — a test that asserts collection must force GC, which means running the suite with the flag that exposes it rather than relying on incidental collection.
Framework Interop
React unmounts a component by removing its host node, which fires disconnectedCallback normally — but React 18’s Strict Mode deliberately mounts, unmounts, and remounts in development, so a component with unbalanced teardown shows its duplicate-listener bug immediately. That is a feature: treat a Strict Mode double-fire as a failing test rather than noise.
Vue’s <keep-alive> and Angular’s structural directives both detach and reattach real DOM nodes, producing exactly the move sequence the pattern above handles. Angular’s zone patching wraps addEventListener, and the signal option passes through cleanly; no adapter is required. Server rendering never runs either callback, so teardown code must not be the only place a component establishes invariants — see avoiding hydration mismatches.
Testing frameworks introduce their own wrinkle. A JSDOM-based test environment implements MutationObserver but historically stubs or omits ResizeObserver and IntersectionObserver, so a component that constructs one unconditionally throws during setup and the failure is reported as an unrelated render error. Guard construction with a capability check — typeof ResizeObserver === 'function' — and treat the absence as “no measurement available” rather than an error. That guard is not test-only accommodation: the same branch is what keeps the component working in a server-side render pass and in the reduced JavaScript environments discussed in polyfilling custom elements in legacy browsers.
Finally, treat teardown as part of the component’s public contract, not an implementation detail. A design system that documents “every component releases all listeners and observers on disconnection, and is safe to move” gives application teams a guarantee they can rely on when building routers and virtualised lists — and gives the library a testable property that belongs in its contract suite alongside event payloads and slot behaviour.
Frequently Asked Questions
Does removing an element automatically remove its event listeners?
Listeners attached to the element itself become unreachable along with it, so they are collected. Listeners the element attached to window, document, a matchMedia list, or any other surviving object are not — those objects still hold the callback, and through it, the component.
Is disconnectedCallback guaranteed to run?
It runs whenever the element is removed from a document, including during a move. It does not run when the whole page is discarded, and it does not run on the server. Never put logic there that the application depends on for correctness beyond releasing resources.
Should I use WeakRef for component registries?
For a module-level registry of instances, yes — a WeakMap keyed by element, or a Set of WeakRefs, lets entries disappear when the component does. For per-instance state, plain private fields are already collected with the instance and need no ceremony.
Why does my observer callback still fire after removal?
IntersectionObserver delivers a final non-intersecting record for a removed target, and MutationObserver may still have queued records. Guard callbacks with an isConnected check and disconnect the observer in disconnectedCallback so no further records are produced.
Related
- Core Architecture & Lifecycle Management — the parent section for construction and lifecycle.
- Lifecycle Callbacks Deep Dive — when each callback runs and what is guaranteed.
- Disconnecting Observers in disconnectedCallback — the ordering and idempotence rules.
- Using AbortController to Scope Listeners — one signal for every registration.
- ResizeObserver Loop Errors in Components — the console error that teardown alone will not fix.