Lazy Loading — Route, Component, Interaction
TL;DR
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).
Interview Q&A
Q: Route-level lazy loading in React.
A:
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.
Q: Vue Router equivalent.
A: 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.
Q: Component-level lazy loading — when?
A: 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.
Q: Interaction-level lazy loading.
A: Even smaller granularity — load on user gesture:
async function openCharts() {
const { Charts } = await import("./charts"); // dynamic import on click
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.
Q: Prefetch on hover/intent — the pattern.
A: 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() {
const prefetch = () => { import("./Dashboard"); }; // fires the dynamic import, browser caches it
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.
Q: Lazy loading images.
A: 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>;
}
Q: Lazy loading data.
A: Don’t fetch what’s not visible. Two patterns:
- Sentinel + fetch on intersect — infinite scroll, “load more on viewport” (see ../14_frontend_system_design/02_infinite_feed.md).
- 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.
Q: Lazy loading + SSR — what’s the gotcha?
A: 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.).
Q: What’s the difference between lazy loading a React component and using a dynamic import directly?
A: 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).
Q: Chunk loading failures — how do you handle them?
A: 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 / edge cases
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.
What a senior is expected to say
- “Route-level splitting first — highest leverage. Component-level for heavy below-fold or conditional content. Interaction-level for behind-a-click features.”
- “Prefetch on hover/intent so the chunk is already in cache by click time. Next.js does this automatically via viewport visibility.”
- “Lazy images via native
loading='lazy', except the LCP image which should be eager +fetchpriority='high'.” - “Lazy components need an error boundary for chunk load failures — usually a deploy invalidated the old hash. Either rolling cache or ‘refresh to update’ UX.”
- “Don’t lazy-load tiny components; the chunk overhead outweighs the save. Threshold is roughly: if it’s not at least 30-50 KB, leave it in the main bundle.”
Cross-references
- Bundle splitting (what creates the chunks): 03_bundle_analysis_and_code_splitting.md
- Image lazy loading specifics: 07_image_and_font_optimization.md
- Resource hints (preload/prefetch/modulepreload): 08_resource_hints.md
- Rendering modes (SSR + lazy): ../19_rendering_modes/
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/api-reference/functions/dynamic - web.dev — Lazy loading images: https://web.dev/articles/lazy-loading-images