Code Splitting & lazy
Every kilobyte of JavaScript you ship has to be downloaded, parsed, and executed before your users can interact with the page. As an app grows, bundling everything into one file means a user visiting the login screen also pays for the dashboard, the settings panel, and the rarely-used admin tools. Code splitting fixes this by breaking the bundle into smaller chunks that load on demand, and React makes the technique first-class through React.lazy and Suspense.
How code splitting works
Under the hood, code splitting relies on the dynamic import() syntax. A static import is resolved at build time and folded into the main bundle; a dynamic import() returns a Promise and tells the bundler (Vite, webpack, Rollup) to emit that module as a separate chunk. The chunk is fetched over the network only when the import() call actually runs.
// Static import — bundled into the main chunk eagerly.
import { formatDate } from './utils';
// Dynamic import — its own chunk, fetched only when this line executes.
const heavy = await import('./heavyChart');
heavy.render();
You rarely call import() by hand for components, though. React wraps this pattern in React.lazy.
React.lazy and Suspense
React.lazy takes a function that returns a dynamic import() and gives you back a component you can render like any other. Because the module loads asynchronously, you must render the lazy component inside a <Suspense> boundary that supplies a fallback to show while the chunk is in flight.
import { lazy, Suspense } from 'react';
// SettingsPanel and its dependencies become a separate chunk.
const SettingsPanel = lazy(() => import('./SettingsPanel'));
function App() {
const [showSettings, setShowSettings] = useState(false);
return (
<div>
<button onClick={() => setShowSettings(true)}>Open settings</button>
{showSettings && (
<Suspense fallback={<p>Loading settings…</p>}>
<SettingsPanel />
</Suspense>
)}
</div>
);
}
The SettingsPanel chunk is not downloaded until the user clicks the button. Note that React.lazy only works with default exports — the module’s default must be a React component. If you use named exports, re-map them inside the import.
const Chart = lazy(() =>
import('./Chart').then((mod) => ({ default: mod.Chart }))
);
Tip: Place
Suspenseboundaries thoughtfully. One boundary high in the tree means a single fallback for a large region; several smaller boundaries let independent sections stream in separately and keep the UI responsive.
Route-based splitting
The highest-impact place to split is at route boundaries — a user almost never needs every page at once. With a router like React Router, lazy-load each route’s component so navigating to a page triggers its chunk download.
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Admin = lazy(() => import('./pages/Admin'));
export default function App() {
return (
<BrowserRouter>
<Suspense fallback={<div className="spinner" />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/admin" element={<Admin />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
The initial bundle now contains only the shell and the first route’s code. The Admin chunk — often the heaviest and least-visited — only ships to the handful of users who actually open it.
Component-based splitting
Route splitting handles the coarse structure, but individual components can also be heavy: a rich text editor, a charting library, a map, or a modal that most users never open. Defer these the same way.
const RichTextEditor = lazy(() => import('./RichTextEditor'));
function CommentBox() {
const [editing, setEditing] = useState(false);
return editing ? (
<Suspense fallback={<textarea disabled placeholder="Loading editor…" />}>
<RichTextEditor />
</Suspense>
) : (
<button onClick={() => setEditing(true)}>Write a comment</button>
);
}
You can warm the chunk before it is needed by calling the import on hover or focus, so the code is already cached by the time the user clicks:
<button
onMouseEnter={() => import('./RichTextEditor')}
onClick={() => setEditing(true)}
>
Write a comment
</button>
Measuring bundle impact
Splitting blindly can backfire — too many tiny chunks add request overhead. Measure before and after. Vite uses Rollup, so you can visualize chunk composition with rollup-plugin-visualizer.
npm install -D rollup-plugin-visualizer
npx vite build
Output:
dist/assets/index-a1b2c3.js 48.2 kB │ gzip: 16.1 kB
dist/assets/Dashboard-d4e5f6.js 91.7 kB │ gzip: 28.4 kB
dist/assets/Admin-g7h8i9.js 142.3 kB │ gzip: 41.0 kB
✓ built in 1.84s
Each lazy boundary shows up as its own line, making it easy to spot which routes are bloated and worth splitting further. The table below summarizes the common strategies.
| Strategy | Granularity | Triggered by | Best for |
|---|---|---|---|
| Route-based | Per page | Navigation | The default, highest-ROI split |
| Component-based | Per feature | User interaction | Heavy widgets, modals, editors |
| Vendor splitting | Per library | Build config | Caching stable third-party code |
| Eager preloading | Per chunk | Hover/idle | Hiding latency before a click |
Best Practices
- Split at route boundaries first — it gives the biggest payload reduction for the least effort.
- Always quote a lazy component inside a
<Suspense>boundary with a meaningful fallback that matches the eventual layout to avoid layout shift. - Remember
React.lazyrequires a default export; re-map named exports inside theimport(). - Preload likely-next chunks on hover, focus, or during idle time to hide network latency.
- Avoid over-splitting — many tiny chunks add request overhead and can slow things down.
- Measure with a bundle visualizer before and after; let real chunk sizes guide where to split.
- Keep shared dependencies in a stable vendor chunk so browser caching survives app deploys.