A custom element has five lifecycle callbacks, and most of the bugs people hit come from treating only two of them as real: constructor and connectedCallback. The other three — disconnectedCallback, attributeChangedCallback, and the rarely-seen adoptedCallback — are where resource management and cleanup live. This step walks through the full set, the timing between them, and the listener that outlives its element because nobody removed it.
Learning Objectives
Master all lifecycle callbacks and their purposes
Understand timing and sequencing of callbacks
Implement proper cleanup patterns
Handle edge cases and memory management
Lifecycle Callbacks Overview
Custom elements have five lifecycle callbacks that allow you to hook into different stages of an element's life. Understanding when and how each callback is triggered is crucial for building robust components.
The Five Lifecycle Callbacks
constructor() - Element is created (instantiated)
connectedCallback() - Element added to the DOM
disconnectedCallback() - Element removed from the DOM
adoptedCallback() - Element moved to new document (rare)
Callback Timing and Flow
// Creation flow:
// 1. constructor() - Element instantiated
// 2. attributeChangedCallback() - For any attributes set initially
// 3. connectedCallback() - When added to DOM
const element = document.createElement('my-element'); // constructor()
element.setAttribute('value', '42'); // attributeChangedCallback()
document.body.appendChild(element); // connectedCallback()
// Removal flow:
document.body.removeChild(element); // disconnectedCallback()
// Attribute change flow (while connected):
element.setAttribute('value', '100'); // attributeChangedCallback()
The fourth callback
adoptedCallback is the one nobody mentions, because it fires rarely: only when an element is moved into a different document, such as document.adoptNode() into an iframe. It matters when it happens, because document-scoped things the element captured earlier — observers, stylesheets, references to document — are now pointing at the wrong document.
adoptedCallback(oldDocument, newDocument) {
// Re-acquire anything that was scoped to the old document.
}
Constructor Rules and Restrictions
The constructor has strict rules defined by the HTML specification. Following these rules prevents errors and ensures consistent behavior.
Correct Constructor Pattern
class MyElement extends HTMLElement {
constructor() {
super(); // Must be first!
// ✅ Initialize private state
this._isConnected = false;
this._data = {};
this._value = 0;
// ✅ Set up internal references
this._handleResize = this._handleResize.bind(this);
// ❌ Don't do this in constructor:
// this.innerHTML = '...'; // DOM manipulation
// this.getAttribute('attr'); // Reading attributes
// this.setAttribute('attr', 'val'); // Setting attributes
// this.appendChild(child); // Adding children
}
connectedCallback() {
// ✅ Do DOM work here instead
this.render();
this.attachEventListeners();
}
}
Connected vs Disconnected Callbacks
These callbacks are called when an element is added to or removed from the document. They're your opportunity to set up and tear down resources.
The attributeChangedCallback is only called for attributes listed in observedAttributes. This improves performance by avoiding unnecessary callbacks.
Observing Attributes
class MyElement extends HTMLElement {
// Define which attributes to observe
static get observedAttributes() {
return ['value', 'disabled', 'theme'];
}
attributeChangedCallback(name, oldValue, newValue) {
// Called when value, disabled, or theme changes
console.log(`${name} changed from "${oldValue}" to "${newValue}"`);
// Update based on which attribute changed
switch(name) {
case 'value':
this._value = parseFloat(newValue) || 0;
this.updateDisplay();
break;
case 'disabled':
this._disabled = newValue !== null;
this.updateState();
break;
case 'theme':
this.updateTheme(newValue);
break;
}
}
// Property setter that reflects to attribute
set value(val) {
this.setAttribute('value', val); // Triggers attributeChangedCallback
}
}
Demo 1: Lifecycle Visualization
Watch lifecycle callbacks fire in real-time as you interact with components. This demo logs every callback execution so you can see the exact order and timing.
The shape of a logging element
A sketch of the idea — every callback announces itself. The frame below runs the same pattern across three different elements (auto-clock, resize-tracker, network-status) logging straight to the console.
Buttons drive a real auto-clock element through create, add, move, attribute change, and remove. A resize tracker and a network status indicator can each be toggled on and off the same way. Every callback logs to the console below the frame.
Resource Management Patterns
Proper resource management is critical for preventing memory leaks and ensuring components behave correctly when added, removed, and re-added to the DOM.
A clock, a resize tracker, and a network status indicator all run in the frame above. The three samples here are teaching sketches of the same three patterns, not the demo's code: the real file formats its clock differently, puts the resize tracker in a shadow root, and — for the network status — uses the single AbortController shown below rather than the paired removeEventListener calls above. Open the frame's view-source button to compare.
Memory Leak Prevention
Memory leaks occur when components hold references that prevent garbage collection. Always clean up resources in disconnectedCallback.
Common Memory Leak Sources
Event listeners on window/document - Use removeEventListener
Timers (setTimeout, setInterval) - Use clearTimeout or clearInterval
External references - Set to null or use WeakMap
Cleanup Checklist Pattern
class WellBehavedElement extends HTMLElement {
connectedCallback() {
// Set up resources
this._interval = setInterval(() => this.update(), 1000);
this._observer = new MutationObserver(() => this.handleMutation());
this._observer.observe(document.body, { childList: true });
window.addEventListener('resize', this._handleResize);
document.addEventListener('click', this._handleClick);
}
disconnectedCallback() {
// Clean up timers
if (this._interval) {
clearInterval(this._interval);
this._interval = null;
}
// Disconnect observers
if (this._observer) {
this._observer.disconnect();
this._observer = null;
}
// Remove event listeners
window.removeEventListener('resize', this._handleResize);
document.removeEventListener('click', this._handleClick);
// Clear data references
this._data = null;
this._cache = null;
}
}
One signal, every listener
The classic component leak is a listener registered on window or document in connectedCallback and never removed: the element goes away, the listener does not, and it holds a reference to the element forever. Removing them one by one means keeping a matching bound reference for each. An AbortController collapses all of that into one signal.
class ViewportWatcher extends HTMLElement {
#controller;
// Arrow-function class fields are bound to the instance for free — no
// bind() in the constructor, and the same reference on every connect.
#onResize = () => this.#update();
#onVisibility = () => this.#update();
connectedCallback() {
// A fresh controller each time: an element can reconnect.
this.#controller = new AbortController();
const { signal } = this.#controller;
window.addEventListener('resize', this.#onResize, { signal });
document.addEventListener('visibilitychange', this.#onVisibility, { signal });
this.#update();
}
disconnectedCallback() {
this.#controller.abort(); // removes every listener above at once
}
#update() {
this.textContent = `${window.innerWidth}px · ${document.visibilityState}`;
}
}
customElements.define('viewport-watcher', ViewportWatcher);
Advanced Lifecycle Patterns
Pattern 1: Lazy Initialization
Defer expensive setup until the element is actually connected to the DOM:
class LazyComponent extends HTMLElement {
connectedCallback() {
// Only create template once
if (!this._template) {
this._template = this.createExpensiveTemplate();
}
// Use the cached template
this.appendChild(this._template.cloneNode(true));
}
createExpensiveTemplate() {
const template = document.createElement('template');
// ... expensive DOM creation
return template.content;
}
}
Pattern 2: Handling Reconnection
Components can be removed and re-added. Handle this gracefully:
class ReconnectableComponent extends HTMLElement {
connectedCallback() {
// Do expensive setup only once
if (!this._initialized) {
this.expensiveSetup();
this._initialized = true;
}
// Do this every time we connect
this.start();
}
disconnectedCallback() {
// Stop but don't destroy state
this.stop();
// Don't reset _initialized
}
expensiveSetup() {
// Heavy lifting (load data, create structures, etc.)
}
start() {
// Start timers, observers, etc.
}
stop() {
// Stop timers, observers, etc.
}
}
Pattern 3: Debouncing Attribute Changes
Avoid expensive re-renders when attributes change rapidly:
class DebouncedComponent extends HTMLElement {
static get observedAttributes() {
return ['value', 'size', 'theme'];
}
attributeChangedCallback(name, oldValue, newValue) {
// Clear previous timeout
if (this._updateTimeout) {
clearTimeout(this._updateTimeout);
}
// Batch updates
this._pendingChanges = this._pendingChanges || {};
this._pendingChanges[name] = newValue;
// Debounce the actual update
this._updateTimeout = setTimeout(() => {
this.update(this._pendingChanges);
this._pendingChanges = {};
}, 100);
}
update(changes) {
// Process all changes at once
console.log('Updating with:', changes);
this.render();
}
}
Demo 2: Attribute Change Handling
Explore how components respond to attribute changes throughout their lifecycle. Try changing different attributes to see how the component updates.