Make observer handling in custom elements strictly symmetric — everything connectedCallback observes, disconnectedCallback releases — because both run on every move and re-insertion; never tear down permanently on disconnect, and guard asynchronous setup with isConnected so work that finishes after removal does not re-attach observers.

Problem / Scenario Context

A kanban board built from custom elements lets users drag <task-card> elements between columns. Each card uses a ResizeObserver to truncate its description and an IntersectionObserver to lazy-load avatars. After the first drag, moved cards stop truncating and their avatars never load. A developer "fixes" it by removing the cleanup from disconnectedCallback, which makes moves work — and makes the board leak every card that is deleted.

Both bugs come from treating disconnectedCallback as a destructor. The Web Component & Lit Observer Controllers topic introduces the lifecycle; this page nails down cleanup.

Mechanics Explanation

A custom element's lifecycle callbacks are reactions to DOM operations, not a birth-and-death pair:

  • connectedCallback runs each time the element becomes connected to a document: initial insertion, insertion after a move, re-insertion after being removed and kept.
  • disconnectedCallback runs each time it becomes disconnected: removal, and the first half of a move. parent.appendChild(el) on an already-connected el removes it from its old parent (disconnect) and inserts it in the new one (connect).
  • The constructor runs once per element (at creation or upgrade).

So an element may cycle connect → disconnect → connect many times in its life, and a disconnect is only final if nothing re-inserts it — which the element cannot know at the time.

That gives two rules:

  1. Disconnect must release, not destroy. Unobserve or disconnect observers so a removed element is not retained. Keep the observer instances (created in the constructor or lazily) so a later connect can re-observe.
  2. Connect must (re-)establish everything. Anything disconnect released must be restored. The pairing must be symmetric.

Newer engines add moveBefore(), which moves an element without the disconnect/connect pair (calling connectedMoveCallback if defined), but drag-and-drop libraries and frameworks still commonly use appendChild/insertBefore.

One Card's Lifecycle Across Moves and DeletionA timeline for one task card. It is constructed, then connected in the To Do column and observers start. It is dragged to In Progress: a disconnect releases observers and a connect immediately re-observes. Later it is dragged to Done with the same pair. Finally it is deleted: a disconnect releases observers and no connect follows, so the element can be garbage collected.task-card, from creation to deletioncallbacksctorconnectdiscconnectdiscconnectdiscobservingobservingobservingon0time20time40time60time80time100time

Comparison Table: Cleanup Strategies

Strategy Moves work? Deleted elements freed? Verdict
Observe in constructor, never clean up yes no — leak wrong
Observe in connect, disconnect() and null the instance on disconnect no — instance gone yes wrong
Observe in connect, no cleanup yes no — leak wrong
Observe in connect, release on disconnect, keep instance yes yes correct
Shared module observer, observe/unobserve per element yes yes correct, and cheaper

Minimal Reproducible Example

TypeScript
class TaskCard extends HTMLElement {
  #ro: ResizeObserver | null = null;
  connectedCallback(): void {
    if (this.#ro) return;                               // "already set up" — wrong after a move
    this.#ro = new ResizeObserver(() => this.#truncate());
    this.#ro.observe(this);
  }
  disconnectedCallback(): void {
    this.#ro?.disconnect();                             // released…
  }                                                     // …but #ro stays non-null, so connect skips setup
  #truncate(): void { /* … */ }
}

After one move, #ro exists but observes nothing, and connectedCallback returns early. The card never truncates again.

Production-Safe Solution

TypeScript
class TaskCard extends HTMLElement {
  // Instances are created once; observation is per connection.
  #ro = new ResizeObserver(() => this.#truncate());
  #ac: AbortController | null = null;
  #connectedGen = 0;

