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.
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
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
// 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'));
/* 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.
Framework Equivalents
Frameworks have their own lazy-component primitives; the observer decides when to render them.
React — React.lazy plus a visibility flag. The component is only rendered (and therefore imported) once visible:
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;
Vue — defineAsyncComponent 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.
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.
Related
- Lazy Upgrading Custom Elements on Visibility — the custom-element version
- Lazy Loading YouTube Iframes with IntersectionObserver — facades for embeds
- Deferring Observer Setup Until After Hydration — SSR timing
↑ Back to Lazy Loading Images & Media