Code Splitting
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 pay the parsing cost. Browsers must compile all that JavaScript whether it runs or not.
The irony: most of this code powers features users might never access. Checkout flows for non-buyers, admin panels for non-admins, analytics dashboards for pages they haven’t clicked.
Solution
Use dynamic import() statements to split your code at logical boundaries. 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 bundles for free-tier users.
Bundlers handle the mechanics automatically. When you write import('./Dashboard') instead of import './Dashboard', Webpack, Rollup, or 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>
let DashboardComponent;
async function loadDashboard() {
const module = await import('./Dashboard.svelte');
DashboardComponent = module.default;
}
$: if ($page.route.id === '/dashboard') loadDashboard();
</script>
{#if DashboardComponent}
<svelte:component this={DashboardComponent} />
{:else}
<LoadingSpinner />
{/if} Component-Level Splitting
Heavy components like charts can be split independently from routes:
import { lazy, Suspense } from 'react';
const Chart = lazy(() => import('./components/Chart'));
function Dashboard({ showChart }) {
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:
async function loadFeature(featureName) {
const features = {
'advanced-analytics': './features/AdvancedAnalytics',
'admin-panel': './features/AdminPanel'
};
if (features[featureName]) {
const module = await import(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
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'charts': ['chart.js', 'd3']
}
}
}
}
};
Error Handling and Preloading
Failed chunk loads need graceful fallbacks, and preloading on hover anticipates navigation:
// Handle failed chunk loads gracefully
async function loadDashboard() {
try {
const module = await import('./pages/Dashboard');
return module.default;
} catch (error) {
console.error('Failed to load dashboard:', error);
return ErrorComponent;
}
}
// Preload on hover to anticipate navigation
document.querySelector('#dashboard-link').addEventListener('mouseenter', () => {
import('./pages/Dashboard');
});
Benefits
- Initial bundle size shrinks dramatically. Users download only what they need for the current page.
- Time-to-interactive improves across all devices; less code means less download time, parsing, and compilation before users can interact.
- Mobile users on slow connections see usable pages faster. The difference between 2MB and 100KB can determine whether users stay or abandon.
- Unused features cost nothing; admin panels stay out of regular user bundles, 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.
- Core Web Vitals improve across the board. Better FCP, LCP, and TTI translate to higher search rankings.
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.
- Build configuration grows more complex. Debugging across chunks, source maps, and tuning split points all require extra effort.
- On HTTP/1.1 connections, many small files hurt performance due to request overhead, though HTTP/2 multiplexing solves this for modern setups.
- Granularity is tricky: too many tiny chunks creates request overhead, too few large chunks defeats the purpose.
- Dynamic imports return promises, so you must handle failed network requests with appropriate error UI.
- Preloading strategy requires balance. Prefetching too aggressively wastes bandwidth, while not prefetching enough makes navigation feel slow.
- Shared dependencies can accidentally duplicate across chunks without proper bundler configuration, increasing total size.
Summary
Code splitting breaks your bundle into on-demand chunks, improving initial load times by downloading only what users need. Split at route boundaries, defer heavy features until requested, and preload to anticipate navigation. Ship less upfront while keeping the experience seamless.