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.
Table of Contents
- 1. Overview of the four lifecycle callbacks
- 2. connectedCallback in detail
- 3. disconnectedCallback in detail
- 4. observedAttributes and attributeChangedCallback
- 5. The reentrancy trap in attributeChangedCallback
- 6. Timing guarantees: order relative to the constructor
- 7. adoptedCallback: the rarest of the four callbacks
- 8. The upgrade process for already existing elements
- 9. Best practice checklist and lifecycle overview
- 10. Summary
- 11. FAQ
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