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.

JavaScriptadvanced
16 min read

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

javascriptjavascript
// 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 demand

Lazy Route Loader

javascriptjavascript
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

javascriptjavascript
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

javascriptjavascript
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

javascriptjavascript
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;
  }
}
StrategyBest ForLoads WhenBandwidth Cost
On hoverNavigation linksMouse enters linkLow (targeted)
On visibleBelow-fold linksLink scrolls into viewMedium
PredictedAnalytics-drivenCurrent page loadsMedium
All on idleSmall appsBrowser goes idleHigh
Network-awareMobile usersFast connection onlyAdaptive
Rune AI

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
RunePowered by Rune AI

Frequently Asked Questions

How much does route-level splitting reduce initial bundle size?

For typical SPAs with 5-10 routes, route splitting reduces the initial bundle by 60-80%. The exact savings depend on how much code is unique to each route. Routes with heavy dependencies like chart libraries or rich text editors see the largest savings (often 200-500KB per route).

Should I split every route into its own chunk?

Not necessarily. Group very small routes that are always visited together (like a multi-step form) into a single chunk. Split routes that have large unique dependencies or are visited by only a subset of users. A route under 10KB is not worth splitting due to the HTTP request overhead.

How do I handle authentication with lazy routes?

Use router middleware that checks auth state before loading the route chunk. If the user is not authenticated, redirect to the login page without loading the protected route's code. This prevents downloading admin code for unauthorized users, saving both bandwidth and protecting source code.

What happens if a route chunk fails to load?

Implement an error boundary with a retry button and fallback UI. Common failures include network errors and CDN timeouts. Use exponential backoff for automatic retries (1s, 2s, 4s). If the chunk URL has changed due to a new deployment, show a "Please refresh" message since the old chunk no longer exists.

How do I measure the impact of route splitting?

Track Time to Interactive (TTI) and Largest Contentful Paint (LCP) before and after splitting. Use Lighthouse CI to compare scores. Monitor real user metrics (RUM) for route transition times. The RouteChunkAnalyzer pattern in this guide tracks chunk sizes and load times for each route.

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.