What Is React.lazy and How Does Suspense Work?
Advertisement
🧩 Scenario
Architecture Walkthrough
How React.lazy and Dynamic import() Work Together
React.lazy does not do code splitting on its own — it is a React-level wrapper around the bundler's code splitting primitive: the dynamic import() function. When webpack, Vite, or another bundler encounters a dynamic import() call, it creates a separate output chunk file for that module. That chunk is not included in the main bundle and is not downloaded on initial page load.
React.lazy(() => import('./AdminPanel')) stores the import function without calling it. When React first encounters this lazy component during rendering, it calls the import function, which triggers a network fetch for the chunk file. While the fetch is in flight, React throws a Promise (this is the internal protocol Suspense uses), which causes the nearest Suspense boundary to show its fallback. When the Promise resolves (the chunk is loaded), React resumes rendering the component from its actual code.
The Three-State Model
A lazy component can be in three states. Pending: the chunk is being downloaded; Suspense shows its fallback. Resolved: the chunk loaded successfully; React renders the component normally. Rejected: the chunk failed to load (network error, bad URL, deploy-time cache busting); React throws an error that an ErrorBoundary can catch. Without an ErrorBoundary, a rejected chunk crashes the Suspense subtree and shows a blank screen.
Named Export Limitation
React.lazy requires the dynamic import to resolve to a module with a default export. The module's shape must be { default: ComponentType }. Named exports (export const MyComponent = ...) do not satisfy this. The adaptation is a .then() call that reshapes the module: lazy(() => import('./module').then(m => ({ default: m.MyComponent }))). Without this, the lazy component is undefined and React throws immediately on render.
Key Code Explained
import { lazy, Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// Default exports: direct use
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
// Named export: .then() wrapper required
const RevenueChart = lazy(() =>
import('./charts/revenue').then((module) => ({
default: module.RevenueChart, // wrap named export as default
}))
);
// Route-level lazy loading: entire page is a separate chunk
function App() {
return (
<BrowserRouter>
{/*
ErrorBoundary catches failed chunk loads.
Without it, a chunk network error shows a blank screen.
*/}
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<p>Failed to load this page: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}
>
{/*
Suspense shows the fallback while any lazy component in this
subtree is loading. Fallback disappears when the chunk loads.
*/}
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
</ErrorBoundary>
</BrowserRouter>
);
}
// Component-level lazy loading: only one section is deferred
function AnalyticsPage() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<h1>Analytics</h1>
<p>Summary text always renders immediately</p>
<button onClick={() => setShowChart(true)}>Load Chart</button>
{showChart && (
<ErrorBoundary fallbackRender={({ resetErrorBoundary }) => (
<button onClick={resetErrorBoundary}>Retry chart</button>
)}>
{/* Chunk fetched only when showChart becomes true */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
</ErrorBoundary>
)}
</div>
);
}
// Verifying code splitting works: check the Network tab
// Before: one large bundle.js
// After: bundle.js + Dashboard.chunk.js, Analytics.chunk.js
// Dashboard.chunk.js should appear in the Network tab only when /dashboard is visited
// Preloading: reduce the loading delay for likely-to-be-needed components
function NavLink({ to, label }: { to: string; label: string }) {
const handleMouseEnter = () => {
// Trigger the chunk download before the user clicks
// import() is idempotent — calling it multiple times resolves from cache
switch (to) {
case '/dashboard': import('./pages/Dashboard'); break;
case '/analytics': import('./pages/Analytics'); break;
}
};
return (
<Link to={to} onMouseEnter={handleMouseEnter}>
{label}
</Link>
);
}
The resetErrorBoundary from react-error-boundary resets the boundary state, which causes React to re-attempt rendering the lazy component and re-trigger the import(). If the user was on a slow connection and the chunk failed, clicking "Try again" retries the network request. This is the correct retry mechanism for chunk load failures.
Tradeoffs
| Lazy loading scope | Bundle impact | First-visit delay | Complexity |
|---|---|---|---|
| No code splitting | Large initial bundle | None | None |
| Route-level splitting | Moderate reduction | Visible on first route visit | Low |
| Component-level splitting | Larger reduction | Only when component is first shown | Low |
| Preloading on hover | Same as above | Often eliminated | Low |
What Interviewers Actually Check
- Whether you know
Suspenseis required (not optional) and what happens without it - Whether you know the three-state model: pending/resolved/rejected
- Whether you pair
SuspensewithErrorBoundaryfor chunk failures - Whether you know the default export requirement and the
.then()adaptation - Whether you know
React.lazyis client-only and Next.js has a different solution for SSR
Follow-Up Questions
- How does Next.js
dynamic()differ fromReact.lazy? What does it add for SSR? - How do you verify in the browser that code splitting is working correctly?
- When would you place the
Suspenseboundary at the component level instead of the route level? - How does React 18 streaming SSR use
Suspensedifferently from client-side code splitting? - If a lazy component fails to load, how does
resetErrorBoundaryfromreact-error-boundarylet the user retry?
Common Candidate Mistakes
- Not wrapping in
Suspense— React throws immediately with a clear message, but candidates forget this - Not adding an
ErrorBoundary— users get a blank screen when CDN is down or a chunk URL changes after deploy - Using
React.lazywith a named export and not understanding why the component is undefined at runtime - Placing the
Suspenseat the very root — one slow lazy component shows a fallback for the entire page - Not knowing the Network tab is how you verify code splitting is working
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you explain the three states of a lazy component and what renders in each?
- Can you implement
React.lazywithSuspenseand anErrorBoundary? - Can you explain why
React.lazyrequires a default export? - Can you adapt a named export with a
.then()wrapper? - Can you explain how to verify code splitting is working in the browser?
Summary
React.lazy wraps a dynamic import() call, telling the bundler to split that component into a separate chunk file. The chunk is not included in the initial bundle and is fetched only when the lazy component is first encountered during rendering. While the fetch is in flight, React throws a Promise that the nearest Suspense boundary intercepts, showing its fallback. When the chunk loads, React resumes and renders the component. If the chunk fails to load, an ErrorBoundary is needed to catch the error and show a retry option; without one, chunk failures produce a blank screen. React.lazy requires the import to resolve to a module with a default export; use a .then() wrapper to adapt named exports. React.lazy is client-only; for SSR use Next.js dynamic().
Can React.lazy be used on the server (SSR)?
React.lazy and Suspense for code splitting are client-only in the classic sense. For SSR, use frameworks like Next.js with its dynamic() function, which handles SSR fallbacks correctly. React 18 streaming SSR uses Suspense differently — for server-side data fetching boundaries, not just code splitting.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement