Master React code splitting — how React.lazy and Suspense work together, why route-based splitting gives the biggest wins, how to handle failed lazy loads, and what named exports require.
Code splitting breaks your JavaScript bundle into smaller chunks that load on demand. Instead of forcing every user to download your entire application upfront, you deliver the minimum code needed for the current page and fetch additional chunks only when the user navigates or triggers a feature. React.lazy wraps the dynamic import — it's the lazy loader. Suspense provides the loading boundary — it shows a fallback while the chunk downloads. Together they give you on-demand loading with almost no boilerplate.
Without code splitting, your bundler (webpack, Vite) combines every JavaScript file in your app into one large file. Every user downloads this bundle on their first visit — even if they only use 10% of the features. A user landing on the login page downloads all the code for the admin panel, the user profile, the analytics dashboard, and every other page they haven't visited yet.
Code splitting solves this by creating separate JavaScript chunks for different parts of the app, loaded on demand.
Before React.lazy, bundlers introduced import() as a dynamic version of the static import statement. It returns a Promise that resolves to the module:
// Static import — loaded immediately, part of the main bundle
import Dashboard from './Dashboard';
// Dynamic import — loaded on demand, creates a separate chunk
import('./Dashboard').then(module => {
const Dashboard = module.default;
render(<Dashboard />);
});
Bundlers automatically create a separate JavaScript file (chunk) for every dynamic import target. The chunk is only fetched when the import() call executes.
React.lazy wraps a dynamic import so you can use the lazily-loaded component just like any other React component. It must receive a function that returns a Promise resolving to a module with a default export:
// Without lazy — AdminPanel is in the main bundle
import AdminPanel from './AdminPanel';
// With lazy — AdminPanel becomes a separate chunk, loaded on demand
const AdminPanel = React.lazy(() => import('./AdminPanel'));
// Usage is identical — React handles the loading
function App() {
return (
<Suspense fallback={<Spinner />}>
<AdminPanel />
</Suspense>
);
}
React.lazy only works with default exports. If the component is a named export, create an intermediate module that re-exports it as default, or use the workaround below.
Suspense is the boundary component that shows a fallback UI while a lazy component's chunk is downloading. It can wrap one or many lazy components — it shows the fallback if any child is still loading:
// One Suspense, multiple lazy children — fallback shows until ALL are ready
<Suspense fallback={<PageSkeleton />}>
<LazyHeader />
<LazyContent />
<LazyFooter />
</Suspense>
// Nested Suspense — each section has its own loading state
<Suspense fallback={<HeaderSkeleton />}>
<LazyHeader />
<Suspense fallback={<ContentSkeleton />}>
<LazyContent />
</Suspense>
</Suspense>
The highest-ROI code splitting strategy is to split at the route level — each page becomes its own chunk. A user visiting /dashboard never downloads the code for /settings:
const Home = React.lazy(() => import('./pages/Home'));
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
Lazy loading can fail — the network is unreliable, a deployment changed the chunk filename, or the user's browser is offline. Without an Error Boundary, a failed lazy load crashes the entire app. Always pair Suspense with an Error Boundary:
<ErrorBoundary fallback={<p>Failed to load. <button onClick={retry}>Retry</button></p>}>
<Suspense fallback={<Spinner />}>
<LazyComponent />
</Suspense>
</ErrorBoundary>
React.lazy requires a default export. For components exported as named exports, create a re-export:
// ❌ Won't work — lazy requires default export
const { UserCard } = React.lazy(() => import('./components'));
// ✅ Option 1: Re-export as default in a wrapper file
// LazyUserCard.js
export { UserCard as default } from './components';
// Then use it
const UserCard = React.lazy(() => import('./LazyUserCard'));
// ✅ Option 2: Inline re-export in the lazy call
const UserCard = React.lazy(() =>
import('./components').then(module => ({ default: module.UserCard }))
);
You can trigger a lazy component's chunk download before the user needs it — on hover, on route transition start, or after the initial page loads — so the chunk is already cached when it's needed:
const Dashboard = React.lazy(() => import('./Dashboard'));
// Preload on hover — by the time the user clicks, the chunk is cached
function NavLink() {
function handleMouseEnter() {
import('./Dashboard'); // triggers the download, React.lazy will use the cached result
}
return (
<a href="/dashboard" onMouseEnter={handleMouseEnter}>Dashboard</a>
);
}Many developers think code splitting automatically improves performance — if all your split chunks are loaded immediately on page load (e.g. all route chunks eagerly fetched), splitting adds overhead (more HTTP requests) with no benefit. Code splitting only helps when chunks are genuinely deferred until needed.
Many developers think React.lazy works with named exports — it requires a Promise that resolves to a module with a default export. For named exports, you must either re-export as default or use the .then(module => ({ default: module.Named })) pattern.
Many developers forget Error Boundaries alongside Suspense for lazy loading — a failed chunk load (network error, deployment mismatch) will crash the app if there's no Error Boundary catching the load failure. Suspense + ErrorBoundary is always a pair.
Many developers split every component into its own chunk — over-splitting creates hundreds of tiny HTTP requests that hurt performance more than the large bundle did. The right granularity is route-level for SPAs and large feature modules for component libraries.
Many developers think Suspense is only for lazy loading — Suspense is a general loading boundary. React Query, Relay, and React 18's use() hook all integrate with Suspense for data fetching. Understanding Suspense as a 'loading boundary' (not a 'lazy boundary') is the complete mental model.
Many developers think dynamic import() is a React feature — it's a JavaScript/bundler feature (webpack, Vite, Rollup). React.lazy is just a React wrapper around dynamic import(). The chunk creation happens at the bundler level, not in React.
Multi-page SPAs: React Router apps split each page route into its own chunk — the /checkout page code isn't downloaded until the user navigates to it, cutting initial bundle size by 60-80% for large apps.
Heavy feature modules: a rich text editor (TipTap, Quill) or data visualisation library (D3, Recharts) is only loaded when the user opens the feature that needs it, not on every page load.
Admin panels: admin features are lazily loaded because most users never access them — splitting /admin routes means regular users never pay the download cost for admin-only code.
Internationalisation: instead of bundling all locale files upfront, lazy-load the specific locale module when the user selects their language.
Map integrations: a Mapbox or Google Maps component is expensive to load. Lazy-loading it means the map chunk is only downloaded when a map-containing page is visited.
Next.js App Router: Next.js performs automatic code splitting per page and layout by default. Understanding React.lazy and Suspense helps you reason about how Next.js's automatic splitting works and how to add component-level splits on top of it.
No questions tagged to this topic yet.
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.