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 connectedCallback runs.

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.

Page Load With Visibility-Triggered DefinitionA timeline of a documentation page. The HTML and core script load first. Undefined widget elements render their fallback content immediately. When the reader scrolls near the first playground, its module is imported and the tag defined, and all playground instances upgrade at once. The API explorer's module is never loaded because the reader never scrolls to it.Reader scrolls to the first playground at about 4 snetworkHTML+coremodulemain threadobservedefine + upgradepage statefallback visibleinteractive0s1s2s3s4s5s6s7s8s

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

HTML
<!-- 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

HTML
<!-- 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>
CSS
/* 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; }
TypeScript
// 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.

Visibility-Triggered DefinitionFive steps. Render the undefined element with useful fallback content and reserved space. Observe every instance of each lazy tag with one observer. When the first instance approaches, import the tag's module, which calls define. The browser upgrades every instance of that tag. Unobserve them all, and disconnect once no tags are pending.1Fallback markupUndefined element renders real content; :not(:defined) reserves space.2One observerEvery instance of every lazy tag, 600px margin.3First approachimport() the tag's module; it calls customElements.define.4Upgrade allThe browser upgrades every existing instance of that tag.5Clean upUnobserve that tag's instances; disconnect when none pending.

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 alt text.
  • 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.

Spinner Fallback Versus Content FallbackTwo columns. A spinner fallback shows nothing useful before load, is invisible to search engines and screen readers, and leaves a blank box if the load fails. A content fallback shows the example code, a static image or a data table, is indexable and accessible, doubles as the component's input data, and still works if the script never loads.Spinner fallbackNothing useful until the code arrivesNot indexable, not accessibleBlank box if the import failsContent fallbackExample code, static image or data tableIndexable and readable by screen readersBecomes the component's input when it upgradesStill useful if the script never loads

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 (:defined in 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-failed attribute 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.


↑ Back to Web Component & Lit Observer Controllers