Route-Based Code Splitting
A single-page application ships one JavaScript graph, and by default the bundler flattens every route’s code into it — the checkout flow, the admin dashboard, the rarely-visited settings screen all parse before the landing page can become interactive. Route-based code splitting draws the fault lines of that graph along route boundaries so the browser downloads and compiles only the code a given URL actually needs. This page shows how to split the bundle with dynamic import(), wire lazy route components into a matcher, render honest loading and error states, and prefetch the next chunk before the user asks for it — without reintroducing the request waterfalls that splitting is supposed to eliminate.
← Back to Routing Architecture & Fundamentals
The Problem
The initial bundle is the tax every visitor pays before your application does anything. When all routes are statically imported, that tax scales with the size of the whole application, not with the size of the page in front of the user. A marketing visitor who never leaves the home route still downloads, parses, and compiles the code for every authenticated screen behind it. The symptom is a Time to Interactive that climbs steadily as the codebase grows, long after the visible page has painted.
Static imports create this coupling implicitly. Any module reachable through an unbroken chain of import statements from the entry point lands in a bundle that must be evaluated before the app boots. A single top-level import { AdminPanel } from './admin' at the router’s registration site is enough to pull the entire admin subtree — its charts, its date pickers, its validation library — into first load. The dependency is invisible in the route table but very visible in the flame chart.
The fix is to make the router’s reference to a route’s code asynchronous. A dynamic import('./admin') returns a promise and instructs the bundler to emit that module and its private dependencies as a separate chunk, fetched on demand. The route table stops holding component values and starts holding functions that produce them. This is the same lazy-resolution instinct that governs route matching algorithms — resolve the minimum needed to answer the current navigation, and defer the rest.
But naive splitting trades one problem for another. Split too finely and every navigation becomes a round trip to the network for a tiny chunk; split too coarsely and you are back to shipping code nobody uses. Fetch the chunk only after the route resolves and you serialise navigation behind a download the browser could have started earlier — a waterfall. The rest of this page is about splitting deliberately: correct boundaries, honest interim UI, and prefetching that hides the latency.
Core API & Primitives
Three primitives underpin every route-splitting scheme, independent of framework.
The first is the dynamic import expression. Unlike the static import statement, import(specifier) is a function-like operator that returns a Promise<Module namespace>. When a bundler sees it, it splits the referenced module graph into a separate chunk and generates the fetch-and-evaluate logic for you.
// TypeScript 5.x — framework-agnostic, no runtime dependencies
// A route loader is a function that resolves to a module exposing a component.
type RouteModule<C> = { default: C };
type LazyLoader<C> = () => Promise<RouteModule<C>>;
// The bundler emits './routes/checkout' and its private deps as one chunk.
const loadCheckout: LazyLoader<unknown> = () => import('./routes/checkout');
The second is the lazy route entry: a route table where each record holds a loader instead of a resolved component, plus optional loading and error descriptors. Keeping the loader as a function is what defers the fetch until navigation.
// TypeScript 5.x — framework-agnostic
interface LazyRoute<C> {
path: string;
load: LazyLoader<C>; // called at most once, on first activation
loading?: C; // shown while the chunk is in flight
error?: (err: unknown) => C; // shown if the chunk fails to load
}
The third is a memoised resolution cell that turns a one-shot loader into a cached, re-entrant promise. Navigating to the same route twice, or prefetching then navigating, must not trigger two network requests, so the promise is stored the first time it is created and reused thereafter.
// TypeScript 5.x — framework-agnostic
interface LoadCell<C> {
promise?: Promise<C>; // in-flight or settled resolution
component?: C; // resolved value, for synchronous re-render
}
These three — an async import, a table of loaders, and a cache cell — are enough to build a correct lazy router. Everything else is UI polish and latency hiding.
Step-by-Step Implementation
The prerequisite is a bundler that understands dynamic import() and emits separate chunks: Vite, Rollup, esbuild, webpack 5, or Parcel all do this out of the box with no configuration. The steps below build a small, framework-agnostic lazy route registry, then render it against a resolved route.
Step 1: Model routes as loaders, not components
Register each route with a function that imports its module. Nothing is fetched at registration time — the arrow functions are inert until called. This is the single change that decouples first-load cost from application size.
// TypeScript 5.x — framework-agnostic, no runtime dependencies
import type { LazyRoute, LazyLoader } from './types';
// Each import() call is a distinct split point; give chunks stable names
// via a leading comment where your bundler supports it.
const routes: LazyRoute<() => Node>[] = [
{ path: '/', load: () => import(/* chunkName: "home" */ './routes/home') },
{ path: '/checkout', load: () => import(/* chunkName: "checkout" */ './routes/checkout') },
{ path: '/admin', load: () => import(/* chunkName: "admin" */ './routes/admin') },
];
Step 2: Wrap each loader in a memoised cell
A raw loader re-imports on every call in some bundler dev servers and, worse, lets two overlapping navigations race. Wrap the loader so the promise is created once and shared. The cell also exposes a synchronous component once resolved, so a second visit renders without a microtask hop.
// TypeScript 5.x — framework-agnostic
import type { LazyLoader, LoadCell } from './types';
function createCell<C>(load: LazyLoader<C>): {
resolve: () => Promise<C>;
peek: () => C | undefined;
} {
const cell: LoadCell<C> = {};
return {
peek: () => cell.component,
resolve: () => {
// Reuse an in-flight or settled promise; never import twice.
cell.promise ??= load().then((mod) => {
cell.component = mod.default;
return mod.default;
});
return cell.promise;
},
};
}
Step 3: Build a registry that resolves the active route
Combine the route table, the matcher, and the cells into a registry. On navigation it finds the route, resolves its cell, and hands the component to your render function. Failures reject the promise rather than throwing synchronously, so the caller can render an error branch.
// TypeScript 5.x — framework-agnostic
import type { LazyRoute } from './types';
function createRegistry<C>(routes: LazyRoute<C>[]) {
const cells = new Map(routes.map((r) => [r.path, createCell(r.load)]));
return {
// Returns the route plus a resolver; the caller decides how to render.
activate(pathname: string) {
const route = routes.find((r) => r.path === pathname);
if (!route) return null;
const cell = cells.get(route.path)!;
return { route, cell };
},
// Prefetch warms the cell without rendering — call it on hover/idle.
prefetch(pathname: string) {
const cell = cells.get(pathname);
void cell?.resolve(); // fire-and-forget; errors surface on real nav
},
};
}
Step 4: Render loading, content, and error states
Drive the registry from your navigation source. Show the loading placeholder immediately, swap in the component when the chunk resolves, and route rejections to an error boundary. Guard against a stale resolution winning after a faster subsequent navigation.
// TypeScript 5.x — framework-agnostic
import type { LazyRoute } from './types';
async function renderRoute<C extends () => Node>(
registry: ReturnType<typeof createRegistry<C>>,
pathname: string,
outlet: HTMLElement,
): Promise<void> {
const active = registry.activate(pathname);
if (!active) { outlet.replaceChildren(notFound()); return; }
const token = (outlet.dataset.nav = String(Date.now())); // race guard
const synchronous = active.cell.peek();
if (synchronous) { outlet.replaceChildren(synchronous()); return; }
outlet.replaceChildren(active.route.loading?.() ?? spinner());
try {
const component = await active.cell.resolve();
if (outlet.dataset.nav !== token) return; // a newer nav superseded us
outlet.replaceChildren(component());
} catch (err) {
if (outlet.dataset.nav !== token) return;
outlet.replaceChildren(active.route.error?.(err) ?? errorPanel(err));
}
}
In React the same shape collapses into React.lazy(loader) wrapped in <Suspense fallback> inside an <ErrorBoundary>; the manual version above makes the loading, race, and error concerns explicit so you can see what the framework abstracts. The React-specific ergonomics live on the React Router implementation page.
Verification & Testing
Two things need proving: that the split actually happened, and that the lazy chunk loads on navigation rather than on first paint. Confirm the first from the build output and the second from the Network panel or an end-to-end test.
// @playwright/test v1.44 — assert the route chunk is deferred, then fetched
import { test, expect } from '@playwright/test';
test('admin chunk is not in the initial load but arrives on navigation', async ({ page }) => {
const requested: string[] = [];
page.on('request', (r) => requested.push(r.url()));
await page.goto('/');
// The admin chunk must not be part of first load.
expect(requested.some((u) => /admin\.[\w]+\.js/.test(u))).toBe(false);
await page.click('a[href="/admin"]');
await expect(page.locator('[data-route="admin"]')).toBeVisible();
// Now — and only now — the chunk should have been fetched.
expect(requested.some((u) => /admin\.[\w]+\.js/.test(u))).toBe(true);
});
For coverage, open DevTools → Coverage, record a load of the home route, and confirm the admin and checkout chunks report zero bytes used — proof they never entered the initial critical path. In the Network panel, filter to JS and watch a navigation: you should see exactly one new chunk request per newly visited route, initiated at click time (or earlier, if prefetching is on). If the chunk appears in the first-paint waterfall, a stray static import is still pulling it into the entry graph — search the bundle for a non-dynamic reference to the route module.
Performance Tuning
- Guard TTI with a chunk budget. The point of splitting is a smaller initial graph, so assert it: fail the build if the entry chunk exceeds a byte threshold. A shrinking entry chunk is what drives Time to Interactive down; verify it did not quietly regrow as routes were added.
- Keep the LCP element in the shell. The largest contentful paint element for the landing route should render from the shell or the home chunk, never behind a lazily-loaded boundary — otherwise splitting pushes LCP later. Split the routes below the fold, not the hero.
- Cut main-thread work, not just bytes. Parsing and compiling a chunk is main-thread work that competes with input handling. Smaller per-route chunks mean shorter compile tasks and better INP during navigation; measure long tasks in the Performance panel, not just transfer size.
- Right-size chunk granularity. One chunk per route is the sane default. Merge sibling routes that are almost always visited together to save round trips; split a single heavy route further (a chart library, a rich editor) only if part of it is conditionally used. Every extra chunk is another request and another cache entry.
- Prefetch to erase the navigation stall. A chunk fetched at click time adds its download latency to the navigation. Warm the cell ahead of time so the code is already in memory — the prefetching and preloading routes guide covers the full strategy, and prefetching on link hover is the highest-leverage trigger.
Gotchas & Failure Modes
- The fetch-after-match waterfall. Resolving the route, then rendering, then discovering you need to fetch a chunk serialises three steps that could overlap. Kick off
cell.resolve()the moment the matcher identifies the route — before rendering anything — and prefetch on intent so the download is already underway. - Flash of loading on fast connections. A spinner that appears and vanishes within 100ms reads as a flicker, not feedback. Delay the loading placeholder by a short threshold (say 150ms) so instant chunk loads show no spinner at all, and only slow ones reveal it.
- No error boundary around the import. A dynamic
import()rejects on a network failure, a deployed-away chunk (stale HTML pointing at a hashed filename that no longer exists), or an evaluation error in the module. Without an error branch the navigation hangs on a permanent spinner. Always render a retry affordance on rejection. - Racing navigations resolving out of order. A slow chunk for route A can resolve after the user has already navigated to route B, painting stale content. Tag each navigation and discard a resolution whose token no longer matches the current one, as Step 4 does.
- Double-fetching from an unmemoised loader. Prefetch-then-click, or a re-render mid-flight, will import the same chunk twice if the promise is not cached. The memoised cell is not optional polish — it is what makes prefetching safe.
- Over-splitting into request storms. Splitting every component, not every route, produces dozens of tiny chunks whose combined round-trip latency dwarfs the bytes saved. Split at route boundaries first; go finer only with evidence.
Go Deeper
- Dynamic Import per Route in a SPA — a focused walkthrough of wiring
() => import('./routes/x')into a framework-agnostic router with loading and error fallbacks, plus the promise-caching and fast-navigation-race gotchas.
Related
- Routing Architecture & Fundamentals — the parent overview connecting matching, history, and architecture decisions.
- Prefetching & Preloading Routes — how to warm the lazy chunk before navigation so splitting costs no visible latency.
- Prefetching Routes on Link Hover — the highest-leverage trigger for prefetching a split route’s chunk.
- Route Matching Algorithms — resolving which route, and therefore which chunk, a URL owns.
- React Router Implementation — how a mainstream framework packages lazy routes, suspense, and error boundaries.