The problem: one giant bundle
When you build a React app without code splitting, every component, library, and utility ends up in a single JavaScript file. A user who lands on your home page downloads the code for the settings panel, the admin dashboard, the PDF viewer — everything — before they see anything interactive.
The result is a high Time to Interactive (TTI), slow First Contentful Paint (FCP), and unnecessary parse/compile work on the main thread. Code splitting fixes this by breaking the bundle into smaller chunks that are fetched on demand, so the initial page only loads what it actually needs.
React.lazy and dynamic import()
React's built-in answer to component-level splitting is React.lazy paired with
the native import() function. The dynamic import() is a bundler hint —
Webpack and Vite see it and automatically emit a separate chunk at build time.
import { lazy, Suspense } from 'react'
// Static import — always in the initial bundle
// import Dashboard from './Dashboard'
// Lazy import — Dashboard becomes its own chunk, loaded on demand
const Dashboard = lazy(() => import('./Dashboard'))
function App() {
return (
<Suspense fallback={<div>Loading dashboard…</div>}>
<Dashboard />
</Suspense>
)
}
When React first tries to render <Dashboard />, it fires the import() call.
While the chunk is in flight, the nearest <Suspense> boundary renders the
fallback. Once the chunk resolves, React swaps in the real component. On
subsequent renders the module is cached — the fallback never shows again.
Suspense boundaries: placement and granularity
React.lazy requires a <Suspense> ancestor somewhere above it in the tree.
Where you place the boundary controls how much of the UI is replaced by the
fallback during loading.
A single top-level boundary shows a full-page spinner — simple, but the whole page blanks out while any lazy chunk is pending. Nested boundaries let each section load independently, showing skeletons that match the real layout and keeping the rest of the page fully interactive.
// Top-level — entire page replaced by fallback
<Suspense fallback={<PageSpinner />}>
<Sidebar />
<MainContent />
</Suspense>
// Nested — each section has its own fallback
<>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
<Suspense fallback={<ContentSkeleton />}>
<MainContent />
</Suspense>
</>
Use a skeleton that matches the real component's height and layout to avoid Cumulative Layout Shift (CLS) — the page should not jump when the real content appears.
Route-based splitting with React Router
Route-based splitting is the highest-leverage change you can make. Each route is a natural isolation boundary; users rarely visit every route in a session, so there is no reason to ship every route's code on the first load.
import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
const Home = lazy(() => import(/* webpackChunkName: "home" */ './pages/Home'))
const Dashboard = lazy(() => import(/* webpackChunkName: "dashboard" */ './pages/Dashboard'))
const Settings = lazy(() => import(/* webpackChunkName: "settings" */ './pages/Settings'))
function App() {
return (
<BrowserRouter>
{/* One Suspense wraps all routes — each route chunk is fetched on navigate */}
<Suspense fallback={<PageSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</BrowserRouter>
)
}
The /* webpackChunkName */ magic comment gives each chunk a human-readable
filename in the build output, which makes bundle analysis and production error
logs far easier to read.
Error boundaries: handling chunk load failures
A <Suspense> boundary only catches the pending state. If a chunk fetch fails
— due to a network error, a 404 after a deploy, or the user going offline —
React propagates an error up the tree. You need an Error Boundary to
intercept it and give the user a recovery path.
import { Component } from 'react'
class ChunkErrorBoundary extends Component {
state = { hasError: false }
static getDerivedStateFromError() {
return { hasError: true }
}
render() {
if (this.state.hasError) {
return (
<div>
<p>Failed to load. Check your connection and try again.</p>
<button onClick={() => this.setState({ hasError: false })}>
Retry
</button>
</div>
)
}
return this.props.children
}
}
// Error boundary wraps Suspense, not the other way around
<ChunkErrorBoundary>
<Suspense fallback={<Spinner />}>
<LazyDashboard />
</Suspense>
</ChunkErrorBoundary>
The library react-error-boundary provides a hook-friendly version of this
pattern if you want to avoid writing class components.
Prefetching: loading before the user asks
Once your initial bundle is lean, you can use idle time to prefetch chunks
the user is likely to need next. Because the browser caches import() results
by URL, calling the same import path before React.lazy needs it means the chunk
is already available when navigation happens — the Suspense fallback is skipped
entirely.
const Dashboard = lazy(() => import('./pages/Dashboard'))
function NavLink() {
// Trigger the fetch on hover — ~100–300 ms head start before the click
const prefetch = () => import('./pages/Dashboard')
return (
<a href="/dashboard" onMouseEnter={prefetch} onFocus={prefetch}>
Dashboard
</a>
)
}
// Or prefetch everything after the page is idle
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(() => {
import('./pages/Dashboard')
import('./pages/Settings')
})
}
Webpack's /* webpackPrefetch: true */ magic comment automates this by emitting
a <link rel="prefetch"> tag in the HTML, so the browser handles it natively
during idle time.
Measuring the impact with bundle analysis
You cannot optimize what you cannot see. Run bundle analysis before and after splitting to confirm actual savings.
# Vite — rollup-plugin-visualizer
# Add to vite.config.ts:
# import { visualizer } from 'rollup-plugin-visualizer'
# plugins: [react(), visualizer({ open: true, gzipSize: true })]
npm run build
# Webpack — webpack-bundle-analyzer
npx webpack --profile --json > stats.json
npx webpack-bundle-analyzer stats.json
# Check chunk sizes in the build output
# dist/assets/index-3f2a.js → 87 KB (initial)
# dist/assets/dashboard-9c1b.js → 142 KB (on demand ✓)
# dist/assets/reports-4d7e.js → 198 KB (on demand ✓)
Also check the Network tab in DevTools with CPU and network throttling
enabled — confirm that on-demand chunks appear as separate requests timed to
navigation, not on the initial page load. Set a bundle size budget in CI (via
bundlesize or Vite's build.chunkSizeWarningLimit) to catch regressions
before they reach production.
Key takeaways
- Use
React.lazy+import()to split any component into its own chunk. - Always wrap lazy components in
<Suspense>and pair with an Error Boundary. - Start with route-based splitting — it gives the biggest wins for the least complexity.
- Name your chunks with magic comments to make build output readable.
- Prefetch on hover or in idle time to make lazy loading invisible to the user.
- Measure with a bundle analyzer before and after — and again in CI to prevent regressions.