Implementing Route-Level Code Splitting in JS
Learn to implement route-level code splitting in JavaScript SPAs. Covers dynamic route imports, lazy route components, loading fallbacks, error boundaries for routes, route prefetching strategies, and building a code-split router from scratch.
Route-level code splitting loads JavaScript for each page only when the user navigates to it. This is the highest-impact splitting strategy for single-page applications, often reducing initial bundle size by 60-80%.
For general code splitting patterns, see JS Code Splitting: Advanced Performance Guide.
The Problem with Monolithic SPAs
// WITHOUT route splitting: everything loaded upfront
import { HomePage } from "./pages/Home"; // 45KB
import { Dashboard } from "./pages/Dashboard"; // 180KB
import { Settings } from "./pages/Settings"; // 90KB
import { AdminPanel } from "./pages/Admin"; // 250KB
import { Editor } from "./pages/Editor"; // 320KB
// Total: 885KB loaded before ANYTHING renders
// User visiting home page downloads admin + editor code
// WITH route splitting: load per route
const routes = {
"/": () => import("./pages/Home"), // 45KB on visit
"/dashboard": () => import("./pages/Dashboard"),// 180KB on visit
"/settings": () => import("./pages/Settings"), // 90KB on visit
"/admin": () => import("./pages/Admin"), // 250KB on visit
"/editor": () => import("./pages/Editor"), // 320KB on visit
};
// Initial: 45KB (home only), rest loaded on demandLazy Route Loader
class LazyRoute {
constructor(path, loader, options = {}) {
this.path = path;
this.loader = loader;
this.module = null;
this.loading = false;
this.error = null;
this.fallback = options.fallback || this.defaultFallback;
this.errorComponent = options.errorComponent || this.defaultError;
this.timeout = options.timeout || 10000;
}
defaultFallback() {
const el = document.createElement("div");
el.className = "route-loading";
el.innerHTML = '<div class="spinner"></div><p>Loading...</p>';
return el;
}
defaultError(error) {
const el = document.createElement("div");
el.className = "route-error";
el.innerHTML = `
<h2>Failed to load page</h2>
<p>${error.message}</p>
<button onclick="location.reload()">Retry</button>
`;
return el;
}
async load() {
if (this.module) return this.module;
if (this.loading) return this.pendingPromise;
this.loading = true;
this.error = null;
this.pendingPromise = Promise.race([
this.loader(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Route load timeout")), this.timeout)
),
]);
try {
this.module = await this.pendingPromise;
return this.module;
} catch (error) {
this.error = error;
throw error;
} finally {
this.loading = false;
}
}
async render(container, params = {}) {
container.innerHTML = "";
container.appendChild(this.fallback());
try {
const mod = await this.load();
const Component = mod.default || mod;
container.innerHTML = "";
if (typeof Component === "function") {
const result = Component(params);
if (result instanceof HTMLElement) {
container.appendChild(result);
} else {
container.innerHTML = result;
}
}
} catch (error) {
container.innerHTML = "";
container.appendChild(this.errorComponent(error));
}
}
}Code-Split Router
class SplitRouter {
constructor(container, options = {}) {
this.container = typeof container === "string"
? document.querySelector(container)
: container;
this.routes = new Map();
this.currentRoute = null;
this.prefetched = new Set();
this.middleware = [];
this.onNavigate = options.onNavigate || null;
window.addEventListener("popstate", () => this.handleRoute());
}
route(path, loader, options = {}) {
const paramPattern = path.replace(/:(\w+)/g, "(?<$1>[^/]+)");
const regex = new RegExp(`^${paramPattern}$`);
this.routes.set(path, {
regex,
lazy: new LazyRoute(path, loader, options),
pattern: path,
});
return this;
}
use(middleware) {
this.middleware.push(middleware);
return this;
}
async navigate(path, pushState = true) {
// Run middleware
for (const mw of this.middleware) {
const result = await mw(path, this.currentRoute);
if (result === false) return;
if (typeof result === "string") {
path = result; // Redirect
}
}
if (pushState) {
history.pushState({ path }, "", path);
}
await this.handleRoute(path);
}
async handleRoute(path = location.pathname) {
for (const [, route] of this.routes) {
const match = path.match(route.regex);
if (match) {
this.currentRoute = route.pattern;
if (this.onNavigate) {
this.onNavigate(path, route.pattern);
}
await route.lazy.render(this.container, match.groups || {});
return;
}
}
// 404
this.container.innerHTML = "<h1>Page Not Found</h1>";
}
prefetch(path) {
if (this.prefetched.has(path)) return;
for (const [, route] of this.routes) {
if (route.regex.test(path)) {
this.prefetched.add(path);
route.lazy.load().catch(() => {
this.prefetched.delete(path);
});
break;
}
}
}
start() {
// Intercept link clicks
document.addEventListener("click", (e) => {
const link = e.target.closest("a[data-route]");
if (link) {
e.preventDefault();
this.navigate(link.getAttribute("href"));
}
});
// Prefetch on hover
document.addEventListener("mouseover", (e) => {
const link = e.target.closest("a[data-route]");
if (link) {
this.prefetch(link.getAttribute("href"));
}
});
this.handleRoute();
}
}
// Usage
const router = new SplitRouter("#app");
router
.route("/", () => import("./pages/Home"))
.route("/dashboard", () => import("./pages/Dashboard"), {
timeout: 15000,
})
.route("/users/:id", () => import("./pages/UserProfile"))
.route("/settings", () => import("./pages/Settings"))
.route("/admin", () => import("./pages/Admin"));
// Auth middleware
router.use(async (path) => {
if (path.startsWith("/admin")) {
const { isAdmin } = await import("./auth");
if (!isAdmin()) return "/"; // Redirect
}
return true;
});
router.start();Route Prefetching Strategies
class RoutePrefetchManager {
constructor(router) {
this.router = router;
this.strategies = new Map();
}
// Strategy 1: Prefetch on link visibility
prefetchOnVisible() {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const href = entry.target.getAttribute("href");
if (href) {
this.router.prefetch(href);
observer.unobserve(entry.target);
}
}
});
});
document.querySelectorAll("a[data-route]").forEach((link) => {
observer.observe(link);
});
return this;
}
// Strategy 2: Prefetch likely next routes based on current
prefetchPredicted(predictions) {
// predictions: { "/home": ["/dashboard", "/settings"], ... }
const current = location.pathname;
const predicted = predictions[current] || [];
predicted.forEach((path) => {
requestIdleCallback(() => {
this.router.prefetch(path);
});
});
return this;
}
// Strategy 3: Prefetch all routes after initial load
prefetchAllOnIdle() {
requestIdleCallback(() => {
for (const [pattern] of this.router.routes) {
if (!pattern.includes(":")) {
this.router.prefetch(pattern);
}
}
});
return this;
}
// Strategy 4: Network-aware prefetching
prefetchIfFastNetwork() {
const conn = navigator.connection;
if (conn) {
const dominated = conn.saveData || conn.effectiveType === "2g";
if (dominated) {
console.log("Skipping prefetch: slow/metered connection");
return this;
}
}
return this.prefetchOnVisible();
}
}
const prefetchManager = new RoutePrefetchManager(router);
prefetchManager.prefetchIfFastNetwork();Route Chunk Analysis
class RouteChunkAnalyzer {
constructor() {
this.routeMetrics = new Map();
}
track(routePath, loader) {
return async (...args) => {
const start = performance.now();
const entry = performance.getEntriesByType("resource").length;
const result = await loader(...args);
const loadTime = performance.now() - start;
const newResources = performance.getEntriesByType("resource").slice(entry);
const chunkResources = newResources.filter((r) =>
r.name.includes(".js") || r.name.includes(".css")
);
const totalSize = chunkResources.reduce(
(sum, r) => sum + (r.transferSize || 0), 0
);
this.routeMetrics.set(routePath, {
loadTimeMs: loadTime.toFixed(2),
chunks: chunkResources.length,
totalSizeKB: (totalSize / 1024).toFixed(1),
resources: chunkResources.map((r) => ({
name: r.name.split("/").pop(),
sizeKB: ((r.transferSize || 0) / 1024).toFixed(1),
durationMs: r.duration.toFixed(2),
})),
});
return result;
};
}
getReport() {
const report = {};
for (const [route, metrics] of this.routeMetrics) {
report[route] = metrics;
}
return report;
}
}| Strategy | Best For | Loads When | Bandwidth Cost |
|---|---|---|---|
| On hover | Navigation links | Mouse enters link | Low (targeted) |
| On visible | Below-fold links | Link scrolls into view | Medium |
| Predicted | Analytics-driven | Current page loads | Medium |
| All on idle | Small apps | Browser goes idle | High |
| Network-aware | Mobile users | Fast connection only | Adaptive |
Rune AI
Key Insights
- Route splitting reduces initial load by 60-80%: Each route loads its own chunk on demand instead of including all route code in the main bundle
- Lazy route loaders need timeout and error handling: Wrap dynamic imports with Promise.race for timeouts and try/catch for error boundaries with retry UI
- Prefetch on hover gives a 200-400ms head start: Start loading route chunks when the user hovers over navigation links, making transitions feel instant
- Network-aware prefetching respects slow connections: Check navigator.connection before prefetching to avoid wasting bandwidth on 2G or data-saver connections
- Router middleware controls chunk access: Auth checks in middleware prevent unauthorized users from downloading protected route code
Frequently Asked Questions
How much does route-level splitting reduce initial bundle size?
Should I split every route into its own chunk?
How do I handle authentication with lazy routes?
What happens if a route chunk fails to load?
How do I measure the impact of route splitting?
Conclusion
Route-level code splitting delivers the highest performance impact for SPAs by loading only the code needed for the current page. A lazy route loader with timeout handling and error boundaries provides resilient loading. Smart prefetching based on hover, visibility, or network conditions eliminates perceived navigation delays. For broader splitting strategies, see JS Code Splitting: Advanced Performance Guide. For lazy loading patterns beyond routes, see Lazy Loading in JavaScript: Complete Tutorial.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.