Code Splitting Strategies for Large Applications

Code splitting partitions a single module graph into multiple chunks that load on demand, so the initial document downloads only the JavaScript needed to render the first route instead of the entire application. Done well, it cuts Time to Interactive on cold loads; done carelessly, it shatters the graph into hundreds of tiny chunks that serialize over the network and inflate per-request overhead. For the dependency-graph model that every splitting decision builds on, read Core Concepts of Modern Bundling before tuning chunk boundaries — splitting is only as deterministic as the graph resolution underneath it.

This guide covers the conceptual mechanics of chunk generation, runnable Vite and Rollup configuration, a numbered workflow with verification commands, the failure modes you will actually hit in production, and the measurement loop that tells you whether a split helped or hurt.

Route-based chunk graph with a shared vendor chunk An entry chunk fans out to three route chunks, each of which references one shared vendor chunk instead of inlining its own copy. entry-app.js eager / first paint route-dashboard.js import('./Dashboard') route-reports.js import('./Reports') route-settings.js import('./Settings') vendor-react.js shared, hashed once
Figure: route chunks load on demand and share a single hashed vendor chunk rather than each inlining its own copy of React.

Prerequisites

This guide assumes the following versions. Behaviour differs across majors, especially manualChunks semantics and the default assetFileNames layout.

  • Node 18.18+ or 20+ (Vite 5/6 drop Node 16).
  • Vite 5.x or 6.x — production builds delegate to Rollup, so every Rollup output option is reachable under build.rollupOptions.
  • Rollup 4.xmanualChunks accepts both the object and function forms; the function receives (id, { getModuleInfo, getModuleIds }).
  • A React, Vue, or framework-agnostic SPA that already uses dynamic import() for at least one route. If you have not introduced import() yet, start with Dynamic import() code splitting patterns for React, which covers React.lazy and Suspense boundaries.

Install the visualizer used throughout for verification:

# Vite 5.x / Rollup 4.x
npm i -D rollup-plugin-visualizer@5

Core Mechanics of Chunk Generation

A chunk is a node in the output graph: a set of modules emitted to one file. Bundlers derive chunks from three signals, evaluated in this order.

  1. Entry points. Each input and each index.html script tag seeds an entry chunk. Everything statically reachable from an entry, and not pulled into another chunk, lands here.
  2. Dynamic import boundaries. Every distinct import('./X') with a statically resolvable specifier becomes a split point. Rollup creates an async chunk for the target and its private subgraph. Modules imported by two or more async chunks are hoisted into a shared chunk to prevent duplication — this hoisting is automatic and is the mechanism most people fight when they see duplicate vendor code.
  3. manualChunks overrides. This output option lets you force specific modules into named chunks regardless of the automatic algorithm. The object form maps a chunk name to an array of module ids; the function form returns a chunk name (or undefined to defer to the default) for each module id.

The function form is graph-aware. It receives getModuleInfo(id), exposing importers and dynamicImporters, so you can decide based on how many chunks reference a module. This is the lever for both vendor splitting and for fixing the duplication problem covered in Fixing vendor chunk duplication with manualChunks.

A critical constraint: manualChunks only influences placement, never reachability. Tree-shaking still runs first. If a module is unused it is pruned before chunk assignment, so aligning split boundaries with sideEffects declarations matters — see Tree-Shaking Mechanics and Dead Code Elimination. The choice between ESM and CJS also constrains splitting: CJS require() is resolved at runtime, so a CJS-heavy dependency cannot be split as precisely as an ESM one. Understanding ESM vs CommonJS in Modern Bundlers covers why.

Configuration & CLI Reference

Vendor splitting with the function form (Vite)

The function form gives per-module control. This config isolates React into its own long-lived chunk and groups remaining third-party code, while leaving application route chunks to the automatic algorithm.

// vite.config.ts — Vite 5.x / Rollup 4.x
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    // Warn earlier than the 500 KB default so regressions surface in CI logs.
    chunkSizeWarningLimit: 250,
    rollupOptions: {
      output: {
        // Stable names so CDN cache keys survive unrelated app changes.
        entryFileNames: 'assets/[name]-[hash].js',
        chunkFileNames: 'assets/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash][extname]',
        manualChunks(id) {
          if (!id.includes('node_modules')) return;
          // Keep React + the renderer together: they version in lockstep.
          if (id.includes('react') || id.includes('scheduler')) {
            return 'vendor-react';
          }
          if (id.includes('react-router')) return 'vendor-router';
          // Everything else third-party shares one chunk.
          return 'vendor';
        },
      },
    },
  },
});

Object form (explicit, deterministic grouping)

The object form is simpler and fully deterministic. Use it when you know exactly which packages co-version and want zero per-id logic.

// vite.config.ts — Vite 5.x / Rollup 4.x
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor-react': ['react', 'react-dom', 'scheduler'],
          'vendor-router': ['react-router', 'react-router-dom'],
          'vendor-charts': ['d3', 'recharts'],
        },
      },
    },
  },
});

Standalone Rollup build

When Rollup runs without Vite, the same output options apply directly. The graph-aware variant uses getModuleInfo to pull only widely shared utilities into a common chunk.

// rollup.config.js — Rollup 4.x
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';

