in the right places
Code splitting is not a one-off measure but an ongoing strategy. React.lazy and dynamic imports only load JavaScript when it is genuinely needed, but only if the split boundaries sit in the right places. Too many small chunks slow down loading through HTTP overhead. Too few prevent any benefit at all. This tutorial shows how to find the balance.
Table of Contents
- 1. Why code splitting matters and where it works
- 2. Bundle analysis: measure first, then split
- 3. React.lazy: loading components on demand
- 4. Placing Suspense boundaries strategically
- 5. Route-based splitting: the most important split boundary
- 6. Component splitting: when it pays off
- 7. Preloading: loading chunks before they are needed
- 8. Common mistakes in code splitting
- 9. Splitting strategies compared
- 10. Summary
- 11. FAQ
1. Why code splitting matters and where it works
React Lazy Loading and code splitting solve a concrete performance problem: without splitting, the browser loads the entire JavaScript bundle on the first visit to a page, regardless of which parts of the application the user actually visits. For a larger single-page application with many pages, dashboards, admin areas and heavy libraries, that often means several megabytes of JavaScript that have to be parsed and executed before the first paint. Code splitting breaks this bundle into smaller chunks and loads them on demand.
The metric that code splitting directly improves is Time to Interactive (TTI): the time until the page is fully interactive. Not every metric benefits equally. Largest Contentful Paint (LCP) and First Contentful Paint (FCP) only improve if the initial bundle becomes significantly smaller. That requires the split boundaries to sit in the right places. Routes are the most important and most effective split boundary. Splitting individual components only helps if they bring in heavy dependencies that are not needed on every page.
2. Bundle analysis: measure first, then split
Before introducing code splitting, the current bundle needs to be analyzed. Without measurement there is no basis for deciding where to split and how much improvement to expect. The most important tool: rollup-plugin-visualizer for Vite or webpack-bundle-analyzer for Webpack. Both produce interactive treemaps that show which modules take up how much space in the bundle. Surprisingly often it is not your own components but dependencies: Moment.js, Chart.js, PDF libraries, editor libraries or date pickers.
A second valuable analysis tool is the Coverage tab in Chrome DevTools. It shows how much of the loaded JavaScript is actually executed on the first page visit. Unused areas are candidates for code splitting. Combining both analyses gives a clear picture: which modules are large (bundle analyzer) and which of them are not needed immediately (coverage). This intersection is the starting point for targeted React Lazy Loading measures.
// vite.config.ts, bundle analyzer setup
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
// Generates stats.html, open in browser to see treemap
visualizer({
filename: 'dist/stats.html',
open: true, // auto-open after build
gzipSize: true, // show gzip size (closer to network transfer)
brotliSize: true, // show brotli size
template: 'treemap', // options: treemap | sunburst | network
}),
],
build: {
rollupOptions: {
output: {
// Manual chunking: keep large stable dependencies in separate chunks
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-charts': ['recharts'], // loaded only on chart pages
'vendor-editor': ['@tiptap/react'], // loaded only in editor
},
},
},
},
});
3. React.lazy: loading components on demand
React.lazy takes a function that returns a dynamic import() and returns a lazily loaded component. The dynamic import creates its own chunk that is only loaded when the component is about to be rendered for the first time. React.lazy only supports default exports: the imported file must provide the component as a default export. Named exports have to be moved into a separate file or wrapped with a re-export.
An important detail: React.lazy and the dynamic import trigger the loading of the chunk as soon as the component is about to be rendered, not earlier. That means the first render of the lazily loaded component fires a network request that can cause noticeable latency. For route splitting on a fast connection that is acceptable. For components that appear after a user interaction, preloading can mitigate this problem. Suspense is always mandatory here, React renders the fallback while the chunk is loading.
4. Placing Suspense boundaries strategically
The placement of Suspense boundaries determines which part of the UI gets swapped out while a chunk is loading. Suspense boundaries placed too high, say at the root level, replace the entire page with a loading bar whenever any lazily loaded component is loading. Boundaries placed too deep produce many small, uncoordinated loading states. The best strategy: Suspense boundaries at the route level and for large, self-contained component areas such as modals, dashboard widgets or sidebars.
React 18 and Concurrent Mode improve Suspense behavior: with useTransition, loading a new route chunk can keep the current page visible until the new chunk has loaded, no layout flash from a loading bar between two page contents. This is the "Concurrent Rendering with Suspense" pattern from the previous article. For the fallback itself: skeleton layouts that match the shape of the actual content are better than generic spinners, because they reduce layout shifts after loading.
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
// Route-level lazy loading, each route is a separate chunk
const HomePage = lazy(() => import('./pages/HomePage'));
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const ReportsPage = lazy(() => import('./pages/ReportsPage'));
const AdminPage = lazy(() => import('./pages/AdminPage'));
// Heavy component only needed on specific interaction
const PDFExportModal = lazy(() =>
import('./components/PDFExportModal').then(module => ({
// Re-export named export as default, React.lazy requires default export
default: module.PDFExportModal,
}))
);
function App() {
return (
// Route-level Suspense: full-page skeleton during navigation
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/admin" element={<AdminPage />} />
</Routes>
</Suspense>
);
}
// Component-level Suspense: isolated loading for heavy modal
function ReportsPage() {
const [showExport, setShowExport] = useState(false);
return (
<div>
<ReportTable />
<button onClick={() => setShowExport(true)}>Export as PDF</button>
{showExport && (
// PDF chunk only loads when user explicitly requests export
<Suspense fallback={<ModalSkeleton />}>
<PDFExportModal onClose={() => setShowExport(false)} />
</Suspense>
)}
</div>
);
}
5. Route-based splitting: the most important split boundary
Route-based code splitting is the most important and most effective application of React Lazy Loading. Every route becomes its own chunk: on first visit the user only loads the code for the current page, not the code for every other page in the application. For an application with an admin area, several dashboards and a public area, that means a visitor to the public area never loads the admin JavaScript code. That reduces the initial bundle by roughly 30 to 70 percent, depending on the application.
In React Router v6, route-based splitting is straightforward to implement with React.lazy: each route component is loaded via a dynamic import. In the Next.js App Router this splitting happens automatically, every page.tsx file is automatically split into its own chunk. For very large page components, you can split further within a route, for example by loading heavy tab content only when the tab is activated. The pattern: state controls the visibility, Suspense and lazy control the chunk.
6. Component splitting: when it pays off
Not every component is worth a split boundary. Splitting small components with little JavaScript leads to more HTTP requests for a minimal chunk-size gain. The rule of thumb: component splitting pays off when the component has heavy dependencies that other pages do not need, and when it is not visible immediately on the first page visit. Good candidates are: rich text editors (Tiptap, Quill, Slate, often 200+ kB), chart libraries (Recharts, Chart.js, 100+ kB), PDF viewers, code syntax highlighters (Prism, Highlight.js) and maps (Leaflet, Mapbox GL).
Poor candidates for component splitting: navigation components that appear on every page. Forms with few fields and no heavy dependencies. Modal dialogs that should appear immediately after the first user interaction without allowing any noticeable latency. For these cases, preloading is the better strategy: load the chunk as soon as the user hovers the mouse over the trigger, so it is already cached by the time the component is actually rendered.
7. Preloading: loading chunks before they are needed
Preloading solves the latency problem on the first render of a lazy chunk. Instead of loading the chunk only when it is about to render, it is loaded ahead of time on hover over a button or during the browser's idle state. That means the chunk is already in the browser cache by the time the click actually happens, and is available instantly, perceived load time close to zero. The pattern: call import() on the hover event without using the result. The browser loads and caches the chunk, and React.lazy can then read it straight from the cache on the next render.
A cleaner alternative is to use link rel="prefetch" or link rel="preload" tags, which the browser can load independently in the background at the lowest priority. Webpack and Vite support magic comments for these hints: import(/* webpackPrefetch: true */ './HeavyComponent') or import(/* webpackPreload: true */ './HeavyComponent'). Prefetch loads the chunk during the idle state, preload loads it at the same time as the current chunk, preload only makes sense when the chunk is very likely needed immediately.
import { lazy, Suspense, useCallback } from 'react';
// Lazy load heavy chart component
const ChartDashboard = lazy(() =>
import(
/* webpackChunkName: "chart-dashboard" */
/* webpackPrefetch: true */
'./ChartDashboard'
)
);
// Preload pattern: trigger import on hover, render on click
const preloadChart = () => import('./ChartDashboard');
function ReportButton() {
const [showChart, setShowChart] = useState(false);
return (
<>
<button
// Preload chunk on hover, by click it's already in cache
onMouseEnter={preloadChart}
onFocus={preloadChart} // keyboard navigation support
onClick={() => setShowChart(true)}
>
Show report
</button>
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<ChartDashboard />
</Suspense>
)}
</>
);
}
// Idle-time preloading: load chunks when browser has nothing else to do
function useIdlePreload(importFn: () => Promise<unknown>) {
useEffect(() => {
const id = requestIdleCallback(
() => { importFn(); }, // browser calls this when idle
{ timeout: 3000 } // force after 3 seconds even if not idle
);
return () => cancelIdleCallback(id);
}, [importFn]);
}
8. Common mistakes in code splitting
The most common mistake: overly granular splitting. If 50 small components are each split into their own chunks, you end up with 50 separate HTTP requests, better on HTTP/2 than on HTTP/1.1, but still overhead from DNS lookup, TCP handshake, TLS and request overhead. The net benefit of small chunks is quickly wiped out by this overhead. The minimum size for a worthwhile split is around 30 to 50 kB gzipped, below that the ratio of overhead to savings is unfavorable.
A second mistake: no error boundaries around Suspense boundaries. If a chunk-loading error occurs, for example because the user opened a stale URL and the chunk filename changed with a new build, React throws an error. Without an error boundary the entire application crashes and shows a blank page. With an error boundary the error is caught and a meaningful error message with a reload button is shown. Every Suspense boundary should be wrapped in an error boundary.
9. Splitting strategies compared
Different split strategies have different effects on performance, developer experience and maintainability. Comparing them helps choose the right strategy for the given context.
| Strategy | Effect | Effort | Recommendation |
|---|---|---|---|
| Route splitting | Very high (30-70% smaller) | Low (React.lazy + Suspense) | Always do this first |
| Heavy libraries | High (large dependencies) | Medium (dynamic import) | For libraries > 50 kB |
| Modals / overlays | Medium (only with heavy deps) | Low | Combine with preloading |
| manualChunks (vendor) | Caching (stable hash) | Medium (build configuration) | For React, router, utility libs |
| Micro-splitting (< 10 kB) | Negative (HTTP overhead) | High (maintenance overhead) | Avoid |
The optimal chunk size is 50 to 150 kB gzipped for initial chunks and 20 to 100 kB for lazy chunks. Initial chunks should be stable (same hash when dependencies are unchanged) so users can load them from the browser cache. Vendor splitting via manualChunks achieves that: React, React-DOM and other stable libraries get their own chunk with a stable hash that only changes on library updates.
Mironsoft
React performance, bundle optimization and code splitting strategy
Need to analyze your React bundle and optimize code splitting?
We analyze your React bundle with Visualizer and Coverage tools, identify the biggest optimization opportunities and implement a splitting strategy that measurably improves Time to Interactive.
Bundle audit
Treemap analysis, coverage report and chunk strategy recommendation
Implementation
Set up route splitting, vendor chunks and a preloading strategy
Measurement
Compare Lighthouse, Web Vitals and TTI before and after optimization
10. Summary
React Lazy Loading and code splitting are powerful performance tools that only deliver their benefit once the split boundaries sit in the right places. The first and most important measure is always route-based splitting: every route in its own chunk, loaded only on visit. Bundle analysis with a visualizer and a browser coverage tool shows which additional dependencies are candidates for splitting or vendor chunking. Preloading on hover or during idle state removes noticeable latency on the first render of lazily loaded chunks.
Error boundaries around every Suspense boundary are mandatory, so chunk-loading errors do not lead to a blank page. Avoid micro-splitting small components, the HTTP overhead outweighs the bundle-size benefit. Vendor chunks for stable libraries like React and React-DOM improve cache stability across deployments. Regular bundle analysis after every larger feature, not just once at the start, keeps the performance configuration current.
React Lazy Loading & Code Splitting, the essentials at a glance
Measure first
rollup-plugin-visualizer + browser Coverage tab. Identify the largest, rarely used modules. Then split, not the other way around.
Routes first
Route splitting via React.lazy brings 30 to 70 percent smaller initial bundles. Each route equals one chunk. Next.js does this automatically.
Preloading
Call import() on hover/focus. The chunk is cached by the time the user clicks. Use webpackPrefetch for idle-state preloading.
Error boundaries
Wrap every Suspense boundary in an error boundary. Otherwise chunk-loading errors lead to a blank page with no error message.