Skip to content

Upgrade and Timing

When the browser parses HTML it may encounter a tag like <my-el> before any JavaScript has run. At that point the browser has no registered constructor for my-el, so it creates the element as a plain HTMLElement — an unknown element with no special behaviour.

Later, when your script calls customElements.define('my-el', MyEl), the browser performs an upgrade: it walks every matching element already in the document, calls the MyEl constructor on each one, and then calls connectedCallback if the element is currently attached to the DOM.

This means upgrade can happen in two different orders:

  • Define before parse — your <script> in <head> (with defer) runs before the parser reaches <my-el>. The element is constructed immediately when the parser reaches the tag.
  • Define after parse — a lazily loaded script runs after the HTML is already in the DOM. Every <my-el> already in the document is upgraded at the moment define is called.

Both orders are intentional and supported. What you must not do is assume the element’s custom behaviour is available before define has been called.

customElements.whenDefined(tagName) returns a Promise that resolves to the constructor class as soon as that tag name is registered. Use it to defer any code that depends on the element’s upgraded interface:

// Promise / .then() style
customElements.whenDefined('my-el').then(function(Ctor) {
console.log('my-el is now defined:', Ctor);
document.querySelector('my-el').doSomething();
});
// async / await style
async function init() {
const Ctor = await customElements.whenDefined('my-el');
console.log('my-el is now defined:', Ctor);
document.querySelector('my-el').doSomething();
}
init();

If the element is already defined when you call whenDefined, the promise resolves immediately on the next microtask tick.

Before upgrade, a custom element matches :not(:defined) in CSS. After customElements.define is called and the upgrade runs, the element matches :defined. This gives you a clean hook to hide partially-rendered elements until they are ready — preventing a Flash of Undefined Content (FOUC):

my-el:not(:defined) {
visibility: hidden;
}

A more complete FOUC-prevention pattern that also shows a loading placeholder:

<style>
upgrade-demo:not(:defined) {
visibility: hidden;
}
upgrade-demo:defined {
display: block;
border: 2px solid #059669;
}
</style>
<upgrade-demo>Loading…</upgrade-demo>
/* Optionally animate the reveal */
upgrade-demo {
transition: opacity 0.3s ease;
}
upgrade-demo:not(:defined) {
opacity: 0;
}
upgrade-demo:defined {
opacity: 1;
}

The timing of define relative to DOM parsing matters:

  • Script in <head> without defer — the script blocks parsing, so define is called before any body elements are parsed. Elements are constructed immediately when the parser reaches them.
  • Script in <head> with defer — the script runs after the full document is parsed. Every element already in the DOM is upgraded at once when define is called.
  • Dynamically imported or lazily loaded scripts — same as defer: all existing elements upgrade in a batch at define time.
  • Elements added via JavaScript after define — these are upgraded synchronously as they are inserted into the document.

Always use customElements.whenDefined (or customElements.upgrade(element) for a single node) when you need to guarantee the element is ready before you access its custom API.

Watch the element start as a plain placeholder, then flip to its upgraded state after 1.2 seconds when customElements.define fires inside the setTimeout.

What does "upgrading" a custom element mean?
What does customElements.whenDefined(tagName) return?
What does the :defined CSS pseudo-class let you do?