Skip to content

Lazy Loading Components

Not every component needs to be loaded upfront. This lesson covers lazy-loading Lit component definitions with dynamic import(), reducing your initial JavaScript payload.

Loading Component Code on Demand

Because a custom element's tag only becomes 'defined' once its module actually runs customElements.define() (directly or via @customElement), you can defer loading that module until the component is actually needed — a modal dialog, a rarely-used settings panel, or a heavy chart component that most visitors never open.

The browser's native import() function (dynamic import) returns a Promise that resolves once the module has been fetched and executed, which is all that's needed to lazily register a custom element at exactly the right moment.

// Lazily load a heavy chart component only when needed
async function showChart() {
  await import('./chart-widget.js'); // registers <chart-widget> once loaded
  document.body.insertAdjacentHTML('beforeend', '<chart-widget></chart-widget>');
}

document.querySelector('#show-chart-btn').addEventListener('click', showChart);

<chart-widget>'s JavaScript is only fetched and parsed the moment a user actually clicks the button — not as part of the initial page load.

Dynamic Import Patterns

// On user interaction
button.addEventListener('click', () => import('./heavy-widget.js'));

// When an element scrolls into view
const observer = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) {
    import('./below-fold-widget.js');
    observer.disconnect();
  }
});
observer.observe(placeholderElement);
  • Dynamic import() works natively in every modern browser and every common bundler, with no special Lit-specific syntax required.
  • Combine lazy loading with an IntersectionObserver to defer below-the-fold components until they're about to scroll into view.
  • Bundlers like Vite/Rollup/webpack automatically split a dynamically imported module into its own separate chunk file.
  • The component using this pattern (rendering an unresolved tag, then upgrading once defined) benefits from the platform's own 'undefined custom element' handling — an unrecognized tag simply renders as an inert, unstyled element until its definition loads.

Lazy Loading Triggers Cheat Sheet

Common triggers for when to lazily load a component.

Trigger Use Case
User interaction (click, hover) A modal, dropdown, or rarely-used panel
Scroll into view (IntersectionObserver) Below-the-fold widgets, images, or heavy charts
Route/page change Components only used on a specific page/route
Media query match Mobile-only or desktop-only specific components
Idle time (requestIdleCallback) Non-critical components you want loaded eventually, but not urgently

Handling the Period Before a Component Is Defined

Between the moment an undefined custom element's tag appears in the DOM and the moment its module finishes loading, the browser treats it as an unstyled, inert HTMLUnknownElement-like element. Provide basic fallback styling (a loading skeleton, a fixed size to avoid layout shift) for that period using the :not(:defined) CSS pseudo-class.

chart-widget:not(:defined) {
  display: block;
  min-height: 300px;
  background: #f3f3f3;
}

:not(:defined) matches any custom element tag whose class hasn't been registered yet — a simple hook for loading-state styling.

Waiting for a Component to Be Defined Programmatically

If your code needs to know exactly when a lazily-loaded component becomes ready (not just visually, but for calling its methods or setting its properties reliably), customElements.whenDefined(tagName) returns a Promise that resolves once that tag's class has been registered.

await customElements.whenDefined('chart-widget');
document.querySelector('chart-widget').data = myData; // now safe to interact with

Common Mistakes

  • Lazy-loading a component that's actually needed immediately on every page load, adding an unnecessary extra network round trip for no benefit.
  • Not providing any fallback styling for the undefined period, causing a jarring layout shift once the component's real styles apply.
  • Setting properties or calling methods on a lazily-loaded element before confirming it's actually defined yet, via customElements.whenDefined().
  • Forgetting that a bundler needs the dynamic import() call to use a literal (or template-literal) path it can statically analyze to correctly split the chunk.

Key Takeaways

  • Dynamic import() defers loading a component's module (and thus its registration) until it's actually needed.
  • Combine lazy loading with interaction events, IntersectionObserver, or route changes depending on the trigger that makes sense.
  • :not(:defined) provides a CSS hook for styling custom elements before their module has loaded.
  • customElements.whenDefined(tagName) gives a reliable Promise-based signal for when it's safe to interact with a lazily-loaded element programmatically.

Pro Tip

Audit your app's initial bundle for components that are rarely used on first load (settings panels, modals, admin-only widgets) — converting even a handful of these to lazy-loaded imports is often the single highest-leverage change for initial load performance in a component-heavy Lit application.