Code Splitting Strategies
A React app compiled without code splitting produces a single JavaScript file — often 500 KB to several megabytes. Every visitor downloads this entire file before the page becomes interactive, even if they only use 10% of it. Code splitting divides that bundle into smaller chunks that load on demand. The result: faster Time to Interactive (TTI), better Lighthouse scores, and a smoother experience on slow connections.
Why Bundle Size Matters
JavaScript is expensive in two ways: downloading it costs bandwidth, and parsing and executing it blocks the main thread. A 1 MB JS bundle on a mid-range phone can take 5–8 seconds to parse — before a single pixel is interactive. Code splitting makes the initial chunk as small as possible; additional chunks load in the background or on-demand.
Metric | Impact of large initial bundle |
|---|---|
Time to Interactive (TTI) | Delayed — JS must parse before UI responds |
First Contentful Paint | Slightly delayed — parser block |
Lighthouse Performance | Lower score, especially on mobile simulation |
Bounce rate | Higher — users leave slow pages |
Strategy 1: Route-Based Splitting
Route-based splitting is the most impactful change you can make. Each page of your app becomes its own chunk — users who only visit the homepage never download the dashboard code. With React Router v6 and React.lazy:
import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
// webpack / Vite sees import() and creates a separate chunk for each module.
// The magic comment gives the chunk a readable name in DevTools.
const Home = lazy(() => import(/* webpackChunkName: "page-home" */ './pages/Home'))
const Dashboard = lazy(() => import(/* webpackChunkName: "page-dashboard" */ './pages/Dashboard'))
const Settings = lazy(() => import(/* webpackChunkName: "page-settings" */ './pages/Settings'))
const Reports = lazy(() => import(/* webpackChunkName: "page-reports" */ './pages/Reports'))
export default function App() {
return (
<BrowserRouter>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/reports" element={<Reports />} />
</Routes>
</Suspense>
</BrowserRouter>
)
}
function PageLoader() {
return <div style={{ textAlign: 'center', padding: 64 }}>Loading…</div>
}Strategy 2: Component-Based Splitting
Some components are heavy but only needed conditionally — a rich text editor, a data grid, a PDF viewer. Lazy-load them so their code is never downloaded until the user actually needs them:
import { lazy, Suspense, useState } from 'react'
// These libraries are large — don't pay the cost until they're needed
const DataGrid = lazy(() => import(/* webpackChunkName: "data-grid" */ './DataGrid'))
const PDFViewer = lazy(() => import(/* webpackChunkName: "pdf-viewer" */ './PDFViewer'))
const VideoPlayer = lazy(() => import(/* webpackChunkName: "video" */ './VideoPlayer'))
function ContentPage({ type, src }) {
return (
<Suspense fallback={<div>Loading viewer…</div>}>
{type === 'grid' && <DataGrid src={src} />}
{type === 'pdf' && <PDFViewer src={src} />}
{type === 'video' && <VideoPlayer src={src} />}
</Suspense>
)
}Strategy 3: Vendor / Library Splitting
Your own code changes on every deployment — but third-party libraries like React and Lodash rarely change. Modern bundlers separate these into a "vendor" chunk that the browser caches across deployments. Configure this in Vite's vite.config.ts:
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
// Rarely-changing libraries — long-lived cache
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-ui': ['@mui/material', '@emotion/react', '@emotion/styled'],
'vendor-charts': ['recharts', 'd3'],
},
},
},
},
})With this config, after you ship a bug fix your users re-download only your application code. The vendor chunk — which may be hundreds of KB — is served from the browser cache.
Webpack Magic Comments
webpack supports several magic comments inside import() to control chunk behaviour:
// Named chunk — appears as "heavy-editor.js" in the network tab const Editor = lazy(() => import(/* webpackChunkName: "heavy-editor" */ './Editor')) // Prefetch — browser downloads this chunk during idle time (low priority) // Use for routes the user is likely to visit next const Settings = lazy(() => import(/* webpackPrefetch: true */ './Settings')) // Preload — browser downloads this chunk at high priority in parallel // Use for resources needed very soon const Modal = lazy(() => import(/* webpackPreload: true */ './Modal')) // Combine name + prefetch const Analytics = lazy(() => import( /* webpackChunkName: "analytics", webpackPrefetch: true */ './Analytics' ))
Vite Automatic Splitting
Vite (powered by Rollup) splits chunks automatically — every dynamic import() becomes its own file. You don't need to configure anything:
// In Vite, this is all you need — no plugin config required
const AdminPanel = lazy(() => import('./AdminPanel'))
// Vite creates: dist/assets/AdminPanel-[hash].js automatically
// Vite also splits node_modules automatically.
// You can inspect the output with:
// npx vite build --report (opens a bundle visualizer)Measuring Impact
Code splitting is only valuable if you measure its effect. Three tools that show you exactly what's in your bundle:
Chrome DevTools → Network tab — filter by "JS", navigate between routes, watch chunk files load on demand. The number after "Transferred" shows what was actually downloaded.
Lighthouse — run in incognito. "Reduce unused JavaScript" and "Remove unused CSS" in the Opportunities section show exactly which modules are dead weight.
webpack-bundle-analyzer —
npm install --save-dev webpack-bundle-analyzer, thennpx webpack --profile --json > stats.json && npx webpack-bundle-analyzer stats.json. Shows a zoomable treemap of every module.Vite bundle visualizer —
npm install --save-dev rollup-plugin-visualizer, addvisualizer()to Vite plugins. Opens an interactive treemap aftervite build.source-map-explorer —
npx source-map-explorer dist/static/js/*.js. Works with any bundler that produces source maps.
A Practical Workflow
Run
npm run buildand note the total JS size.Add route-based splitting for every top-level route — this is the single best ROI.
Re-build and compare. Most apps see a 40–70% reduction in initial bundle.
Open bundle-analyzer, find the largest modules in the initial chunk.
Lazy-load any that are conditional (modals, panels, editors).
Check
webpackPrefetchfor routes users commonly navigate to next.Re-run Lighthouse in mobile simulation and verify TTI improvement.