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.
Lazy loading splits your app into chunks that download on demand.
Basic usage
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
Profilecode. - 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):
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:
const Chart = lazy(() => import('./Chart')); // includes rechartsA 200 KB chart library no longer ships in the initial bundle if charts are conditional.
3. Modals and dialogs:
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:
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:
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:
<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
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.