Render heavy components as plain, undefined custom elements with useful fallback content, observe them with one IntersectionObserver, and when the first instance of a tag approaches the viewport, import() its module and call customElements.define() — the browser then upgrades every instance of that tag in place.
Problem / Scenario Context
A documentation site uses web components for interactive widgets: <code-playground>, <api-explorer> and <diagram-viewer>. Each ships with a large dependency — an editor, a schema parser, a rendering engine — and every page loads all of them up front, even though most readers never scroll to the widgets. Lighthouse flags hundreds of kilobytes of unused JavaScript, and interaction readiness on mobile suffers.
Custom elements have a property that makes lazy loading unusually clean: an element whose tag is not yet defined is simply an HTMLElement that renders its children, and defining the tag later upgrades existing instances automatically. The Web Component & Lit Observer Controllers topic introduces the lifecycle; this page uses it for loading.
Mechanics Explanation
The custom element lifecycle has an undefined state. Before customElements.define('code-playground', …) runs:
- The element is parsed and rendered as an unknown inline element with its light-DOM children visible.
- It matches the CSS selector
code-playground:not(:defined). - No constructor or
connectedCallbackruns.
When define() is called, the browser upgrades every existing <code-playground> in the document: it runs the constructor, then connectedCallback, and the element matches :defined. Instances created later are born upgraded.
That means lazy loading needs no wrapper component and no placeholder swapping. The observer's only job is to decide when to import and define a tag; the platform does the rest.
Comparison Table: Lazy-Loading Strategies for Components
| Strategy | JS on first load | Content before load | Code complexity |
|---|---|---|---|
| Define all eagerly | all widgets | full component | low |
| Dynamic import on page load | all, later | fallback | low |
| Import on interaction (click to activate) | none | fallback + button | medium |
| Import on visibility, define on arrival | only what is approached | fallback | low |
| Framework islands (per-component hydration) | per island | server-rendered | framework-specific |
Minimal Reproducible Example
<!-- Eager: every widget's code loads whether or not it is ever seen. -->
<script type="module">
import './widgets/code-playground.js';
import './widgets/api-explorer.js';
import './widgets/diagram-viewer.js';
</script>
The Coverage panel in DevTools shows most of those bytes unused on a typical visit.
Production-Safe Solution
<!-- Fallback content is real, useful HTML: the example code, a link, a static image. -->
<code-playground src="/examples/io-basic.ts">
<pre><code>const io = new IntersectionObserver(callback, { threshold: 0.5 });</code></pre>
<a href="/examples/io-basic.ts">Open the example</a>
</code-playground>
/* Reserve space and dim the fallback until the element is defined. */
code-playground:not(:defined) { display: block; min-block-size: 320px; opacity: 0.9; }
code-playground { display: block; }
// lazy-define.ts — one observer for all lazily defined tags
const loaders: Record<string, () => Promise<unknown>> = {
'code-playground': () => import('./widgets/code-playground.js'),
'api-explorer': () => import('./widgets/api-explorer.js'),
'diagram-viewer': () => import('./widgets/diagram-viewer.js'),
};
const pending = new Set(Object.keys(loaders));
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
const tag = e.target.localName;
if (!pending.has(tag)) continue;
pending.delete(tag);
// Stop watching every instance of this tag: one definition upgrades them all.
document.querySelectorAll(tag).forEach((el) => io.unobserve(el));
loaders[tag]()
.then(() => customElements.whenDefined(tag))
.catch(() => document.querySelectorAll(tag).forEach((el) => el.setAttribute('load-failed', '')));
}
if (pending.size === 0) io.disconnect();
}, { rootMargin: '600px 0px' });
for (const tag of pending) {
if (customElements.get(tag)) { pending.delete(tag); continue; } // already defined elsewhere
document.querySelectorAll(tag).forEach((el) => io.observe(el));
}
Each widget module must call customElements.define() itself when imported. The 600 px margin starts the download well before the widget is on screen. Once a tag is defined, all its instances upgrade together and the observer forgets them; when every tag is defined — or the page simply has none left pending — the observer disconnects.
Content added later (a client-side navigation that inserts a new playground) is handled automatically if the tag is already defined; if not, call io.observe(newElement) from the code that inserts it, or watch for insertions with a MutationObserver.
Designing Fallback Content
The fallback is what most readers see first, and on slow networks, for a while. It should be useful on its own, not a spinner:
- Code playgrounds: the example source in a
<pre>, plus a link to run it elsewhere. - API explorers: a static table of the endpoints or a link to the reference.
- Diagram viewers: a static SVG or image of the default view, with
alttext. - Charts: the data as an HTML table, which is also the accessible alternative.
Because the fallback lives in the light DOM, the upgraded component can use it — reading the <pre> as the initial editor content, or the table as the chart's data — so nothing is duplicated. When the component attaches a shadow root, light-DOM children without a slot stop rendering, and the fallback disappears exactly when the component is ready.
Verification Steps
- Open the Coverage panel and confirm widget modules are absent until you scroll near a widget.
- Scroll to a widget and confirm every instance of that tag upgrades together (
:definedin the Elements panel). - Throttle the network and confirm the fallback is readable and the space does not collapse.
- Block a widget module and confirm the
load-failedattribute appears and the fallback remains. - Check the Network panel for duplicate imports; each module should load once.
Common Mistakes to Avoid
- Observing only the first instance. The first one may be far down the page while a later one is above it; observe all instances.
- Spinner-only fallbacks. They waste the chance to show content and fail badly.
- Defining the tag in the loader instead of the module. Keep
define()inside the module so direct imports elsewhere still work. - Collapsing space before upgrade. Reserve height with
:not(:defined)rules to avoid layout shift.
FAQ
What happens to an undefined custom element's children?
They render normally, as if the element were an unknown inline element. That is what makes fallback content work without any script.
Do all instances upgrade when the tag is defined?
Yes. customElements.define upgrades every element with that tag already in the document, in document order, and any created afterwards are upgraded immediately.
Does lazy definition hurt SEO?
Not if the fallback content is meaningful HTML. Crawlers see the fallback, which is often more indexable than the interactive component would be.
How is this different from framework islands?
Islands architectures hydrate server-rendered framework components on visibility; this approach defines platform custom elements on visibility. The observer logic is the same, and the custom element version needs no framework runtime.
What if a widget is already in view when the page loads?
The observer's first delivery reports it as intersecting, so its module is imported immediately after the lazy-define script runs. For widgets that are almost always above the fold, skip the observer and import them eagerly with a modulepreload hint, because waiting for the observer only adds a round trip.
How do I stop layout shift when the component upgrades?
Reserve the component's final size on the undefined element with a :not(:defined) rule — a min-block-size or aspect-ratio that matches the upgraded layout. If the upgraded component's height depends on content, size the fallback from the same content so both states are close.
Can I prefetch the module without defining the element?
Yes. Add a link rel=modulepreload for the module when the element is within a larger margin, and import it at a smaller margin. The import then resolves from the preload cache almost instantly.
Related
- Lazy Loading Components with Dynamic Import — the framework-component version
- disconnectedCallback Observer Cleanup in Custom Elements — what upgraded components must release
- Lazy Loading YouTube Iframes with IntersectionObserver — facade loading for embeds
↑ Back to Web Component & Lit Observer Controllers