Custom Elements Lifecycle

TL;DR / Custom elements have four lifecycle callbacks — connectedCallback, disconnectedCallback, adoptedCallback, and attributeChangedCallback — invoked by the browser at specific moments in the element's existence.

How It Works

 ┌────────────────┐            ┌────────────────────┐
 │ constructor()  │─────┐      │  attributeChanged  │
 └────────────────┘     └─────→│      Callback      │
          │                    └────────────────────┘
          │
          └─┐                  (can fire before or
            │                   after connection)
            ↓
 ┌────────────────────┐
 │ connectedCallback  │
 └────────────────────┘
            │
            │
            └┐
             │
             ↓
 ┌──────────────────────┐
 │ disconnectedCallback │
 └──────────────────────┘

     -> connectedCallback (re-insert)
     -> adoptedCallback  (adoptNode)

Edit diagram

Custom Elements are the registration and lifecycle layer of Web Components. When you call customElements.define('my-widget', MyWidget), you bind a class to a tag name. From that point on, the browser invokes specific methods on your class at well-defined moments: when instances are created, inserted into the DOM, removed, moved between documents, or when their observed attributes change.

The Constructor

The constructor runs when the element is created — either via document.createElement('my-widget'), via the HTML parser encountering <my-widget>, or via new MyWidget(). The spec imposes strict rules on what the constructor may do. It must call super() first. It must not add attributes or children. It must not read attributes (because they may not be set yet when the parser creates the element). It must not access the DOM tree (because the element may not be connected yet).

The constructor is the place to set up internal state, create the shadow root, and establish event listeners on the shadow DOM. Think of it as a skeleton assembly phase — you build the structure but do not populate it with data.

connectedCallback

This fires when the element is inserted into a document's DOM — specifically, into a document that has a browsing context (a rendered document, not a detached fragment). This is where you should read attributes, fetch data, start observers, and perform setup that requires the element to be in the live DOM.

A critical subtlety: connectedCallback fires every time the element is inserted, not just the first time. Moving an element from one parent to another triggers disconnectedCallback followed by connectedCallback. You must write it to be idempotent or guard against double initialization with a boolean flag (this._initialized).

connectedCallback does not mean children are available. If the parser has not yet parsed the element's children, this.children may return nothing. Defer child access with a microtask (Promise.resolve().then(...)) or a MutationObserver.

disconnectedCallback

This fires when the element is removed from the DOM. It is the cleanup phase: remove event listeners on external elements, disconnect observers, abort fetch requests, clear intervals, and release resources. Failing to clean up causes memory leaks, especially for elements that register global event listeners or subscribe to external state stores.

Like connectedCallback, this fires every time the element is removed — including temporarily during a DOM move. If the element is re-inserted elsewhere, you see disconnectedCallback followed by connectedCallback in quick succession.

attributeChangedCallback

This fires whenever an observed attribute changes, is added, or is removed. You must declare which attributes to observe by defining a static observedAttributes getter that returns an array of attribute names. Only attributes listed here trigger the callback — all others are ignored.

The callback receives three arguments: the attribute name, the old value (or null if the attribute was just added), and the new value (or null if it was removed). This is the primary mechanism for reactive data flow into a custom element from the outside world: setting an attribute in HTML or calling element.setAttribute() triggers this callback, and the element updates its internal state or rendering accordingly.

A common pattern is to reflect properties to attributes and vice versa. You define getters and setters on the class that read from and write to attributes, and you implement attributeChangedCallback to update internal state when attributes change externally. This creates a bidirectional sync between the property API (used by JavaScript) and the attribute API (used by HTML and frameworks).

adoptedCallback

This fires when the element is moved to a new document via document.adoptNode(). This is rare but relevant for iframe communication or moving elements between documents and popup windows. The callback is your opportunity to re-register document-level listeners and update references to document-specific globals.

Registration Timing and Upgrades

If the browser encounters a <my-widget> tag before customElements.define('my-widget', ...) has been called, it creates an HTMLUnknownElement. When the definition is later registered, the browser "upgrades" the element: it changes its prototype, runs the constructor, and fires connectedCallback if the element is already in the DOM. customElements.whenDefined('my-widget') returns a Promise that resolves after the definition is registered, which is useful for coordinating dependent initialization.

Gotchas

  • Constructor restrictions — accessing attributes, children, or parent elements in the constructor throws or returns stale data. Defer all DOM interaction to connectedCallback.
  • Children are not parsed in connectedCallback — when the HTML parser calls connectedCallback, the element's child elements may not exist yet. Use a MutationObserver, slotchange events, or a microtask deferral to safely access children.
  • attributeChangedCallback fires before connectedCallback — if the HTML parser sets attributes on the element, the attribute callback fires during parsing, before the element is connected. Guard against accessing DOM or parent references in this callback before connection.
  • disconnectedCallback is not guaranteed — if the page is navigated away from or the tab is closed, the browser does not fire disconnectedCallback. Do not rely on it for persistent cleanup like saving data to a server.
  • Observed attributes must be strings — the attribute API only supports string values. Complex data (objects, arrays) must be passed via properties or serialized as JSON attribute values.