  connectedCallback(): void {
    this.#ro.observe(this);
    lazyAvatars.observe(this);                         // shared module-level observer
    this.#ac = new AbortController();
    this.addEventListener('pointerdown', this.#onDown, { signal: this.#ac.signal });
    void this.#loadDetails(++this.#connectedGen);
  }

  disconnectedCallback(): void {
    this.#ro.unobserve(this);                           // release, but keep the instance
    lazyAvatars.unobserve(this);
    this.#ac?.abort();                                  // listeners too
    this.#ac = null;
    this.#connectedGen++;                               // invalidate in-flight async work
  }

  async #loadDetails(gen: number): Promise<void> {
    const data = await fetchDetails(this.dataset.id!);
    // Removed (or moved and reconnected) while loading? Only the latest connection may proceed.
    if (gen !== this.#connectedGen || !this.isConnected) return;
    this.#render(data);
  }

  #onDown = (): void => { /* start drag */ };
  #truncate(): void { /* … */ }
  #render(_d: unknown): void { /* … */ }
}

const lazyAvatars = new IntersectionObserver((entries) => {
  for (const e of entries) if (e.isIntersecting) (e.target as HTMLElement).querySelector('img[data-src]')
    ?.setAttribute('src', (e.target as HTMLElement).querySelector('img')!.dataset.src!);
}, { rootMargin: '200px' });

declare function fetchDetails(id: string): Promise<unknown>;
customElements.define('task-card', TaskCard);

connectedCallback observes and subscribes unconditionally; disconnectedCallback releases exactly those things. The generation counter guards asynchronous work: if the card was removed — or moved, which bumps the generation twice — while fetchDetails was in flight, the stale continuation does nothing. Because each connection starts its own load, the moved card still gets its details.

Destructor Thinking Versus Symmetric LifecycleTwo columns. Destructor thinking treats disconnect as final, nulls observer instances, skips setup on reconnect and lets async work finish into a removed element. Symmetric lifecycle keeps instances, observes on every connect, releases on every disconnect, and uses a generation counter so stale async work is ignored.Disconnect as destructorNull or destroy instances on disconnectSkip setup on reconnect: "already done"Async work finishes into a removed elementSymmetric connect / disconnectKeep instances; release observationsObserve and subscribe on every connectGeneration counter discards stale async work

Testing the Lifecycle

These bugs only appear with moves, so tests must move elements:

TypeScript
import { expect, test } from 'vitest';

test('card keeps observing after a move and releases on removal', () => {
  const observed = new Set<Element>();
  globalThis.ResizeObserver = class {
    observe(el: Element) { observed.add(el); }
    unobserve(el: Element) { observed.delete(el); }
    disconnect() { observed.clear(); }
  } as unknown as typeof ResizeObserver;

  const a = document.createElement('div'), b = document.createElement('div');
  document.body.append(a, b);
  const card = document.createElement('task-card');
  a.append(card);
  expect(observed.has(card)).toBe(true);

  b.append(card);                          // move: disconnect then connect
  expect(observed.has(card)).toBe(true);

  card.remove();                           // removal: disconnect only
  expect(observed.has(card)).toBe(false);
});

The same test pattern — mock observer with a set of observed targets, move, remove, assert — catches both the "stops working after move" and the "leaks after delete" regressions. For real-browser confirmation, count live ResizeObserver targets indirectly by measuring detached elements in a heap snapshot after deleting cards, as in finding observer leaks with heap snapshot diffing.

The Move-and-Remove TestFour steps. Replace the observer constructor with a mock that records observed elements. Insert the card and assert it is observed. Move it to another parent and assert it is still observed. Remove it and assert it is no longer observed.1Mock the observerRecord observe and unobserve calls in a Set.2InsertCard is connected; it must be in the Set.3MoveDisconnect then connect; it must still be in the Set.4RemoveDisconnect only; it must be gone from the Set.

Verification Steps

  • Drag cards between columns repeatedly and confirm truncation and avatar loading keep working.
  • Delete cards and confirm with a heap snapshot that detached task-card elements do not accumulate.
  • Delete a card while its details are loading and confirm no errors and no rendering into the detached element.
  • Run the move-and-remove unit test in CI.
  • Test with moveBefore() where supported, confirming the element keeps working without a disconnect.

Common Mistakes to Avoid

  • Guarding setup with "already initialised" flags. They make reconnection skip necessary work.
  • Destroying instances on disconnect. Keep instances; release observations.
  • Forgetting listeners and timers. Observers are one resource among several; an AbortController per connection releases listeners in one call.
  • Trusting async continuations. Check a generation token and isConnected after every await.

FAQ

Does appendChild on a connected element really call disconnectedCallback?

Yes. Moving an element with appendChild or insertBefore removes it from its old position and inserts it in the new one, so disconnectedCallback runs followed by connectedCallback. Only moveBefore, where supported, avoids the pair.

Should I call disconnect() or unobserve(this) in disconnectedCallback?

For an observer owned by this element that observes only this element and its internals, disconnect() is simplest. For a shared observer used by many elements, unobserve(this) only, so other elements keep working.

What is the generation counter for?

It identifies the current connection. Async work started in one connection checks, when it resumes, that the element has not been disconnected or reconnected since; if it has, the result belongs to a stale connection and is discarded.

Is disconnectedCallback called when the page unloads?

Not reliably, and it does not need to be: the whole document and its observers are discarded. The callback matters for removals within a living document, which is where leaks accumulate.

Do frameworks move custom elements or recreate them?

It depends on the framework and on keys. Keyed lists in React, Vue and Lit usually move existing elements when items reorder, triggering disconnect and connect. Unkeyed lists and conditional rendering often destroy and create new elements instead. Symmetric lifecycle handling works correctly in both cases.

What is connectedMoveCallback?

A newer lifecycle callback that runs instead of the disconnect/connect pair when an element is moved with moveBefore. Elements that define it signal that they can handle a move without being torn down, so observers can simply stay attached.


↑ Back to Web Component & Lit Observer Controllers