Web Platform · DOM and Events

Custom Element Reactions and Their Synchronous Callback Contract

Lifecycle callbacks are queued reactions invoked at the end of the DOM operation that caused them, which makes them synchronous but batched, and explains upgrade ordering.

Removing a custom element from the document calls its disconnectedCallback. The word “calls” hides a queue. The DOM operation does not invoke the method directly: it enqueues a reaction, and reactions are drained when the outermost DOM operation that has a reaction-sensitive boundary completes. The callback still runs before the operation returns to the caller, so from the outside the call looks synchronous. Inside, the ordering is more specific: the reaction runs after the tree has been fully updated, so a callback that inspects the DOM sees the finished result of the whole operation rather than an intermediate state.

What makes an element custom

A custom element is a class registered under a name that contains a hyphen, must not begin with a hyphen, and must not be one of the reserved names. customElements.define('my-widget', MyWidget) installs it in the registry. The hyphen requirement exists to keep the namespace separable from the built-in elements the platform may add later; a name without a hyphen is reserved for the platform.

The class must extend HTMLElement for an autonomous element, or the appropriate interface for a built-in that is being customised through the extends option. Its constructor must call super() and must avoid inspecting or modifying its attributes and children, because the element may be constructed before its attributes exist and before the parser has placed children inside it. Violations are not enforced by a runtime check in every case; the result is an element whose upgrade path differs between parser-created and script-created instances, which is worse than an exception.

Five callbacks form the contract: the constructor, connectedCallback, disconnectedCallback, adoptedCallback, and attributeChangedCallback. The last one fires only for attributes listed by the class’s static observedAttributes getter, which is read at definition time and cached.

Reactions are queued, then drained

The mechanism is the custom element reaction queue. A DOM operation that can affect a custom element is wrapped in a reaction boundary. Every relevant change enqueues a reaction: insertion enqueues the connected reaction, removal enqueues the disconnected reaction, an attribute change enqueues attributeChangedCallback, and adoption enqueues adoptedCallback. When the boundary is left, the queue is processed in order.

Two properties follow. First, the callbacks are synchronous with respect to the caller: appendChild does not return until the connected callbacks for the inserted subtree have run. Second, they are batched with respect to the operation: inserting a fragment containing a hundred custom elements enqueues a hundred reactions and drains them all before returning, one after another.

class Named extends HTMLElement {
  static get observedAttributes() { return ['label']; }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.textContent = this.getAttribute('label') ?? '';
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'label' && this.isConnected) {
      this.shadowRoot.textContent = newValue ?? '';
    }
  }
}

customElements.define('x-named', Named);

const el = document.createElement('x-named');
el.setAttribute('label', 'before');
document.body.append(el);   // connectedCallback runs before append returns
el.setAttribute('label', 'after'); // attributeChangedCallback runs here

The example is careful about isConnected for a reason. attributeChangedCallback runs during upgrade for attributes that already exist, at a moment when the element may not be in the document, and it runs for attributes set before insertion. A callback that assumes a rendered tree does work that gets thrown away or, worse, throws.

The backup queue and reentrancy

A reaction may itself perform DOM operations. Each operation with a reaction boundary pushes a fresh element queue, and when that operation returns, the reactions it enqueued are drained before control comes back to the callback that made the call. A connectedCallback that appends a child custom element therefore sees the child’s connectedCallback before its appendChild call returns, with the child’s own DOM work nested inside the parent’s callback.

That nesting does not apply to a batch inserted by one operation. When a fragment containing a parent and its custom element children is appended, the operation enqueues the parent first and the children after it, so the parent’s connectedCallback runs before any of the children’s. The parent cannot rely on those children having been connected yet; if it needs them initialised first, it must either do the initialisation itself or defer the work. The reverse holds for removal: a child’s disconnectedCallback may run before the parent’s, depending on the order in which the removals were enqueued.

