Custom Elements Lifecycle: attributeChangedCallback and Common Pitfalls
AI generated
JS
() =>
JavaScript · Web Components · Custom Elements
Custom Elements Lifecycle: Pitfalls in Detail
attributeChangedCallback, reentrancy and the timing guarantees of the four lifecycle callbacks

Custom elements come with four lifecycle callbacks that look simple at first glance but carry a number of non obvious timing guarantees and pitfalls in practice. This article covers connectedCallback, disconnectedCallback, attributeChangedCallback, and adoptedCallback in detail, including the often forgotten observedAttributes and the reentrancy trap.

16 min read Web Components Custom Elements Lifecycle

1. Overview of the four lifecycle callbacks

The Custom Elements API v1 defines four lifecycle callbacks that a class may optionally implement when it extends HTMLElement and is registered with customElements.define: connectedCallback, disconnectedCallback, attributeChangedCallback, and adoptedCallback. Each of these callbacks is automatically called by the browser at a clearly defined point in an element's lifecycle.

At first glance the concept looks simple and similar to lifecycle hooks in frameworks, but the exact timing guarantees, the order relative to the constructor, and a few edge cases like reentrancy regularly cause bugs in practice that only become understandable after a closer look at the specification.

2. connectedCallback in detail

connectedCallback is called every time the element is inserted into a document that is connected to the DOM. A common pitfall is assuming this callback only runs once per element. In reality it fires again whenever an element is removed from the DOM and then inserted elsewhere, for example when moved between two containers.

Initialization logic that should only run once per element, such as creating a shadow DOM, should therefore be guarded by a private flag, while logic that reasonably needs to rerun every time the element connects to the DOM, such as adding event listeners to document or window, may stay directly in the callback.

3. disconnectedCallback in detail

disconnectedCallback is called as soon as the element is removed from the connected DOM, and is the right place to clean up event listeners, timers, or observers registered in connectedCallback. Without this cleanup, memory leaks arise easily, especially with listeners on document or window that would otherwise keep living independently of the actual element.

A lesser known pitfall: disconnectedCallback also fires during purely internal DOM reparenting, when an element is briefly removed and immediately reinserted, for example by certain DOM manipulation libraries. Anyone who then runs unnecessarily expensive teardown code wastes computation on a state that gets rebuilt immediately anyway by the following connectedCallback.

4. observedAttributes and attributeChangedCallback

attributeChangedCallback is called exclusively for attributes explicitly listed as an array of attribute names in the static observedAttributes getter. Probably the most common pitfall when working with custom elements is forgetting this getter, which means attributeChangedCallback never fires even though the attribute visibly changes in the DOM, with no error message pointing to the cause.

The callback receives three parameters: the name of the changed attribute, the old value, and the new value, each as a string or null, since HTML attributes are fundamentally string based. For typed internal state, the string therefore needs to be explicitly converted to the desired type, for example with Number() or a comparison against the empty string for boolean attributes.


class StatusBadge extends HTMLElement {
  static get observedAttributes() {
    return ["status"]; // without this getter, the callback would never fire
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === "status" && oldValue !== newValue) {
      this.textContent = `Status: ${newValue}`;
      this.className = `badge badge-${newValue}`;
    }
  }
}

customElements.define("status-badge", StatusBadge);
// <status-badge status="active"></status-badge>
// later setAttribute("status", "closed") triggers the callback

5. The reentrancy trap in attributeChangedCallback

A subtle but practically relevant trap occurs when attributeChangedCallback itself sets an observed attribute via setAttribute. This synchronously triggers another call to attributeChangedCallback before the original call has finished, which can lead to unexpected recursion and, in the worst case, a stack overflow.

The usual safeguard is comparing oldValue and newValue at the start of the callback, combined with an early return once the value has not actually changed, plus an internal guard flag for cases where the callback deliberately needs to update a different attribute as a side effect.


class RangeSlider extends HTMLElement {
  static get observedAttributes() {
    return ["value", "max"];
  }

  #updating = false;

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue || this.#updating) return;

    this.#updating = true;
    try {
      if (name === "value" && Number(newValue) > Number(this.getAttribute("max"))) {
        this.setAttribute("value", this.getAttribute("max")); // triggers again
      }
    } finally {
      this.#updating = false;
    }
  }
}

6. Timing guarantees: order relative to the constructor

attributeChangedCallback runs synchronously on every attribute change. Importantly, it can also fire for attributes already set while the HTML is being parsed, and it can run before connectedCallback, in some cases even before the constructor has fully finished, when the element was already present in the markup before registration and gets upgraded later.

This ordering has an important consequence for initialization code: access to this.shadowRoot or to child elements should not happen in the constructor or directly in the first attributeChangedCallback, but only in connectedCallback, since only at that point is it guaranteed that the element is fully anchored in the DOM and its internal structure is complete.


class SafeInit extends HTMLElement {
  static get observedAttributes() { return ["label"]; }

  constructor() {
    super();
    // only set base state here, no DOM access to children
    this._label = "";
  }

  attributeChangedCallback(name, oldValue, newValue) {
    this._label = newValue; // can run before connectedCallback
  }

  connectedCallback() {
    // safe place for DOM setup, this._label is already set
    this.textContent = this._label;
  }
}

