Render a lightweight placeholder with reserved dimensions where a heavy component will go, observe it with a generous rootMargin, and when it intersects call import() for the component's module and mount it — optionally prefetching the chunk with <link rel="modulepreload"> at a wider margin so the import resolves instantly.

Problem / Scenario Context

An article page includes an interactive map, a comment thread with a rich-text editor, and a charting widget, all near the bottom. Together they add 400 KB of JavaScript to the initial bundle, most readers never scroll that far, and the page's Time to Interactive on mid-range phones is dominated by parsing code for features below the fold.

Code for components is a resource like images, and it can be deferred with the same technique. The Lazy Loading Images & Media topic covers media; this page covers code.

Mechanics Explanation

Dynamic import() returns a promise for a module and tells bundlers (Vite, webpack, Rollup, esbuild) to split that module and its unique dependencies into a separate chunk. The chunk is only requested when import() runs. Combining it with an observer defers the request until the component's position approaches the viewport.

Three costs are deferred, not just one: download (the chunk's bytes), parse and compile (often larger than download on mobile), and execution and render (mounting the component). All three leave the critical path.

The trade-off is latency when the user does reach the component: the chunk must download, parse and run before it appears. That is why the placeholder must be a genuinely useful stand-in — reserved space, a static preview or a summary — and why a wider prefetch margin helps: modulepreload downloads and compiles the module ahead of time without executing it, so the later import() resolves almost immediately.

Initial Load With and Without Deferred ComponentsTwo lanes over the first four seconds on a mid-range phone. With everything in the main bundle, download and parse of the map, comments and chart code occupy the main thread and interactivity arrives late. With deferred components, the main bundle is small and the page becomes interactive early; the component chunks load later when the reader scrolls near them.Mid-range phone, article pageone bundleapp codemap + comments + chartinteractivedeferredapp codeinteractivemap chunk on scroll0s1s2s3s4s

Comparison Table: Deferral Triggers for Components

Trigger When code loads Good for Drawback
Main bundle immediately above-the-fold, always-used cost on every visit
import() on interaction (click) when asked modals, editors latency on first click
import() on visibility when approaching below-the-fold widgets latency if scrolled to quickly
modulepreload far + import() near preloaded early, executed late heavy widgets likely to be seen some wasted bytes
import() on idle after page settles likely-needed features competes with user on slow devices

Minimal Reproducible Example

TypeScript
import { mountMap } from './widgets/map';           // 180 KB, statically imported
import { mountComments } from './widgets/comments'; // 150 KB
import { mountChart } from './widgets/chart';       // 70 KB

mountMap(document.querySelector('#map')!);
mountComments(document.querySelector('#comments')!);
mountChart(document.querySelector('#chart')!);

The bundle analyser shows all three in the entry chunk; the Coverage panel shows most of that code unused on a typical visit.

Production-Safe Solution

TypeScript
// lazy-mount.ts
type Mount = (el: HTMLElement) => void | (() => void);
type Loader = () => Promise<{ default: Mount }>;

interface LazyOptions { preloadMargin?: string; mountMargin?: string; chunkUrl?: string }

export function lazyMount(el: HTMLElement, load: Loader, opts: LazyOptions = {}): () => void {
  const { preloadMargin = '150%', mountMargin = '50%', chunkUrl } = opts;
  let unmount: (() => void) | void;
  let disposed = false;

  const preload = chunkUrl ? new IntersectionObserver(([e], obs) => {
    if (!e.isIntersecting) return;
    obs.disconnect();
    const link = Object.assign(document.createElement('link'), { rel: 'modulepreload', href: chunkUrl });
    document.head.append(link);                         // download + compile, no execution
  }, { rootMargin: preloadMargin }) : null;

  const mount = new IntersectionObserver(async ([e], obs) => {
    if (!e.isIntersecting) return;
    obs.disconnect();
    preload?.disconnect();
    el.setAttribute('aria-busy', 'true');
    try {
      const mod = await load();
      if (disposed || !el.isConnected) return;          // navigated away while loading
      unmount = mod.default(el);
    } catch {
      el.dataset.state = 'failed';                      // keep the placeholder, offer a retry
      el.querySelector<HTMLButtonElement>('.retry')?.removeAttribute('hidden');
    } finally {
      el.removeAttribute('aria-busy');
    }
  }, { rootMargin: mountMargin });

  preload?.observe(el);
  mount.observe(el);
  return () => { disposed = true; preload?.disconnect(); mount.disconnect(); unmount?.(); };
}

// Usage: each call site names its chunk, so the bundler splits it.
lazyMount(document.querySelector('#map')!, () => import('./widgets/map'));
lazyMount(document.querySelector('#comments')!, () => import('./widgets/comments'));
CSS
/* Reserve the space the component will take, so mounting shifts nothing. */
#map { aspect-ratio: 16 / 9; background: var(--color-surface-muted); }
#comments { min-block-size: 480px; }

Each widget module default-exports a mount function that may return a cleanup. The placeholder keeps its reserved size throughout, so the swap causes no layout shift. The isConnected check prevents mounting into a placeholder that a client-side navigation removed while the chunk was in flight.

Obtaining a stable chunkUrl for modulepreload depends on the bundler — Vite exposes it through its manifest, and many frameworks emit modulepreload hints for route chunks automatically. When you cannot get the URL, the two-observer approach still works with the mount margin alone.

Lazy-Mounting a Component, Step by StepFive steps. Render a placeholder with reserved dimensions and a useful static preview. When it enters the wide preload margin, add a modulepreload link so the chunk downloads and compiles. When it enters the mount margin, call dynamic import and mount the component into the placeholder. If the import fails, keep the placeholder and show a retry button. On navigation away, dispose the observers and unmount.1PlaceholderReserved size; static preview or summary.2Preload marginmodulepreload: download and compile, no execution.3Mount marginimport() resolves from cache; mount into the placeholder.4On failureKeep the placeholder; show a retry control.5DisposeDisconnect observers and unmount on navigation.

Framework Equivalents

Frameworks have their own lazy-component primitives; the observer decides when to render them.

ReactReact.lazy plus a visibility flag. The component is only rendered (and therefore imported) once visible:

TSX
const Map = React.lazy(() => import('./widgets/Map'));

function LazyMap() {
  const [ref, visible] = useInView({ rootMargin: '50%', once: true });
  return (
    <div ref={ref} style={ { aspectRatio: '16 / 9' } }>
      {visible && <React.Suspense fallback={<MapPreview />}><Map /></React.Suspense>}
    </div>
  );
}

declare function useInView(o: { rootMargin: string; once: boolean }): [(el: Element | null) => void, boolean];
declare function MapPreview(): JSX.Element;

VuedefineAsyncComponent rendered under a v-if driven by a visibility composable; Nuxt adds built-in lazy hydration, covered in lazy hydrating Nuxt components on visibility.

Custom elements — define the element when it approaches, as in lazy upgrading custom elements on visibility.

Initial JavaScript by Loading StrategyA bar chart of JavaScript downloaded before the page became interactive on an article page. With all widgets in the main bundle it was about five hundred and twenty kilobytes. With the three below-the-fold widgets imported on visibility it was about one hundred and twenty kilobytes, and the widget chunks loaded only for readers who scrolled to them.Compressed JS before interactive, article pageall widgets in main bundle~520 KBwidgets imported on visibility~120 KB

Verification Steps

  • Inspect the bundle (analyser or network waterfall) and confirm each widget is a separate chunk.
  • Load the page without scrolling and confirm widget chunks are not requested.
  • Scroll to each widget on a throttled connection and confirm the placeholder is shown until mount, with no layout shift.
  • Block a chunk and confirm the retry path works.
  • Navigate away mid-load in a single-page app and confirm nothing mounts into a detached placeholder.

Common Mistakes to Avoid

  • Static imports elsewhere of the same module. Any static import pulls the module back into the main bundle.
  • Placeholders without reserved space. The component's arrival shifts the page.
  • Deferring above-the-fold components. They load later than they would have eagerly.
  • Ignoring import failures. Deploys that remove old chunks cause import() to reject for users on stale pages.

FAQ

Does dynamic import work without a bundler?

Yes. Native ES modules support import() in all current browsers, so the pattern works with unbundled modules too; the bundler's role is to split dependencies into sensible chunks.

What is modulepreload for?

It fetches and compiles a module and its static dependencies without executing it. A later import() of the same module then resolves almost instantly, which hides the load latency of deferred components.

How big should the mount margin be?

Large enough that download, parse and mount usually finish before the component is visible — half a screen to a screen is a good start. Measure how often users see the placeholder and adjust.

What happens to SEO for lazily mounted components?

Content rendered only by the deferred component is not in the initial HTML. Keep indexable content — article text, headings, a static preview — in the placeholder, and defer only the interactive layer.

How do I handle chunk load errors after a deploy?

Catch the rejection, keep the placeholder usable and offer a retry. If retries also fail because the old chunk no longer exists, prompt a page reload, which fetches the new build's chunks.

Can several components share one observer?

Yes. A shared observer that maps each placeholder to its loader is more efficient on pages with many deferred widgets, following the pooling pattern used elsewhere on this site.


↑ Back to Lazy Loading Images & Media