Dynamic Import per Route in a SPA
After reading this you will be able to attach a () => import('./routes/x') loader to any route in a framework-agnostic single-page application, render a loading placeholder while the chunk downloads, fall back to an error view when it fails, and do all of it without fetching the same module twice or painting a stale route after a fast navigation.
← Back to Route-Based Code Splitting
Prerequisites
Core Concept
A static import { Page } from './routes/x' binds the module into the entry graph, so its code loads before the app boots regardless of whether the route is ever visited. Replacing it with the expression () => import('./routes/x') changes two things at once: the arrow function defers execution until you call it, and the import() operator tells the bundler to emit that module as a separate chunk fetched over the network on demand. The loader is a value your router stores in its route table instead of a component.
Because the loader returns a Promise, every route activation becomes asynchronous, and asynchrony is where the interesting failures live. The chunk can still be downloading when you need to render, so you need a loading state. The download can fail — a dropped connection, or a redeployment that renamed the hashed file — so you need an error fallback. And the user can navigate away before the promise settles, or trigger the same route twice, so you need to cache the promise and guard against a stale resolution overwriting fresh content. This guide wires all four concerns into one small function. It is the mechanism the broader route-based code splitting approach depends on.
Implementation
The mountRoute function below takes a loader, an outlet, and a navigation token. It caches each loader’s promise in a WeakMap so repeat activations and prefetches share one fetch, renders a loading node immediately, and only commits the resolved (or failed) view if its navigation is still the current one.
// TypeScript 5.x — framework-agnostic, no runtime dependencies
type Loader = () => Promise<{ default: () => Node }>;
// One cache cell per loader identity; survives re-entry and prefetch.
const cache = new WeakMap<Loader, Promise<() => Node>>();
function load(loader: Loader): Promise<() => Node> {
// ??= caches on first call; every later call reuses the same promise,
// so prefetch-then-click never triggers a second network request.
let cell = cache.get(loader);
if (!cell) {
cell = loader().then((mod) => mod.default);
cache.set(loader, cell);
}
return cell;
}
export async function mountRoute(
loader: Loader,
outlet: HTMLElement,
loading: () => Node,
errorView: (err: unknown, retry: () => void) => Node,
): Promise<void> {
// Stamp this navigation; a newer one will change the token and win.
const token = (outlet.dataset.nav = crypto.randomUUID());
const promise = load(loader);
// Only show the placeholder if the chunk is not already cached and warm —
// a short delay avoids a spinner flash on instant loads.
const timer = setTimeout(() => {
if (outlet.dataset.nav === token) outlet.replaceChildren(loading());
}, 150);
try {
const component = await promise;
clearTimeout(timer);
if (outlet.dataset.nav !== token) return; // superseded by a later nav
outlet.replaceChildren(component());
} catch (err) {
clearTimeout(timer);
if (outlet.dataset.nav !== token) return;
// On a failed chunk, drop the poisoned cache entry so retry can refetch.
cache.delete(loader);
outlet.replaceChildren(
errorView(err, () => void mountRoute(loader, outlet, loading, errorView)),
);
}
}
Wiring it to a route table is then a one-liner per route, and prefetching is just calling load(loader) early — on hover or during idle time — so the chunk is warm before the click:
// TypeScript 5.x — framework-agnostic
const table: Record<string, Loader> = {
'/': () => import('./routes/home'),
'/reports': () => import('./routes/reports'),
};
function navigate(path: string, outlet: HTMLElement): void {
const loader = table[path];
if (!loader) { outlet.replaceChildren(document.createTextNode('Not found')); return; }
void mountRoute(loader, outlet, spinner, errorPanel);
}
Verification
Confirm the chunk is deferred and then fetched on navigation. A Playwright run records network requests and asserts the reports chunk is absent from first load but present after the click.
// @playwright/test v1.44
import { test, expect } from '@playwright/test';
test('reports chunk loads on navigation, not first paint', async ({ page }) => {
const urls: string[] = [];
page.on('request', (r) => urls.push(r.url()));
await page.goto('/');
expect(urls.some((u) => /reports\.[\w]+\.js/.test(u))).toBe(false);
await page.click('a[href="/reports"]');
await expect(page.locator('[data-route="reports"]')).toBeVisible();
expect(urls.some((u) => /reports\.[\w]+\.js/.test(u))).toBe(true);
});
For a manual check, open DevTools → Network, filter to JS, and click through routes: each newly visited route should trigger exactly one chunk request, and revisiting it should trigger none because the promise is cached.
Gotchas
- Forgetting to cache the promise. Calling
loader()directly on every navigation refetches the chunk (or at least re-runs module evaluation). Cache by loader identity — theWeakMapabove — so prefetch and navigation share one in-flight promise. - A stale resolution winning the race. On a slow connection the user can navigate
/a→/bbefore/a’s chunk resolves; without the token guard,/a’s late resolution overwrites/b. Stamp each navigation and bail if the token changed. - Caching a rejected promise forever. If you cache the promise but never evict on failure, a single transient network error poisons the route until reload. Delete the cache entry in the
catchbranch so retry can refetch. - Spinner flash on cached routes. Rendering the loading node synchronously makes instant, already-cached loads flicker. Gate the placeholder behind a short timeout so only genuinely slow fetches reveal it.
FAQ
Why cache the import promise instead of just calling import() each time? The bundler-level module cache prevents a second network download, but your own overlapping calls still create redundant promise chains and can race. Caching the promise by loader identity gives every caller — navigation and prefetch alike — the exact same settled or in-flight result, which is also what makes the fast-navigation race guard reliable.
How do I prefetch a route’s chunk before the user clicks? Call the same cached loader early — on link hover, on viewport intersection, or during idle time — so the promise is already resolving when navigation happens. Because the promise is cached, the later navigation reuses it with no extra request. The hover trigger is covered in detail on prefetching routes on link hover.
What happens when a chunk fails to load after a redeploy? A new deployment produces new hashed filenames, so old HTML pointing at a since-deleted chunk gets a 404 and the import rejects. Catch the rejection, show a retry that refetches, and consider prompting a full reload to pull the current HTML and asset manifest when a chunk 404s.
Do I need a framework like React or Vue to do per-route dynamic import? No. Dynamic import() is a native language feature and the split is performed by the bundler, so the plain function above works with any router. Frameworks add ergonomics — React.lazy plus Suspense, Vue async components — but the underlying mechanism is identical.
Should every route be its own dynamic import? One chunk per route is a good default, but merge routes that are almost always visited together to avoid extra round trips, and only split a single route further when part of it is heavy and conditionally used. Over-splitting trades saved bytes for request latency.
Related
- Route-Based Code Splitting — the parent guide covering chunk granularity, suspense states, and avoiding waterfalls.
- Prefetching & Preloading Routes — warming a lazy chunk ahead of navigation so the dynamic import costs no visible delay.
- Prefetching Routes on Link Hover — the intent signal that triggers the cached loader before a click.