Tutorial · performance · Published 2026-08-16 · 3 min read
Code splitting by route
Route-level code splitting uses dynamic import() to emit per-route chunks, cutting the initial bundle and avoiding parse cost for inactive routes.
How route splitting works
Without intervention, a single-page application ships one JavaScript bundle, and by default the bundler flattens every route's code into it: the checkout flow, the admin dashboard, and the rarely-visited settings screen all get downloaded and parsed before the landing page can become interactive. That means the browser commits network bytes and main-thread parse time to code the current user may never run.
Route-level code splitting draws the fault line of the module graph along route boundaries. The way to do this in modern bundlers (webpack 5, Vite, Rollup, esbuild, Parcel) is the dynamic import() call. When the bundler sees import('./admin') instead of a static import, it cuts the synchronous dependency edge and emits the referenced module and its private dependencies as a separate chunk, fetched only when needed. The route table stops holding eager component values and starts holding lazy loaders.
The benefit is two-fold. First, the initial bundle shrinks, so bytes, first paint, and interactivity all improve. Second, and increasingly important for Interaction to Next Paint, code that is never activated is never parsed at all, which removes whole classes of long tasks from the main thread. Both levers feed the performance budget conversation and the broader JavaScript reduction work.
Wiring it into a router
The pattern is consistent across frameworks. In React it is React.lazy plus dynamic import, which also adds a preload hint and a natural place for a fallback UI:
const Checkout = lazy(
() => import(/* webpackChunkName: "page-checkout" */ './pages/CheckoutPage')
);
function App() {
return (
<Suspense fallback={<PageSpinner />}>
<Routes>
<Route path="/checkout" element={<Checkout />} />
</Routes>
</Suspense>
);
}
Vue Router uses component: () => import('./views/Home.vue') and Angular uses the loadChildren pattern, but the underlying mechanism is the same: a route definition that resolves to an import() promise. Give each chunk a stable descriptive name via webpackChunkName, because predictable names make caching reliable and make the bundle report readable.
Caching chunks well
Splitting only pays off if the chunking does not wreck your cache. Two details matter. First, isolate the bundler runtime and module manifest with optimization.runtimeChunk: "single"; without it, changing any module invalidates the contenthash of every chunk that embeds the manifest, destroying cache efficiency. Second, avoid over-splitting. A page with twenty tiny chunks pays more HTTP round-trips and coordination overhead than it saves; a common rule of thumb is a handful of meaningful chunks per route rather than one per component.
Also be careful that boundaries stay aligned with shared dependencies. If a UI library ends up duplicated across route chunks instead of hoisted into one shared chunk, you add bytes and fragment cache efficiency (each copy is cached and invalidated independently), so review the output bundle graph (for example with webpack-bundle-analyzer) rather than trusting the route table alone. Keep route chunks under the byte ceiling your performance budgets set, and verify in DevTools that inactive routes defer their parse work until navigation, which is the observable proof that the split is working.