Lazy Loading — Route, Component, Interaction
Don’t ship code until it’s needed. Three granularities, in decreasing impact:
- Route-level — each route’s code is its own chunk; loaded only on navigation.
- Component-level — a heavy widget that may or may not render; loaded when needed.
- Interaction-level — code triggered by a click/hover; loaded just-in-time.
Plus: lazy data fetching (don’t fetch what’s not visible), lazy media (loading="lazy" images, intersection-loaded videos), and prefetch on intent (hover a link → load that route’s chunk before the click).
Route-level lazy loading in React.
import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
const Dashboard = lazy(() => import("./Dashboard"));
const Settings = lazy(() => import("./Settings"));
<BrowserRouter>
<Suspense fallback={<RouteSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</BrowserRouter>The bundler sees the dynamic import(), emits separate chunks. First visit to /dashboard downloads Dashboard.[hash].js; subsequent visits hit the browser cache.
Suspense fallback is the loading UI while the chunk is fetched. Route-level fallback usually looks like a skeleton of the layout, not a spinner — better LCP perception.
Vue Router equivalent.
Vue Router accepts async components natively:
const routes = [
{ path: "/dashboard", component: () => import("./Dashboard.vue") },
{ path: "/settings", component: () => import("./Settings.vue") },
];No <Suspense> needed for the route component itself (Vue Router handles loading state via loading route meta or transition hooks). For loading UI: in-route placeholder or transition.
Component-level lazy loading — when?
When a component:
- Is not always rendered (modal, drawer, tab content shown on click).
- Is heavy (chart library, rich-text editor, large form).
- Is below the fold and not a CLS risk.
const HeavyChart = lazy(() => import("./HeavyChart"));
function Dashboard() {
const [tab, setTab] = useState<"overview" | "chart">("overview");
return (
<>
<Tabs value={tab} onChange={setTab} />
{tab === "chart" && (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
)}
</>
);
}The chart’s bundle downloads when the user switches to the chart tab — not before.
Interaction-level lazy loading.
Even smaller granularity — load on user gesture:
async function openCharts() {
// dynamic import on click
const { Charts } = await import("./charts");
Charts.init(container);
}
<button onClick={openCharts}>Show charts</button>Useful for features that may never be used (export to PDF, advanced settings, debugging panels). The cost is the visible delay on first use — pair with prefetch on hover/intent to mitigate.
Prefetch on hover/intent — the pattern.
Anticipate that the user will click; start loading the chunk on hover:
import { Link } from "react-router-dom";
const Dashboard = lazy(() => import("./Dashboard"));
function NavLink() {
// fires the dynamic import, browser caches it
const prefetch = () => { import("./Dashboard"); };
return <Link to="/dashboard" onMouseEnter={prefetch} onFocus={prefetch}>Dashboard</Link>;
}The bundle starts downloading on hover. By the time the user clicks ~200ms later, it’s already in the cache.
Next.js’ <Link> does this automatically (viewport-based prefetch). Vue Router has <router-link prefetch> via Nuxt. Plain React/Vue Router, you wire it yourself.
Don’t over-prefetch — phones on cellular hate it. Modern Next.js prefetches conservatively (only on viewport visibility), which is usually right.
Lazy loading images.
Native loading="lazy":
<img src="hero.jpg" loading="lazy" decoding="async" width="800" height="600" alt="...">Defers loading until the image is near the viewport. Saves bandwidth + parse time.
Don’t lazy-load the LCP image — it delays the very thing you’re trying to render fast. The hero image should be loading="eager" (default) + fetchpriority="high".
For more control or older browsers, IntersectionObserver:
function LazyImage({ src, ...rest }: { src: string }) {
const [loaded, setLoaded] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const obs = new IntersectionObserver(([e]) => {
if (e.isIntersecting) { setLoaded(true); obs.disconnect(); }
}, { rootMargin: "200px" });
if (ref.current) obs.observe(ref.current);
return () => obs.disconnect();
}, []);
return <div ref={ref}>{loaded && <img src={src} {...rest} />}</div>;
}Lazy loading data.
Don’t fetch what’s not visible. Two patterns:
- Sentinel + fetch on intersect — infinite scroll, “load more on viewport” (see Design: Infinite Feed (Twitter-Style)).
- Idle-time prefetch — fetch low-priority data after the page is interactive:
useEffect(() => { if ("requestIdleCallback" in window) { requestIdleCallback(() => fetchSecondaryData()); } else { setTimeout(fetchSecondaryData, 1); } }, []);
TanStack Query’s enabled: false + manual refetch() is the idiomatic way to gate fetches on visibility.
Lazy loading + SSR — what’s the gotcha?
A lazy component is import()’d on the server and client. SSR pre-renders the placeholder (Suspense fallback) and the client hydrates with the same. After hydration, the client may load the chunk and render the real component.
In Next.js, use next/dynamic which is SSR-aware:
import dynamic from "next/dynamic";
const HeavyChart = dynamic(() => import("./HeavyChart"), {
loading: () => <ChartSkeleton />,
ssr: false, // skip server render (client-only)
});ssr: false is for components that touch window/document and can’t render on the server (date pickers using browser APIs, etc.).
What’s the difference between lazy loading a React component and using a dynamic import directly?
React.lazy + Suspense integrates with React’s rendering — the placeholder shows automatically, error boundaries catch chunk-load failures. A bare await import() is more flexible but you wire the loading state yourself.
lazy is the right default for React components; bare import() is for non-component code (utilities, libraries called from event handlers).
Chunk loading failures — how do you handle them?
Network drops, server returns 404 (after a deploy invalidated old chunks), CSP blocks. Wrap lazy components in an error boundary that prompts a page reload:
class ChunkLoadErrorBoundary extends React.Component {
componentDidCatch(error: Error) {
if (error.message.includes("Loading chunk") || error.name === "ChunkLoadError") {
window.location.reload();
}
}
render() { return this.props.children; }
}A naive window.location.reload() is crude but effective — the user gets the latest deploy’s assets. Better: detect the chunk-load failure and surface a “refresh to update” banner.
Gotchas
React.lazyrequires a default export —lazy(() => import("./X"))expects./X.tsxtoexport default. Named exports need a wrapper:const Foo = lazy(() => import("./Foo").then(m => ({ default: m.Foo })));- Deploys invalidate old chunk hashes — a user with the page already loaded clicks a lazy route, chunk 404s. Either rolling-cache for N minutes (server keeps old assets briefly) or chunk-load-error boundary.
- Tab switching to a lazy tab fires the load every time if the component unmounts on switch — keep mounted (CSS show/hide) or use
KeepAlive/memopatterns. - Lazy + Suspense fallback flashing — if the chunk loads in 50ms, the fallback flashes for that 50ms. Use
useDeferredValueor amin-display-timelibrary to avoid flash for sub-150ms loads. - CSS imports inside lazy components create a separate CSS chunk; some bundlers don’t preload it, causing a brief unstyled state. Configure CSS chunk preloading or rely on the framework’s defaults (Vite handles this).
- Webpack
magic commentscan name chunks:import(/* webpackChunkName: "dashboard" */ "./Dashboard"). Useful for debugging and HTTP caching.
Related
Further reading
- React docs —
lazy: https://react.dev/reference/react/lazy - Vue docs — Async Components: https://vuejs.org/guide/components/async.html
- Next.js —
next/dynamic: https://nextjs.org/docs/app/guides/lazy-loading - web.dev — Lazy loading images: https://web.dev/articles/lazy-loading-images
Interview angle 5
- “What should you lazy load, in what order of value?” - routes first, since that is where the large, clearly separable chunks are. Then heavy below-the-fold components, then anything behind a click - a modal, an editor, a chart nobody opens by default.
- “Does lazy loading make the click slower?” - it can, which is why you prefetch on intent. Hovering a link gives you a couple of hundred milliseconds of free time, and frameworks prefetch route chunks when the link enters the viewport, so the chunk is usually already cached by the time it is needed.
- “What is the exception for images?” - the LCP image.
loading="lazy"on the hero defers the exact resource the metric is timing, so it should be eager withfetchpriority="high". Lazy everything below the fold and nothing above it. - “A user sees a chunk load error. What happened?” - usually a deploy while their tab was open: the hashed filename they were about to request no longer exists. Wrap lazy boundaries in an error boundary and either keep old assets around for a window or prompt the user to refresh.
- “When is lazy loading not worth it?” - for small components, where the extra request and the loading state cost more than the bytes saved. As a rough threshold, if the chunk is not tens of kilobytes, leave it in the main bundle.