frontend / performance / 05_lazy_loading.md

Lazy Loading — Route, Component, Interaction

6 min read source

Lazy Loading — Route, Component, Interaction

TL;DR

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).

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.lazy requires a default exportlazy(() => import("./X")) expects ./X.tsx to export 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/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.

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

Further reading