skip to content

A custom element registers a window resize listener and starts a fetch in connectedCallback. What goes wrong as the element is moved around the DOM, and how do you make the lifecycle safe?

level: seniorimportance: should knowfreq 45%

answer

  1. there is no move, only remove plus insert
  2. every acquire needs a release
  3. window outlives your element
  4. one controller per connection
  5. no final callback on unload

basics

~20 s

connectedCallback runs again on every reinsertion, so a move stacks up duplicate listeners and duplicate fetches. Everything it registers must be released in disconnectedCallback, and setup must be idempotent — and disconnectedCallback never runs on page unload.

solid answer

~50 s

`connectedCallback` is not a constructor: moving the element — `otherParent.appendChild(el)`, a list reorder, a framework re-parenting a node — removes and reinserts it, so `disconnectedCallback` fires and then `connectedCallback` fires again on the same instance. Anything registered on an external object (a `window` listener, an observer, a timer, an in-flight request) is registered a second time and the first registration leaks, because the element itself holds nothing that garbage collection can clean up while `window` still references your handler. The discipline is symmetric: every acquisition in `connectedCallback` gets a matching release in `disconnectedCallback`. In practice that means creating an `AbortController` per connection, passing its signal to listeners and to `fetch`, and calling `abort()` on disconnect. Two extra facts matter in production: `disconnectedCallback` is not called when the page unloads, and a disconnect may be temporary, so treat it as pause-and-release, not destroy.

go deeper

for a junior

Remember that connectedCallback can run several times on the same element and that anything it sets up outside the element must be torn down in disconnectedCallback.

for a middle

Explain why a move fires disconnect then connect, why a window listener keeps a detached element alive, and how an AbortController signal releases listeners and an in-flight fetch in one call.

for a senior

Show the production judgment: idempotent setup, symmetric teardown, filtering AbortError so aborts do not pollute logs, and distinguishing a temporary move from a real removal before doing expensive teardown.

for a principal

Standardise the contract across a component library — a base class or lint rule enforcing acquire/release symmetry — and be explicit that the platform offers no guaranteed final callback, so anything that must not be lost travels over page-lifecycle machinery instead.

## Why connectedCallback runs more than once The DOM has no move operation. `newParent.appendChild(el)` is defined as removing `el` from wherever it is and inserting it in the new place, and every custom element reaction follows that definition: `disconnectedCallback`, then `connectedCallback`, on the very same instance with the same fields intact. That happens when a list is reordered, when a component moves an element into a portal or a dialog container, when a framework re-parents a node during reconciliation, or when a developer temporarily detaches a subtree to batch mutations. So `connectedCallback` is best read as "you are live again", not "you were born". ## The two failure modes **Duplicate registration.** `window.addEventListener('resize', this.onResize)` in `connectedCallback` adds a second identical registration on the second connect. Because both use the same bound function reference, one `removeEventListener` will not undo two `addEventListener` calls, and the handler now runs twice per resize. After five moves, five times. **Leaks.** The element may be removed for good, but `window` still holds a reference to a handler that closes over the element. Nothing about detaching a node breaks that chain, so the element, its shadow tree and everything it references stay reachable. The element is gone from the page and alive in memory. The same applies to `setInterval`, to any observer you called `observe()` on, and to a subscription on a shared store. **Wasted or racing work.** A `fetch` started in `connectedCallback` keeps running after disconnect. On a move you get two in flight, and their responses can land in either order, so the element renders whichever finished last rather than whichever was requested last. ## The symmetric pattern The cleanest formulation is one `AbortController` per connection, because a single signal covers listeners, observers wired to abort, and requests: ```js class MyPanel extends HTMLElement { #ac = null; connectedCallback() { this.#ac = new AbortController(); const { signal } = this.#ac; window.addEventListener('resize', this.#onResize, { signal }); fetch('/api/panel', { signal }) .then((r) => r.json()) .then((data) => this.#render(data)) .catch((err) => { if (err.name !== 'AbortError') throw err; }); } disconnectedCallback() { this.#ac?.abort(); this.#ac = null; } } ``` On disconnect everything registered under that signal is released in one call, and on the next connect a fresh controller starts a clean generation. The `AbortError` filter matters: aborting a fetch rejects its promise, and an unfiltered rejection becomes an unhandled rejection in your logs on every move. Where a controller does not apply, do the symmetric thing manually: `clearInterval` what you set, `disconnect()` what you observed, unsubscribe what you subscribed. ## Disconnect is not destruction Two consequences people miss. First, a disconnect may be temporary. Do not throw away expensive derived state that you will need again half a millisecond later when the move completes; release *external* resources and keep internal state. If you need to distinguish a real removal from a move, check `this.isConnected` in a microtask or on the next task before doing expensive teardown — a move will have already reconnected by then. Second, `disconnectedCallback` is **not** called when the page unloads, when the tab is closed, or when the document is discarded. There is no guaranteed final callback for a custom element. Anything that must happen before the user leaves — flushing analytics, saving a draft — belongs on the page lifecycle events the platform provides for that purpose, not on element teardown. ## Making setup idempotent Alongside teardown, guard anything in `connectedCallback` that must happen only once or that would overwrite the author's markup: ```js connectedCallback() { if (!this.hasAttribute('role')) this.setAttribute('role', 'region'); if (!this.#rendered) { this.#renderOnce(); this.#rendered = true; } } ``` The combination — idempotent setup plus symmetric teardown — is what makes a custom element survive arbitrary re-parenting by code it has never heard of. That is the real bar, because a component author does not control how consumers move nodes around.

  • Why does adding the same bound handler twice mean one removeEventListener is not enough?
    Each addEventListener call with the same target, type, callback and capture flag would normally be deduplicated — but the leak case is usually a *newly created* function per connect, so the registrations are distinct. Keeping one stable bound reference per instance avoids that; passing an AbortController signal avoids having to reason about it at all.
  • How do you tell a genuine removal apart from a move?
    Do not decide synchronously. In disconnectedCallback, release external resources immediately, and if you have expensive teardown, defer it and re-check `this.isConnected` on the next task — a move will have reconnected the element by then, so `isConnected` is true again and you skip the destructive work.
  • What should a component do when its work must complete before the user leaves the page?
    Not rely on disconnectedCallback, which never fires on unload or tab close. Hook the page lifecycle events the platform provides for that purpose from within connectedCallback, release them on disconnect like any other external registration, and send the payload with a transport designed to survive an unloading document.

saying these in an interview costs you the question

  • Treats connectedCallback as a one-time constructor
  • Registers window listeners with no matching teardown
  • Believes detaching a node is enough for garbage collection
  • Expects disconnectedCallback to run when the tab closes
  • Tears down expensive state on every disconnect, including moves

context