React's concurrent rendering and automatic batching improve runtime performance, but they do not reduce the amount of JavaScript the browser has to download and parse before anything appears on screen. A large initial bundle is a load time problem that runtime optimizations do not solve.
Code splitting addresses this by dividing the bundle into smaller chunks that are loaded on demand rather than all at once. Lazy loading defers the loading of specific components until they are needed. Together, they reduce the initial payload the browser must process before the first meaningful render.
What this covers:
Dynamic imports and how they split bundles
React.lazyandSuspensefor component-level lazy loadingRoute-based code splitting with React Router v6
Wrapping lazy routes in a shared Suspense boundary
Real-world cases where lazy loading provides the most benefit
Error boundaries with lazy-loaded components
Bundle analysis to identify what to split
Why Bundle Size Still Matters
The first load of a React application requires the browser to download, parse, and execute JavaScript before the user sees anything interactive. A bundle that includes every page, every chart library, and every admin component delivers all of that cost upfront — including to users who will never visit the profile page or open the admin dashboard.
The impact is measurable:
Largest Contentful Paint (LCP) degrades when the browser is blocked executing JavaScript before it can render visible content
Time to Interactive (TTI) increases because the main thread is busy parsing a large bundle
Mobile users experience the cost more severely: slower CPUs parse JavaScript more slowly, and mobile networks frequently have higher latency than desktop connections
Code splitting does not change what code exists. It changes when the browser loads each piece of it.
Step 1: Dynamic Imports
Dynamic imports are the foundation of code splitting in JavaScript. A static import is evaluated at bundle time and its contents are included in the initial chunk:
// Static — included in the initial bundle
import Chart from './components/Chart';A dynamic import is evaluated at runtime and produces a separate chunk that is fetched when the import executes:
// Dynamic — loaded only when this code runs
const { default: Chart } = await import('./components/Chart');Bundlers (Webpack, Vite, Rollup) recognize dynamic imports and automatically create a separate chunk for the imported module. The application fetches that chunk only when the import statement runs.
React.lazy wraps this pattern in a form React can work with directly.
Step 2: React.lazy and Suspense
React.lazy takes a function that returns a dynamic import and produces a component that React can render lazily:
import { lazy, Suspense } from 'react';
const Chart = lazy(() => import('./components/Chart'));
function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<div>Loading chart...</div>}>
<Chart />
</Suspense>
</div>
);
}When Chart renders for the first time, React fetches the chunk, shows the Suspense fallback while it loads, and replaces the fallback with the component when the chunk is ready. On subsequent renders, the chunk is already cached and the fallback is not shown again.
The Suspense boundary can wrap multiple lazy components. All of them show the same fallback while any of them are loading:
<Suspense fallback={<DashboardSkeleton />}>
<Chart />
<RecentActivity />
<MetricsSummary />
</Suspense>For independent components that should each show their own loading state, use separate Suspense boundaries:
<div>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>Step 3: Route-Based Code Splitting
Route-based splitting is the highest-impact application of lazy loading in most React apps. Users visiting the homepage should not download the JavaScript for the account settings page, the admin dashboard, or any other route they have not navigated to.
With React Router v6:
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
const Home = lazy(() => import('./pages/Home'));
const Profile = lazy(() => import('./pages/Profile'));
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
const router = createBrowserRouter([
{
path: '/',
element: (
<Suspense fallback={<PageSpinner />}>
<Home />
</Suspense>
),
},
{
path: '/profile',
element: (
<Suspense fallback={<PageSpinner />}>
<Profile />
</Suspense>
),
},
{
path: '/admin',
element: (
<Suspense fallback={<PageSpinner />}>
<AdminDashboard />
</Suspense>
),
},
]);
export default function App() {
return <RouterProvider router={router} />;
}Each route becomes its own bundle chunk. The Home chunk loads on the initial visit. The Profile chunk loads only when the user navigates to /profile.
For cleaner route definitions, a wrapper component handles the Suspense boundary:
function LazyPage({ component: Component }) {
return (
<Suspense fallback={<PageSpinner />}>
<Component />
</Suspense>
);
}
const router = createBrowserRouter([
{ path: '/', element: <LazyPage component={Home} /> },
{ path: '/profile', element: <LazyPage component={Profile} /> },
]);Step 4: Error Boundaries for Failed Loads
Lazy-loaded chunks can fail to load due to network issues, deployment changes, or cache inconsistencies. Without error handling, a failed chunk fetch causes the entire component tree to crash.
An Error Boundary wraps lazy-loaded components to handle this gracefully:
import { Component } from 'react';
class ChunkErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<div>
<p>Failed to load this section.</p>
<button onClick={() => window.location.reload()}>
Reload
</button>
</div>
);
}
return this.props.children;
}
}Usage:
<ChunkErrorBoundary>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
</ChunkErrorBoundary>A common failure mode is a deployment that invalidates the chunk filenames (hash-based filenames change on each build) while users still have the old HTML in their browser. The old HTML references chunk URLs that no longer exist. The reload button in the error UI handles this case.
Where Lazy Loading Provides the Most Value
Lazy loading is most effective for components that are heavy and not needed on the initial render:
Data visualization libraries. Chart.js, D3, Recharts, and similar libraries add hundreds of kilobytes to the bundle. A chart that appears below the fold or only on a specific page should not be in the initial bundle.
Maps. Google Maps, Leaflet, and Mapbox are large. Load them only when the map component is actually rendered.
Rich text editors. TipTap, Quill, and similar editors are heavy. Load them only when the user opens an editing context.
Modals and drawers. Components that are hidden on load and only appear on user interaction are good candidates for lazy loading.
Admin or analytics sections. If only a subset of users access these routes, splitting them out prevents non-admin users from downloading them.
Avoid lazy loading navigation, authentication forms, and any content the user sees immediately on the first render. The loading fallback is visible for these components, which degrades the perceived performance of the initial load.
Analysing the Bundle
Before and after code splitting, a bundle analyzer provides a visual map of what is in each chunk and how large each dependency is.
For Vite projects:
npm install --save-dev rollup-plugin-visualizer// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [visualizer({ open: true })],
};For Webpack projects:
npm install --save-dev webpack-bundle-analyzerThe analyzer output shows which modules are largest, which are duplicated, and which are in the initial chunk versus lazy chunks. This is the practical starting point for deciding what to split.
Key Takeaways
Dynamic imports tell the bundler to create a separate chunk for the imported module, loaded at runtime rather than upfront.
React.lazywraps a dynamic import for use as a React component.Suspenseprovides the fallback UI while the chunk loads.Route-based splitting is the highest-impact application: each page loads only its own chunk when navigated to.
Separate
Suspenseboundaries allow independent loading states for independent components.Error Boundaries around lazy components handle chunk load failures gracefully. A reload button addresses the common case of stale HTML referencing invalidated chunk URLs.
Lazy load heavy, non-critical components. Do not lazy load navigation, login forms, or anything visible on the initial render.
A bundle analyzer shows what is in each chunk and identifies the largest candidates for splitting.
Conclusion
Code splitting and lazy loading are the primary tools for reducing what the browser downloads before the first render. Runtime optimizations like concurrent rendering improve the experience after the JavaScript is loaded. Reducing the initial bundle improves the experience before and during load — which is where most users encounter performance problems.
The implementation is straightforward: dynamic import, React.lazy, and Suspense. The returns compound across a codebase as more heavy, non-critical components are split into their own chunks.
Applied code splitting to a specific component or route and noticed a measurable improvement? Share the before and after in the comments.




