Disconnecting Observers in disconnectedCallback: Ordering, Idempotence, and the Move Problem

disconnectedCallback looks like a destructor and behaves like nothing of the sort. It fires every time the element leaves a document — including the middle of a move — it can fire twice in a row in some framework teardown paths, it never fires when the page itself is discarded, and it runs after the element has already been unlinked, so anything that reads the DOM from inside it sees a detached node.

Components that treat it as “the place cleanup goes” without accounting for those four facts produce two symptoms in production: observers that keep firing on removed elements, and observers that were torn down during a legitimate move and never came back. This deep dive belongs to Observer Teardown & Memory Safety inside Core Architecture & Lifecycle Management.

Problem Statement: The Component That Stops Working After a Move

class StickyHeader extends HTMLElement {
  // BUG: one observer created once, disconnected forever on the first removal.
  #observer = new IntersectionObserver(
    ([entry]) => this.toggleAttribute('data-pinned', !entry.isIntersecting),
    { threshold: 1 }
  );

  connectedCallback() {
    this.#observer.observe(this);
  }

  disconnectedCallback() {
    this.#observer.disconnect();
  }
}
customElements.define('sticky-header', StickyHeader);
const header = document.querySelector('sticky-header');
const newParent = document.querySelector('#column-two');
newParent.append(header);      // move: disconnect, then connect

After that single line the header never pins again. The move ran disconnectedCallback, which called disconnect() and cleared every observation on the shared observer, then ran connectedCallback, which called observe(this) again — so far so good. But a variant that is just as common fails harder:

  disconnectedCallback() {
    this.#observer.disconnect();
    this.#observer = null;         // "release the reference"
  }

Now the reconnection throws TypeError: Cannot read properties of null (reading 'observe'), and because custom element reactions are invoked from the DOM mutation itself, the exception surfaces as an unhandled error in the middle of an unrelated append call — far from the component that caused it.

What a DOM move does to a component's callbacks A single append call produces a disconnect followed immediately by a connect, so teardown that destroys state permanently breaks the reconnection. newParent.append(header) — one statement, two reactions remove from old parent disconnectedCallback insert into new parent connectedCallback state destroyed? TypeError on reconnect The invariant that fixes both variants Create resources in connectedCallback, destroy them in disconnectedCallback, never at field-initialiser time. Why isConnected is not enough Inside disconnectedCallback the element is already detached, so isConnected is false for a move too.

Root-Cause Analysis

The HTML Standard defines both callbacks as custom element reactions, enqueued on the element’s reaction queue and invoked by the custom element reactions stack at the end of the DOM operation that triggered them. Two consequences follow directly.

First, a move is specified as a removal followed by an insertion. There is no “moved” reaction and no flag distinguishing a move from a real removal at the moment disconnectedCallback runs. this.isConnected is false in both cases, because the element genuinely is detached at that instant — the insertion has not happened yet.

Second, reactions run synchronously inside the mutation. An exception thrown from disconnectedCallback or connectedCallback propagates out of the append call that caused it, which is why a broken teardown reads as an error in the calling code rather than in the component.

The standard also allows disconnectedCallback to be enqueued more than once in some sequences — notably when an element is removed as part of a larger subtree removal that is itself being processed. Teardown must therefore be idempotent: calling it twice must be harmless.

Debugging Pitfall — distinguishing a move from a removal by deferring. The popular workaround is to schedule a microtask from disconnectedCallback and check isConnected, on the theory that a move will have completed by then. It does work, and it introduces a window in which a genuinely removed element still holds live observers. Worse, it makes teardown asynchronous, so a test that removes an element and immediately asserts on listener counts becomes flaky. Prefer symmetric create-and-destroy: rebuilding an observer on reconnect costs microseconds and needs no timing assumptions.

Ordering inside the callback matters less than people expect, with one exception. Aborting an AbortController and disconnecting observers are independent operations that cannot interfere. But cancelling scheduled work must come last, because aborting listeners can synchronously run an abort handler that schedules a final frame. Cancel after abort, not before.

Teardown order inside disconnectedCallback Abort signal-scoped listeners first, then disconnect observers, then cancel timers and animation frames, and finally null the fields. 1. abort() every listener on every target, at once 2. disconnect() Mutation, Resize, Intersection observers 3. cancel work rAF, intervals, pending fetches 4. null fields release the last references held Why this order Aborting can synchronously run abort handlers that schedule one last frame, so cancellation must follow it. Nulling last keeps every teardown call safe to repeat: optional chaining turns a second pass into a no-op.

Production-Safe Pattern

class StickyHeader extends HTMLElement {
  // Declared, never constructed here: field initialisers run once per instance,
  // but the lifecycle can run many times.
  #observer = null;
  #controller = null;
  #frame = 0;

