Frontend / Performance / 05_lazy_loading.md

Lazy Loading — Route, Component, Interaction

Updated 5 interview angles 7 min read source
On this page14
  1. Route-level lazy loading in React.
  2. Vue Router equivalent.
  3. Component-level lazy loading — when?
  4. Interaction-level lazy loading.
  5. Prefetch on hover/intent — the pattern.
  6. Lazy loading images.
  7. Lazy loading data.
  8. Lazy loading + SSR — what’s the gotcha?
  9. What’s the difference between lazy loading a React component and using a dynamic import directly?
  10. Chunk loading failures — how do you handle them?
  11. Gotchas
  12. Related
  13. Further reading
  14. Interview angle

Lazy Loading — Route, Component, Interaction

Don’t ship code until it’s needed. Three granularities, in decreasing impact:

  1. Route-level — each route’s code is its own chunk; loaded only on navigation.
  2. Component-level — a heavy widget that may or may not render; loaded when needed.
  3. 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.

tsx
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:

ts
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.
tsx
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:

tsx
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:

tsx
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":

html
<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:

tsx
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:
    tsx
    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:

tsx
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:

tsx
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.lazy requires a default export — lazy(() => import("./X")) expects ./X.tsx to export default. Named exports need a wrapper:
    ts
    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/memo patterns.
  • Lazy + Suspense fallback flashing — if the chunk loads in 50ms, the fallback flashes for that 50ms. Use useDeferredValue or a min-display-time library 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 comments can name chunks: import(/* webpackChunkName: "dashboard" */ "./Dashboard"). Useful for debugging and HTTP caching.

Further reading

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 with fetchpriority="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.