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.

Retention paths that keep a detached component alive Window, document and module scope hold strong references through listeners, observers, timers and caches, so a removed element stays reachable until each path is severed. detached component removed from the document but still reachable window resize / scroll listener document delegated click handler observer registry Resize / Intersection module scope cache, timer id One cut severs all four Register every listener with an AbortSignal and disconnect every observer in disconnectedCallback.
Spec authority. Teardown obligations are distributed across several standards: the DOM Standard defines 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:

  1. Listeners on objects you do not own. window.addEventListener('resize', this.#onResize) stores your bound function — which references this — in the window’s listener list. The element is now reachable from the global object.
  2. Observer registrations. MutationObserver, ResizeObserver, IntersectionObserver, and PerformanceObserver each hold their targets and their callbacks. The observer is reachable from the browser’s internal registry for as long as it has active observations.
  3. Timers and animation frames. setInterval keeps its callback alive indefinitely. A requestAnimationFrame loop that reschedules itself never stops on its own.
  4. Module-level collections. A Map keyed 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.

Debugging Pitfall — the leak that looks like a duplicate-event bug. The first symptom of missing teardown is usually not memory at all: it is a handler firing two, three, then ten times for one user action. Moving an element with 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.

Registration and teardown across a component move Connect registers with a fresh AbortController, disconnect aborts it, and a move produces a disconnect and reconnect pair that must balance exactly. connect move: disconnect move: reconnect remove new AbortController listeners = 1 controller.abort() listeners = 0 fresh controller listeners = 1 abort + null out collectable Without the abort at the orange steps, each reconnect adds a listener: 1, 2, 3, 4 — and the handler fires that many times.

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.

Five teardown symptoms mapped to their real cause Duplicate handlers point to missing removal, an inert component to a reused controller, a throw on a detached node to a final observer record, silent heap growth to module-level caches, and a loop error to layout feedback rather than a leak. What you observe → what is actually wrong handler fires N times after N navigations registration without matching removal component goes inert after one move controller reused after being aborted callback throws on a detached node final observer record after removal heap grows, listener count is flat module-level cache holds the subtree ResizeObserver loop error not a leak: layout feedback cycle

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.