The backup element queue is a separate mechanism. It holds elements whose reactions were enqueued while no reaction boundary was active, which happens for mutations the browser performs without going through a reaction-decorated operation, such as parser and editing changes. Those reactions are drained from a microtask rather than at the end of a DOM call.

Upgrade and the order of existing elements

Defining a name upgrades existing elements with that name. The registry walks the document in shadow-including tree order, and for each matching element it constructs the instance, then enqueues attribute callbacks for observed attributes already present, then enqueues the connected reaction if the element is connected. So the constructor runs first, then the attribute callbacks in the element’s attribute order, then connected.

customElements.upgrade(root) performs the same walk on a detached subtree, which is useful when a fragment is built before insertion and the code needs the instances ready early. customElements.whenDefined(name) returns a promise that resolves once the name is registered, which is the only reliable way to wait for a definition that may be loaded later; the promise resolves with the constructor.

customElements.get(name) returns the constructor or undefined. define throws NotSupportedError if the name is already registered and SyntaxError if the name violates the hyphen rule, so a second definition of the same name is a hard failure rather than a silent override.

Moves are a disconnect plus a connect

Inserting a node that already has a parent removes it first, as described in node identity and structural mutation. Both operations enqueue reactions, so moving a custom element calls disconnectedCallback and then connectedCallback, in that order, inside the single appendChild or insertBefore call. State that assumes one connection per lifetime breaks here.

The same applies to moving an element into a shadow root, moving between documents (adoptedCallback in addition), and replacing a subtree. A component that tears down event listeners in disconnectedCallback and reattaches in connectedCallback handles moves correctly; one that treats disconnect as final does not.

Reactions are not microtasks

It is tempting to model the callbacks as microtasks, since both run “later” relative to the triggering statement. They differ in when. A microtask runs when the JavaScript stack empties; a reaction runs when the DOM operation’s reaction boundary closes, which can be in the middle of a task, before the next statement. In the example above, document.body.append(el) invokes the callback before the following statement executes, and no microtask checkpoint occurs in between.

This matters for code that awaits a promise expecting the callback to have run, and for code that mutates the DOM from inside a callback and expects the resulting reactions to be deferred to a microtask. Neither holds. The reactions are drained as part of the same synchronous operation.

Parsing before definition

An element name with a hyphen that has no registration is parsed as an ordinary element with the HTMLElement interface and no class behaviour, and its attributes and children are preserved exactly as written. When the definition later arrives, those existing elements upgrade in place: the constructor runs, the observed attributes are replayed as callbacks, and connectedCallback fires for each element that is already in the document. Nothing is re-parsed and no markup is lost.

That behaviour is what makes a component library usable with server-rendered markup, but it also defines the failure mode. Before the definition arrives, the element has no shadow root, no styling hooks from its class, and no behaviour. Any code that runs between parsing and definition sees an inert element. customElements.whenDefined is the only supported way to wait for the definition, and a script that queries the element’s properties before that promise resolves sees whatever the unknown element exposes, which is only the standard element surface.

Upgrade also concentrates work. Every element that matches an upgraded definition is constructed and connected during the define call, so defining several components at once concentrates that work into one synchronous step. Elements created after the definition skip the replay entirely and are constructed directly during parsing or document.createElement.

What the contract does not provide

The callbacks carry no information about why they ran. connectedCallback does not say whether the element was just created, upgraded, or moved. attributeChangedCallback does not say whether the change came from the parser or from script. Any code that needs to distinguish those cases must track state itself, typically with a private field set in the constructor.

There is also no callback for “children changed”. A custom element that needs to react to slotted content changing listens for slotchange on its slots, and one that needs to react to light DOM changes uses a MutationObserver, as covered in mutation observer records and delivery. The lifecycle callbacks cover the element’s own connection and attributes, not the shape of its content, and treating them as a general change notification produces components that miss updates.

References

  1. HTML Standard: Custom elements — WHATWG
  2. DOM Standard: Custom element reactions — WHATWG
  3. CustomElementRegistry — MDN Web Docs