export default {
  input: 'src/main.tsx',
  output: {
    dir: 'dist',
    format: 'es',
    entryFileNames: '[name]-[hash].js',
    chunkFileNames: '[name]-[hash].js',
    manualChunks(id, { getModuleInfo }) {
      if (id.includes('node_modules')) {
        return id.includes('react') ? 'vendor-react' : 'vendor';
      }
      // Hoist utilities imported by 3+ modules to avoid per-route copies.
      const info = getModuleInfo(id);
      if (id.includes('/src/utils/') && info && info.importers.length > 2) {
        return 'shared-utils';
      }
      return null; // Defer to the default algorithm.
    },
  },
  plugins: [nodeResolve(), commonjs()],
};

esbuild (CLI, coarse splitting only)

esbuild supports ESM splitting but has no function-based chunk control. It is useful for dev iteration speed, not for surgical vendor boundaries.

# esbuild 0.25.x, Node 20+
esbuild src/main.tsx --bundle --splitting --format=esm \
  --chunk-names='chunks/[name]-[hash]' --outdir=dist --minify

Step-by-Step Workflow

  1. Establish a baseline. Run npx vite build and record the entry chunk size from the build summary. This is the number every later step is measured against.
  2. Generate a treemap. Add rollup-plugin-visualizer (config below) and rebuild. Open dist/stats.html and note any vendor module that appears inside more than one chunk — that is duplication you will remove.
  3. Introduce route-level import(). Convert top-level routes to dynamic imports so each route seeds its own async chunk. Verify each route now emits a distinct file under dist/assets/.
  4. Add a vendor-react boundary. Apply the function-form manualChunks above. Rebuild and confirm a single vendor-react-[hash].js exists and the route chunks shrank.
  5. Diff the chunk list. Run npx vite build again and compare the emitted asset list against the baseline (commands in Verification). Confirm React no longer appears in route chunks.
  6. Measure the load. Serve the build with npx vite preview and capture the network waterfall under throttling. Confirm the eager payload dropped and route transitions fetch one vendor chunk plus one route chunk, not duplicated vendors.
// vite.config.ts — visualizer for step 2
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    visualizer({ template: 'treemap', gzipSize: true, filename: 'dist/stats.html' }),
  ],
});

Verification

Capture the emitted asset list before and after a change and diff it:

# Vite 5.x — list emitted chunks, sorted, for a reproducible diff
npx vite build >/dev/null 2>&1
ls -1 dist/assets/*.js | sed 's/-[a-z0-9]\{8\}\.js$/.js/' | sort > /tmp/chunks.after.txt
diff /tmp/chunks.before.txt /tmp/chunks.after.txt

A correct vendor split shows vendor-react.js appearing once and the route chunks losing weight. To confirm React is not duplicated into a route chunk, grep the chunk contents for a React-internal marker:

# A duplicated React copy prints the path twice; a deduped one prints it once.
grep -lR "react-dom.production" dist/assets/*.js | wc -l   # expect 1

Debugging & Failure Modes

Duplicated vendor code across chunks

The visualizer treemap shows the same package (commonly react, react-dom, or a date library) inside two or more chunks. The usual cause is mixed package versions resolving to two node_modules paths, or a manualChunks function that returns different names for the same package. The full diagnosis and fix is in Fixing vendor chunk duplication with manualChunks.

ChunkLoadError after deploy

A user loads index.html cached from a previous deploy, then navigates to a route whose chunk hash no longer exists on the CDN. The dynamic import() rejects with ChunkLoadError. Mitigate by retaining old chunk files for one or two deploy cycles, and add a global unhandledrejection handler that force-reloads on ChunkLoadError.

Over-splitting: too many tiny chunks

Splitting every component produces dozens of sub-10 KB chunks. Under HTTP/2 the request count and per-chunk parse cost outweigh the caching benefit. Collapse component-level splits back into feature chunks via the object form, and reserve component-level import() for genuinely heavy, rarely used widgets (rich text editors, charting canvases).

Module "x" was tree-shaken away in a manual chunk

Naming a module in manualChunks does not keep it alive. If nothing imports it, it is pruned before placement and the named chunk silently disappears. Confirm the module is actually reachable before assigning it.

Performance Impact & Measurement

The metric that matters is the eager critical path: bytes parsed before first paint, not total bundle size. A 900 KB app that ships a 120 KB entry chunk outperforms a 400 KB app shipping all 400 KB eagerly.

Measure three numbers across a change:

  • Eager payload (gzip). Sum of the entry chunk plus any modulepreload-ed vendor chunk. Target under ~150 KB gzip for sub-1.5s TTI on 4G.
  • Route transition cost. Bytes fetched on navigation. After a correct vendor split this is one route chunk; the shared vendor chunk is already cached from first load.
  • Cache hit rate on repeat visit. Stable vendor hashing keeps vendor-react-[hash].js valid across app-only deploys, so returning users re-download only changed route chunks.

Track these in CI with a size budget so a regression fails the build rather than reaching production:

{
  "bundlesize": [
    { "path": "dist/assets/index-*.js", "maxSize": "150 kB" },
    { "path": "dist/assets/vendor-*.js", "maxSize": "180 kB" }
  ]
}

Compatibility Matrix

Capability Vite 5/6 Rollup 4 esbuild 0.25 Webpack 5
Function-form manualChunks Yes (via build.rollupOptions) Yes No splitChunks cacheGroups
Object-form manualChunks Yes Yes No splitChunks cacheGroups
getModuleInfo in chunk fn Yes Yes No N/A
Automatic shared-chunk hoisting Yes Yes Partial (--splitting) Yes
Stable content-hash naming Yes Yes Yes ([hash]) Yes ([contenthash])
Minimum Node 18.18 / 20 18 18 18

In-Depth Guides