Implementing Route-Level Code Splitting in SPAs

Without route-level code splitting, every SPA ships every route’s JavaScript in a single entry bundle. A 12-route application that has accumulated three months of features can easily exceed 1.2 MB of parsed JavaScript — adding 1.5–2.5 seconds of main-thread blocking on a mid-range mobile device before a single pixel renders. Splitting that bundle at route boundaries reduces the entry payload to the shell, the router, and shared primitives, deferring route-specific code until the user actually navigates there.

The concrete impact of correct implementation: initial bundle drops from ~850 KB to ~320 KB gzipped (a 62% reduction), FCP improves by 200–400 ms on 4G connections, and LCP on the landing route improves by a similar margin. Subsequent navigations fetch only the target route chunk — typically 15–80 KB — over an already-warm HTTP/2 connection.

This page sits within the broader Route-Based Code Splitting & Dynamic Import Strategies guide. That guide covers the full spectrum from dynamic import syntax to prefetch orchestration; this page focuses on the bundler configuration, framework integration, and verification workflow needed to make route splitting production-ready.


How route chunks form in the module graph

When the bundler encounters a dynamic import() call — rather than a static import statement — it creates a new chunk node in the module dependency graph and severs the synchronous dependency edge. The file referenced by import() and everything it transitively requires (that isn’t already in a shared chunk) gets extracted into a separately loadable asset.

The diagram below shows the chunk graph for a three-route SPA. The entry chunk contains the runtime manifest, the router, and shared utilities. Each route chunk contains only the code exclusive to that route; code shared between two or more routes is hoisted into a shared-utils chunk.

Route chunk dependency graph The entry chunk (runtime + router + shared utils) splits at dynamic import() boundaries into three route chunks: Home, Dashboard, and Settings. A shared-utils chunk is hoisted above the route chunks because two or more routes depend on it. entry.js runtime · router · critical CSS home.[hash].js ~18 KB gzip dashboard.[hash].js ~47 KB gzip settings.[hash].js ~22 KB gzip shared-utils.[hash].js hoisted · used by ≥2 routes import() import() import()

Bundler configuration: Webpack 5 and Vite 5+ side-by-side

Webpack 5 — splitChunks configuration

// webpack.config.js — Webpack 5
module.exports = {
  entry: './src/index.js',
  output: {
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].async.js',
    path: path.resolve(__dirname, 'dist'),
    clean: true,
  },
  optimization: {
    runtimeChunk: 'single',          // isolate the runtime manifest
    splitChunks: {
      chunks: 'async',               // only split dynamically imported chunks
      minSize: 20_000,               // ignore chunks smaller than 20 KB
      maxAsyncRequests: 30,          // cap concurrent async requests per entry
      maxInitialRequests: 20,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          priority: -10,
          reuseExistingChunk: true,
        },
        sharedUtils: {
          test: /[\\/]src[\\/]shared[\\/]/,
          name: 'shared-utils',
          minChunks: 2,              // only hoist if used by ≥2 chunks
          priority: -5,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

The runtimeChunk: 'single' option extracts the Webpack runtime and module manifest into its own tiny file. This is important: without it, changing any module invalidates the contenthash of every chunk that embeds the manifest, destroying cache efficiency.

Vite 5+ — rollupOptions configuration

// vite.config.ts — Vite 5+
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    chunkSizeWarningLimit: 500, // KB; alert when any chunk exceeds this
    rollupOptions: {
      output: {
        // content-hash filenames for long-term caching
        entryFileNames: '[name].[hash].js',
        chunkFileNames: '[name].[hash].async.js',
        manualChunks(id) {
          // Third-party libraries — stable, cache-friendly
          if (id.includes('node_modules')) return 'vendor';
          // Internal shared utilities used across routes
          if (id.includes('/src/shared/')) return 'shared-utils';
          // Route chunks are produced automatically by dynamic import()
        },
      },
    },
  },
});

Vite’s Rollup-based bundler generates route chunks automatically from import() calls without additional configuration. manualChunks only needs to address shared code that Rollup would otherwise duplicate across route chunks — particularly node_modules libraries. To prevent accidental vendor duplication, pair this with vendor chunk isolation strategies for Vite’s manualChunks API.


Framework integration: async component patterns

React — lazy + Suspense with error boundary

// src/router/AppRouter.tsx — React 18 + react-router-dom v6
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import { ErrorBoundary } from '../components/ErrorBoundary';
import { RouteSkeleton } from '../components/RouteSkeleton';

// Each lazy() wraps one dynamic import — one chunk per route
const HomeRoute      = lazy(() => import('../routes/Home'));
const DashboardRoute = lazy(() => import('../routes/Dashboard'));
const SettingsRoute  = lazy(() => import('../routes/Settings'));