7. adoptedCallback: the rarest of the four callbacks

adoptedCallback fires when an element is taken over into another document via document.adoptNode, for example when moving an element between an iframe and the main document. In the vast majority of applications, this case practically never occurs, which is why the callback is implemented far less often than the other three in practice.

adoptedCallback becomes relevant mainly in multi document applications, such as editors with multiple windows or applications that deliberately move content between several iframes. Without an implementation, the element usually stays functional but may lose references to document specific resources of the original document.

8. The upgrade process for already existing elements

If customElements.define is only called after matching tags already exist in the HTML markup, the browser runs a so called upgrade process: all matching, already present elements retroactively get their class instance assigned and their lifecycle callbacks are caught up in the correct order.

One pitfall here is that attributes already present in the markup before the upgrade trigger an initial attributeChangedCallback call for every attribute listed in observedAttributes during the upgrade, with null as oldValue. Code that assumes an attribute already has a meaningful old history on the first callback call needs to explicitly account for this case.

9. Best practice checklist and lifecycle overview

As a summary: never forget observedAttributes when attributeChangedCallback is going to be used, always perform DOM access to child elements in connectedCallback, consistently use disconnectedCallback for cleanup work, and always use a value comparison or a guard flag for recursive setAttribute calls inside attributeChangedCallback.

The following table summarizes all four callbacks with what triggers them and the most common pitfall, as a quick reference when building custom elements.


class Checklist extends HTMLElement {
  static get observedAttributes() { return ["title"]; }

  #initialized = false;

  connectedCallback() {
    if (!this.#initialized) {
      this.attachShadow({ mode: "open" });
      this.#initialized = true;
    }
    document.addEventListener("keydown", this.#onKeydown);
  }

  disconnectedCallback() {
    document.removeEventListener("keydown", this.#onKeydown);
  }

  #onKeydown = (event) => {
    if (event.key === "Escape") this.remove();
  };
}
Callback Triggered By Common Pitfall Typical Use
connectedCallback Element is inserted into the connected DOM Runs multiple times, not just once Building shadow DOM, registering listeners
disconnectedCallback Element is removed from the DOM Also fires during brief reparenting Cleaning up listeners and timers
attributeChangedCallback An observed attribute changes Forgetting observedAttributes Syncing internal state from attributes
adoptedCallback Element is taken over into another document Rarely tested, easy to overlook Multi document and iframe scenarios
constructor Element is instantiated No DOM access to children possible Initializing base state and private fields

Mironsoft

Modern browser APIs, performance, and maintainable JavaScript

JavaScript that holds up in the real browser, not just in the tutorial?

We review existing frontend code for outdated patterns, unnecessary dependencies, and performance traps, then replace them with modern, native browser APIs that mean less bundle weight and less maintenance burden.

Code Review

Systematically finding outdated patterns, unnecessary dependencies, and memory leaks.

Performance Optimization

Improving bundle size, load time, and runtime performance with modern APIs.

Modernization

Deliberately introducing native browser APIs instead of heavy libraries.

10. Summary

Custom Elements Lifecycle: The Essentials at a Glance

connectedCallback

Runs on every DOM insertion, not just the first time

attributeChangedCallback

Requires observedAttributes, otherwise the callback never fires

Reentrancy

setAttribute inside the callback can recursively trigger it again

Timing

DOM access to children belongs in connectedCallback, not the constructor

11. FAQ: Custom Elements Lifecycle: The Essentials at a Glance

1Which lifecycle callbacks does a custom element have?
The Custom Elements API v1 defines four lifecycle callbacks: connectedCallback, disconnectedCallback, attributeChangedCallback, and adoptedCallback, each automatically called at a clearly defined point.
2Does connectedCallback only run once per element?
No, connectedCallback runs every time the element is inserted into a document connected to the DOM, including after being removed and reinserted.
3Why is my attributeChangedCallback never called?
The most common reason is a missing or incomplete static get observedAttributes getter. Only attribute names listed there trigger the callback at all.
4What parameters does attributeChangedCallback receive?
The callback receives the name of the changed attribute plus the old and new value, each as a string or null, since HTML attributes are fundamentally string based.
5What is the reentrancy trap in attributeChangedCallback?
If the callback itself sets an observed attribute via setAttribute, it is called again synchronously before the original call finishes, which can lead to unexpected recursion.
6Can attributeChangedCallback run before connectedCallback?
Yes, attributeChangedCallback can already fire while the HTML markup is being parsed, before connectedCallback runs, which makes DOM access to children risky there.
7What is disconnectedCallback the right place for?
disconnectedCallback is the right place to clean up event listeners, timers, or observers previously registered in connectedCallback, in order to avoid memory leaks.
8When does adoptedCallback fire?
adoptedCallback fires when an element is taken over into another document via document.adoptNode, for example when moved between an iframe and the main document.
9What happens when already existing elements get upgraded?
If customElements.define is called only after matching tags have already been inserted, those elements retroactively get their class instance assigned and go through an initial attributeChangedCallback with null as oldValue for every observed attribute.
10Where should DOM access to child elements happen?
DOM access to child elements or this.shadowRoot should happen in connectedCallback, not in the constructor or the first attributeChangedCallback, since only there is a complete setup guaranteed.