Code Splitting
Break application code into smaller bundles loaded on demand to reduce initial load time.
In this article
Problem
Your homepage takes 8 seconds to become interactive because users download the entire admin dashboard, a rich text editor they’ll never use, three charting libraries, and every route they might someday visit, all before clicking a single button.
I’ve seen production bundles ship 2MB of JavaScript for landing pages that only needed 100KB, leaving mobile users on 3G staring at blank screens for 15 seconds. Even users with fast connections wait for parsing, because the browser has to parse all that JavaScript whether it runs or not.
Most of this code runs features users might never open: checkout flows for people who aren’t buying, and admin panels for people who aren’t admins.
Solution
Dynamic import() statements split your code at natural boundaries, such as routes or large features. Your bundler creates separate chunk files that load on demand rather than shipping everything upfront.
The most common split point is route boundaries: settings page code downloads only when users navigate there. You can also split at the component level for heavy dependencies like charting libraries, or by feature flags so premium features don’t bloat the bundle for free-tier users.
Bundlers do this automatically. When you write import('./Dashboard') instead of import './Dashboard', Webpack and Vite create a separate chunk for that module and its dependencies. That chunk downloads only when that code path executes.
Example
Route-based splitting is most common, but component-level splitting works well for heavy widgets.
Route-Based Splitting
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</BrowserRouter>
);
} import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/dashboard', component: () => import('./views/Dashboard.vue') },
{ path: '/analytics', component: () => import('./views/Analytics.vue') },
{ path: '/settings', component: () => import('./views/Settings.vue') }
]
}); <script>
import { page } from '$app/state';
let DashboardComponent = $state();
async function loadDashboard() {
const module = await import('./Dashboard.svelte');
DashboardComponent = module.default;
}
$effect(() => {
if (page.route.id === '/dashboard') loadDashboard();
});
</script>
{#if DashboardComponent}
<DashboardComponent />
{:else}
<LoadingSpinner />
{/if} Component-Level Splitting
You can split heavy components like charts independently from routes:
import { lazy, Suspense } from 'react';
const Chart = lazy(() => import('./components/Chart'));
function Dashboard({ showChart, data }) {
return (
<div>
<h1>Dashboard</h1>
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<Chart data={data} />
</Suspense>
)}
</div>
);
}
Named Chunks and Prefetching (Webpack)
Webpack magic comments give you control over chunk names and loading behavior:
const Dashboard = () => import(
/* webpackChunkName: "dashboard" */
/* webpackPrefetch: true */
'./pages/Dashboard'
);
const HeavyEditor = () => import(
/* webpackChunkName: "editor" */
/* webpackPreload: true */
'./components/RichTextEditor'
);
Feature Flag Splitting
Dynamic imports with feature flags load code only for enabled features:
// Each import() path is a literal so the bundler can see it and create a chunk.
const features = {
'advanced-analytics': () => import('./features/AdvancedAnalytics'),
'admin-panel': () => import('./features/AdminPanel')
};
async function loadFeature(featureName) {
if (features[featureName]) {
const module = await features[featureName]();
return module.default;
}
return null;
}
Vendor Splitting Configuration
Separating vendor libraries from app code improves cache efficiency:
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendor', chunks: 'all' },
common: { minChunks: 2, name: 'common', chunks: 'all' }
}
}
}
};
// vite.config.js (Vite 8, which bundles with Rolldown)
export default {
build: {
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{ name: 'react-vendor', test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom)[\\/]/ },
{ name: 'charts', test: /[\\/]node_modules[\\/](chart\.js|d3[^\\/]*)[\\/]/ }
]
}
}
}
}
};
Error Handling and Preloading
Failed chunk loads need a fallback, and preloading on hover starts the download before the user clicks:
// Show a fallback component if the chunk fails to load
async function loadDashboard() {
try {
const module = await import('./pages/Dashboard');
return module.default;
} catch (error) {
console.error('Failed to load dashboard:', error);
return ErrorComponent;
}
}
// Start loading the chunk when the user hovers over the link
document.querySelector('#dashboard-link').addEventListener('mouseenter', () => {
import('./pages/Dashboard');
});
Benefits
- The initial bundle gets smaller, because users download only what they need for the current page.
- Pages become usable sooner on every device, because less code means less to download and parse before users can interact.
- Mobile users on slow connections see usable pages faster. The difference between 2MB and 100KB can decide whether users stay or give up.
- Unused features add nothing to the download: admin panels stay out of regular user bundles, and premium features don’t slow down free-tier users.
- Vendor libraries cache independently from app code, so pushing updates doesn’t invalidate users’ cached React or other dependencies.
- Less JavaScript up front helps loading metrics like FCP and LCP, and less script competing for the main thread helps INP. LCP and INP are both Core Web Vitals, which Google uses as a search ranking signal.
Tradeoffs
- Navigation feels slower initially. Users see loading spinners when visiting routes for the first time while chunks download.
- Every split point needs loading UI; without fallbacks, chunk loading causes blank screens and layout shifts.
- Your build configuration grows more complex, and debugging across chunks and tuning split points both take extra effort.
- On HTTP/1.1 connections, many small files hurt performance because each request has its own overhead, though HTTP/2 multiplexing largely removes that problem.
- Choosing a chunk size is tricky. If you make too many tiny chunks, they add request overhead, and if you make too few large ones, you defeat the purpose.
- Dynamic imports return promises, so you must handle failed network requests with appropriate error UI.
- Prefetching too aggressively wastes bandwidth, while not prefetching enough makes navigation feel slow.
- Shared dependencies can accidentally end up duplicated across chunks unless you configure your bundler properly, which increases the total size.
Summary
Code splitting breaks your bundle into on-demand chunks, improving initial load times by downloading only what users need. Route boundaries are the usual place to split, and heavy features can load when someone actually opens them. Preloading likely next routes keeps navigation fast while the initial download stays small.