export function AppRouter() {
  return (
    <ErrorBoundary fallback={<p>This page failed to load. <button onClick={() => window.location.reload()}>Reload</button></p>}>
      <Suspense fallback={<RouteSkeleton />}>
        <Routes>
          <Route path="/"          element={<HomeRoute />} />
          <Route path="/dashboard" element={<DashboardRoute />} />
          <Route path="/settings"  element={<SettingsRoute />} />
        </Routes>
      </Suspense>
    </ErrorBoundary>
  );
}

The ErrorBoundary must sit outside Suspense so it can catch chunk network failures — if the CDN returns a 404 for a redeployed content-hashed chunk, the import() promise rejects and React.lazy propagates it as a render error. The boundary intercepts it and offers a reload prompt. The implementation detail of timing Suspense fallbacks correctly with React Router transitions is covered in how to implement React.lazy with route transitions.

Vue 3 — defineAsyncComponent

// src/router/index.ts — Vue 3 + vue-router 4
import { defineAsyncComponent } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';

const DashboardRoute = defineAsyncComponent({
  loader: () => import('../views/Dashboard.vue'),
  loadingComponent: () => import('../components/RouteSkeleton.vue'),
  errorComponent:   () => import('../components/RouteError.vue'),
  delay: 200,    // ms before showing loadingComponent (avoids flash)
  timeout: 8000, // ms before showing errorComponent
});

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/',          component: () => import('../views/Home.vue') },
    { path: '/dashboard', component: DashboardRoute },
    { path: '/settings',  component: () => import('../views/Settings.vue') },
  ],
});

Vue Router’s route-level component: () => import(...) syntax is the simplest entry point — it is equivalent to defineAsyncComponent with no options. Use defineAsyncComponent when you need the loading/error component lifecycle or the delay guard against loading-state flicker.


Predictive prefetch: loading the next chunk before the user navigates

Fetching a route chunk on-demand at navigation time adds one network round-trip before the new view appears. Predictive prefetching eliminates that round-trip by requesting the chunk during idle time after the current route has mounted.

// src/hooks/useRoutePrefetch.ts
import { useEffect } from 'react';

/**
 * Fires a prefetch import() during browser idle time, then cancels on unmount.
 * The browser will cache the response so the navigation import() resolves instantly.
 */
export function useRoutePrefetch(loader: () => Promise<unknown>) {
  useEffect(() => {
    let handle: number | ReturnType<typeof setTimeout>;
    let cancelled = false;

    const prefetch = async () => {
      if (cancelled) return;
      try {
        await loader();
      } catch {
        // Prefetch failures are non-fatal — the chunk loads on navigation instead
      }
    };

    if (typeof requestIdleCallback !== 'undefined') {
      handle = requestIdleCallback(prefetch, { timeout: 2000 });
    } else {
      handle = setTimeout(prefetch, 200);
    }

    return () => {
      cancelled = true;
      if (typeof cancelIdleCallback !== 'undefined' && typeof handle === 'number') {
        cancelIdleCallback(handle);
      } else {
        clearTimeout(handle);
      }
    };
  }, [loader]);
}

Usage on a landing page that is likely to navigate to /dashboard:

// Prefetch the dashboard chunk while the user reads the home page
useRoutePrefetch(() => import('../routes/Dashboard'));

For navigation-intent-based prefetching (hover, focus, or IntersectionObserver) see prefetch and preload strategies for critical routes and how <link rel="prefetch"> integrates with dynamic import patterns for on-demand loading.


Quantified impact metrics

  • Initial payload: a 12-route SPA reduces its entry bundle from ~850 KB to ~320 KB gzipped — a 62% reduction — with the remainder distributed across route chunks fetched on demand.
  • FCP / LCP on the landing route: 200–400 ms improvement on 4G connections (Lighthouse mobile, Moto G4 simulation), because the browser parses and executes 530 KB less JavaScript before first paint.
  • Time to Interactive (TTI): 600–900 ms improvement on the entry route; the main thread is no longer blocked parsing unused route modules.
  • Cache efficiency: vendor chunks isolated from route chunks achieve >85% cache hit rates across navigations and deployments; only the changed route chunk is invalidated on deploy.
  • Per-navigation overhead: +2–4 HTTP/2 requests for a deep route; offset by connection reuse and idle-time prefetch so perceived navigation latency is under 100 ms on a warm connection.

Common pitfalls

1. Missing runtimeChunk: 'single' in Webpack 5

Root cause: Without this option, Webpack embeds its module manifest inside each entry chunk. Any module change rotates the contenthash of every chunk that includes the manifest, even if the module content itself didn’t change.

Diagnostic signal: After a deploy, the Network tab shows all JS assets as cache misses even for routes that haven’t changed.

Fix: Add optimization.runtimeChunk: 'single' to extract the manifest into a tiny runtime.[hash].js file — only this file is invalidated on every deploy.

2. Vendor code duplicated across route chunks

