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:
connectedCallbackruns each time the element becomes connected to a document: initial insertion, insertion after a move, re-insertion after being removed and kept.disconnectedCallbackruns each time it becomes disconnected: removal, and the first half of a move.parent.appendChild(el)on an already-connectedelremoves 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:
- 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.
- 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.
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
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
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.
Testing the Lifecycle
These bugs only appear with moves, so tests must move elements:
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.
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-cardelements 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
AbortControllerper connection releases listeners in one call. - Trusting async continuations. Check a generation token and
isConnectedafter everyawait.
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.
Related
- Lazy Upgrading Custom Elements on Visibility — the upgrade side of the lifecycle
- Cleaning Up Observers in Svelte and Solid Lifecycles — the same discipline in frameworks
- Unobserve vs Disconnect: When to Use Each — choosing the release call
↑ Back to Web Component & Lit Observer Controllers