  connectedCallback() {
    this.#controller = new AbortController();
    const { signal } = this.#controller;

    this.#observer = new IntersectionObserver(([entry]) => {
      // A detached target still delivers one final record.
      if (!this.isConnected) return;
      this.toggleAttribute('data-pinned', !entry.isIntersecting);
    }, { threshold: 1 });
    this.#observer.observe(this);

    window.addEventListener('orientationchange', () => this.#remeasure(), { signal });
  }

  disconnectedCallback() {
    // 1. Listeners, on every target, in one call. Optional chaining makes a
    //    second invocation a no-op, satisfying the idempotence requirement.
    this.#controller?.abort();
    this.#controller = null;

    // 2. Observers — no signal support, so each needs an explicit call.
    this.#observer?.disconnect();
    this.#observer = null;

    // 3. Scheduled work last: an abort handler may have queued a final frame.
    if (this.#frame) {
      cancelAnimationFrame(this.#frame);
      this.#frame = 0;
    }
  }

  #remeasure() {
    if (!this.isConnected || this.#frame) return;
    this.#frame = requestAnimationFrame(() => {
      this.#frame = 0;
      this.toggleAttribute('data-narrow', this.clientWidth < 480);
    });
  }
}
customElements.define('sticky-header', StickyHeader);

The shape to internalise is symmetry: every resource is created in connectedCallback and destroyed in disconnectedCallback, nothing is created at field-initialiser time, and every destruction is written so that running it twice changes nothing. A move then costs one observer construction — a few microseconds — and buys immunity from the entire class of move-related bugs.

Note also what is not in disconnectedCallback: no DOM reads, no dispatching of events, no state persistence. The element is detached, so measurements return zero and dispatched events reach nobody. Anything that must observe the removal belongs to the code performing it.

Verification

const header = document.querySelector('sticky-header');

// A move must leave the component fully functional.
document.querySelector('#column-two').append(header);
console.assert(header.isConnected, 'still in the document after a move');

// Idempotence: a second teardown must not throw.
header.disconnectedCallback();
header.disconnectedCallback();

// Removal must leave nothing observing.
const probe = new FinalizationRegistry((tag) => console.log('collected:', tag));
let el = document.createElement('sticky-header');
document.body.append(el);
probe.register(el, 'sticky-header');
el.remove();
el = null;
globalThis.gc?.();

The DevTools confirmation is quicker than the script. Select the element, open the Event Listeners pane, and move the element between parents five times: the listener count must read exactly one after every move. If it reads five, the abort is missing or the controller was reused after being aborted. In the Performance Monitor, the “JS event listeners” counter climbing during navigation while the visible DOM stays flat is the same defect at application scale.

When to Use vs When to Avoid

Cleanup approach Use when Avoid when
Create in connect / destroy in disconnect Always the default Never — this is the baseline
Construct observers as field initialisers Element is never moved and never reconnected Any component in a list, router, or framework tree
Microtask deferral to detect a move A resource is genuinely expensive to rebuild Tests assert synchronously on teardown
unobserve(target) instead of disconnect() One observer watches several targets A per-instance observer — disconnect() is clearer
Cleanup in a beforeunload handler Never for observers Always — page discard needs no cleanup

The last row is worth stating plainly because it appears in real code: releasing memory when the page is being destroyed accomplishes nothing and blocks the browser’s back/forward cache, which requires that no unload or beforeunload handler is registered.

The unobserve row deserves a note as well, because the choice is about ownership rather than efficiency. When a component constructs its own observer and watches only itself, disconnect() is both correct and clearer — there is nothing else to preserve. When a list component owns one observer watching every row, each row leaving the DOM must call unobserve(row) and only the list’s own teardown may call disconnect(). Getting this backwards is a real bug: a row that calls disconnect() on the shared observer silently stops the other ninety-nine rows from being observed, and nothing reports it.

Finally, note what belongs outside teardown entirely. Cancelling an in-flight fetch is best expressed by passing the same AbortSignal to the request rather than tracking the promise, and cancelling a CSS transition or Web Animation is handled by the element’s own removal. Teardown code that grows beyond aborting, disconnecting, and cancelling is usually doing application work in the wrong place.

disconnect versus unobserve, decided by who owns the observer A per-instance observer should be fully disconnected, while a shared list-level observer requires each row to unobserve only itself so the remaining rows keep working. Observer owned by the instance one observer, one target: itself disconnect() in disconnectedCallback nothing else is watching, so tearing everything down is exactly right null the field afterwards Observer owned by a list one observer, one hundred targets a row calling disconnect() blinds all 100 each row calls unobserve(itself); only the list may disconnect() failure is silent, not thrown

Frequently Asked Questions

How do I tell a move from a real removal inside disconnectedCallback?

You cannot, synchronously — the element is detached in both cases and isConnected is false either way. Rather than deferring to find out, make teardown and setup symmetric so that a move simply rebuilds cheap resources on reconnection.

Can disconnectedCallback run twice for one removal?

It can in some subtree-removal sequences, and framework teardown paths sometimes invoke it explicitly. Write teardown to be idempotent — optional chaining plus nulling fields makes the second pass a no-op.

Is it safe to read layout inside disconnectedCallback?

No. The element is already detached, so clientWidth, getBoundingClientRect, and computed styles all report zero or defaults. Capture any measurement you need while the element is still connected.

Does disconnectedCallback run when the user closes the tab?

No. Page discard skips the callback entirely, and that is fine — the whole heap goes away. Never rely on it to persist state; use visibilitychange or pagehide for that.