Root cause: When manualChunks (Vite) or cacheGroups (Webpack) are absent or misconfigured, Rollup/Webpack includes node_modules code in each route chunk that references it.

Diagnostic signal: rollup-plugin-visualizer or webpack-bundle-analyzer shows the same library (e.g. date-fns) appearing in multiple route chunks.

Fix: Declare a vendors cache group / manualChunks entry that matches /node_modules/ and assigns it to a single named chunk. Pairing this with vendor chunk isolation and third-party management gives full control over which libraries are grouped together.

3. Suspense placed inside ErrorBoundary

Root cause: React.lazy promotes chunk load failures as React render errors. If ErrorBoundary is nested inside Suspense, the boundary never sees the error — it propagates up to the nearest boundary above Suspense.

Diagnostic signal: Uncaught chunk load errors crash the entire React tree rather than showing a graceful fallback.

Fix: Always render <ErrorBoundary> as the outer wrapper with <Suspense> nested inside it.

4. Dynamic string template in import()

Root cause: import(\./routes/${name}`)` prevents static analysis. The bundler cannot determine the chunk graph at compile time and either includes all possible matches or refuses to split.

Diagnostic signal: Vite emits a warning: dynamic import cannot be analyzed by vite; Webpack falls back to a synchronous context module.

Fix: Use discrete import() calls per route — one per file — or use the magic comment /* webpackChunkName: "route-name" */ with Webpack’s explicit context module API.

5. Hydration mismatch in SSR applications

Root cause: The server renders the full component tree synchronously; the client starts with the shell and defers route components. If the client’s lazy boundary suspends before hydration completes, React logs a hydration mismatch.

Diagnostic signal: React console warning: Hydration failed because the initial UI does not match what was rendered on the server.

Fix: Use framework-native SSR-aware lazy loading (next/dynamic with ssr: false to opt specific routes out of server rendering, or Nuxt’s <ClientOnly> wrapper).


Verification workflow

Confirming that route splitting is working correctly requires checking both the build output and the runtime network behaviour.

  1. Inspect build output. Run npm run build and examine dist/. You should see separate .js files for each route (e.g. dashboard.a1b2c3d4.async.js) and a separate vendors.[hash].js.

  2. Open Bundle Analyzer. For Webpack 5, add webpack-bundle-analyzer to the build:

    // webpack.config.js — Webpack 5 (dev/CI only)
    const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
    plugins: [new BundleAnalyzerPlugin({ analyzerMode: 'static', openAnalyzer: false })],

    For Vite 5+:

    // vite.config.ts — Vite 5+
    import { visualizer } from 'rollup-plugin-visualizer';
    plugins: [visualizer({ open: false, filename: 'dist/stats.html' })],

    Open dist/stats.html and verify that no node_modules library appears in more than one route chunk, and that route chunks contain only route-specific code.

  3. DevTools Network tab. Navigate to each route and confirm that a new .js file is fetched on first visit but served from the (disk cache) or (memory cache) on return visits. Content-hashed filenames must match between requests.

  4. CI bundle-size gate. Add a budget check to your pull request pipeline:

    # .github/workflows/bundle-check.yml — Webpack 5 / Vite 5+
    name: Bundle Size Gate
    on: [pull_request]
    jobs:
      size-check:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - run: npm ci && npm run build
          - name: Compressed size check
            uses: preactjs/compressed-size-action@v2
            with:
              repo-token: "${{ secrets.GITHUB_TOKEN }}"
              pattern: "./dist/**/*.js"
              threshold: "5%"
  5. Lighthouse CI. Run npx lhci autorun against the staging URL to confirm that FCP and TTI meet targets. Route-level splitting should push mobile FCP below 1.8 s and TTI below 3.8 s on a mid-tier 4G simulation for the entry route.


FAQ

Does route-level code splitting work with server-side rendering?

Yes, but you must synchronise the server’s module loading with the client’s chunk resolution to avoid hydration mismatches. Frameworks like Next.js and Nuxt handle this through their own lazy and dynamic wrappers; in custom SSR setups you need to collect the chunks required for the initial render and inject <link rel="preload"> tags into the HTML shell so the browser fetches them in parallel with the HTML parse.

How many route chunks is too many?

With HTTP/2 multiplexing, 10–20 async chunks rarely cause measurable overhead. Problems emerge when micro-chunking creates 50+ files each under 5 KB — the per-request overhead outweighs the caching benefit. Set Webpack’s minSize to at least 20 000 bytes and minChunks to 2 to prevent over-fragmentation.

What causes a ChunkLoadError during navigation?

ChunkLoadError occurs when the browser tries to fetch a chunk that no longer exists on the CDN — typically after a redeployment that content-hashes filenames. The corrective strategy is to catch the error in an ErrorBoundary, show a reload prompt, and optionally retry the import() once before surfacing the error. Never cache chunk URLs in a service worker without a network-first or stale-while-revalidate strategy.