Back to React
React
medium
mid

What is lazy loading in React and how does it improve application performance?

Lazy loading defers downloading/executing a component until it's actually needed. `React.lazy(() => import('./Heavy'))` returns a component that fetches its chunk on first render, wrapped in `<Suspense fallback={...}>`. Improves initial bundle size, time to interactive, and LCP. Best for routes, heavy widgets (charts, editors), and modals. Pair with preload-on-intent (hover) to hide network latency.

6 min read·~10 min to think through

Lazy loading splits your app into chunks that download on demand.

Basic usage

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

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

<Suspense fallback={<Spinner />}>
  <Profile />
</Suspense>

When <Profile/> first renders, the bundler-emitted chunk downloads. While in flight, <Spinner/> shows.

How it works

  • The bundler (Vite/webpack/Rollup) sees the dynamic import('./Profile') and emits a separate JS chunk.
  • The main bundle ships without Profile code.
  • On first render, the chunk is requested, parsed, executed, and the component renders.
  • Future renders are instant (chunk is in cache).

Performance wins

  • Smaller initial bundle: faster parse + execute → faster TTI.
  • Faster LCP: less JS competing for main thread during paint.
  • Less work for users who never visit certain routes.

Where to apply

1. Route-level (highest ROI):

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

Each route is its own chunk. Users only download what they visit.

2. Heavy widgets:

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

A 200 KB chart library no longer ships in the initial bundle if charts are conditional.

3. Modals and dialogs:

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

Modal code (often heavy with forms) only loads if the modal opens.

Preload on intent

Hide network latency by warming the cache before the user clicks:

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

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

By the time the user clicks (150ms after hover), the chunk is downloaded.

Named exports

React.lazy requires a default export. Wrap for named:

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

Error boundaries

A chunk download can fail (network, deploy rollover). Wrap with an error boundary:

tsx
<ErrorBoundary fallback={<Retry />}>
  <Suspense fallback={<Spinner />}>
    <Lazy />
  </Suspense>
</ErrorBoundary>

Suspense fallback choices

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

When NOT to lazy load

  • Components visible on first paint — adds a spinner flash.
  • Tiny components — chunk overhead exceeds savings.
  • Above-the-fold UI — defer below-the-fold instead.

In Next.js

tsx
import dynamic from 'next/dynamic';

const Chart = dynamic(() => import('./Chart'), {
  loading: () => <ChartSkeleton />,
  ssr: false, // disable server-side rendering for this component
});

Same idea, framework-integrated. ssr: false is useful for client-only widgets.

Measuring impact

  • Bundle analyzer: confirm the chunk is split out of main.
  • Lighthouse: TTI and LCP should improve.
  • Network panel: chunks should load on the right interactions.

Senior framing

Lazy loading is the highest-leverage frontend optimization. A 30% reduction in initial bundle is common; correlate that with a 30% improvement in TTI. Combine with preload-on-intent to make navigation feel instant while keeping the initial download small.

Follow-up questions

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

Common mistakes

  • Wrapping the entire app in one Suspense — a single slow chunk blocks everything.
  • Lazy-loading above-the-fold UI — adds flash without benefit.
  • Forgetting an error boundary — chunk 404 produces a white screen.

Performance considerations

  • Route-level splitting is highest ROI. Component-level splitting matters for heavy deps. Always pair with preloading-on-intent so the network round-trip is hidden behind user latency.

Edge cases

  • Stale chunks after deploy: old index.html references a hash that no longer exists. Add retry-on-fail.
  • SSR + lazy: Next.js needs dynamic() instead of React.lazy in Pages Router.
  • Suspense for data fetching requires a framework or 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

Senior framing: lazy loading is the architectural answer to bundle size, not a tactic. Plan splits at user-journey boundaries (logged-out vs logged-in, admin vs user), measure with bundle budgets, and enforce in CI.

Related questions