Back to React
React
medium
mid

How do you use React.lazy and Suspense to load components only when needed?

`React.lazy(() => import('./X'))` returns a component that resolves on first render via dynamic import — the bundler emits a separate chunk. Wrap it in `<Suspense fallback={<Spinner/>}>` to show UI while the chunk downloads. Best for route-level code splitting and heavy components (charts, editors, modals) that aren't on the critical path.

7 min read·~12 min to think through

Lazy loading lets you defer downloading a component's code until it's actually rendered.

Basic usage

tsx
import { lazy, Suspense } from 'react';

const Settings = lazy(() => import('./Settings'));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Settings />
    </Suspense>
  );
}

The bundler (Vite/webpack/Rollup) sees import('./Settings') and emits a separate JS chunk. The chunk is fetched on first render of <Settings/>.

Route-level splitting

tsx
const Home = lazy(() => import('./pages/Home'));
const Profile = lazy(() => import('./pages/Profile'));

<Suspense fallback={<PageSkeleton />}>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/profile" element={<Profile />} />
  </Routes>
</Suspense>

Each route is its own chunk; users only download what they navigate to.

Component-level splitting (heavy widgets)

tsx
const Chart = lazy(() => import('./Chart')); // includes recharts/d3

function Dashboard() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>Show chart</button>
      {open && (
        <Suspense fallback={<ChartSkeleton />}>
          <Chart />
        </Suspense>
      )}
    </>
  );
}

A 200 KB chart library no longer ships in the initial bundle.

Preloading on intent

tsx
const Profile = lazy(() => import('./Profile'));

function Nav() {
  return (
    <a
      href="/profile"
      onMouseEnter={() => import('./Profile')} // warm the cache
    >
      Profile
    </a>
  );
}

Hover triggers the fetch; by the time the user clicks, the chunk is in memory.

Named exports

React.lazy only takes default exports. Wrap to expose a named one:

tsx
const Chart = lazy(() =>
  import('./charts').then(m => ({ default: m.Chart })),
);

Error boundaries

A failed chunk fetch (network down, deploy rollover) throws. Wrap with an error boundary so users see a retry instead of a white screen.

tsx
<ErrorBoundary fallback={<RetryUI />}>
  <Suspense fallback={<Spinner />}>
    <LazyThing />
  </Suspense>
</ErrorBoundary>

Suspense fallback choices

  • Skeleton matching the layout: best — no layout shift.
  • Spinner centered: fine for modals/inline.
  • Empty: fine if the chunk loads under 100 ms.

When NOT to lazy-load

  • Components that are visible on first paint: you'd just add a flash.
  • Tiny components: the chunk overhead exceeds the savings.
  • Above-the-fold UI: defer below-the-fold instead.

Follow-up questions

  • How would you preload a lazy chunk on hover?
  • Why does React.lazy require a default export?
  • What happens if the chunk download fails?

Common mistakes

  • Wrapping the entire app in one Suspense — a single slow route blocks everything.
  • Lazy-loading components that are always visible — adds a spinner flash for no savings.
  • Forgetting an error boundary, then white-screening when a chunk 404s after deploy.

Performance considerations

  • Route-level splitting is the highest-ROI use. Component-level splitting matters for heavy deps (charts, rich text, editors). Always pair with preloading-on-intent so the network hop is hidden behind user latency (hover → click is ~150 ms).

Edge cases

  • Stale chunks after deploy: the user has the old index.html referencing a chunk hash that no longer exists. Fix with retry-on-fail or a SW that revalidates.
  • SSR + lazy: Next.js needs dynamic() instead of React.lazy in the Pages Router.
  • Suspense for data fetching requires a framework or a hand-rolled cache.

Real-world examples

  • Gmail, Twitter, every major SPA does route-level code splitting. Stripe Elements lazy-loads payment method UIs. Notion lazy-loads its block editor.

Senior engineer discussion

Beyond the mechanics, senior framing is about WHERE to split. Measure with bundle analyzer, split at user-journey boundaries (logged-out vs logged-in, admin vs user), preload on intent, and budget initial bundle size (e.g. < 200 KB gzipped on critical path).

Related questions