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.
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.x —
manualChunksaccepts 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 introducedimport()yet, start with Dynamic import() code splitting patterns for React, which coversReact.lazyandSuspenseboundaries.
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.
- Entry points. Each
inputand eachindex.htmlscript tag seeds an entry chunk. Everything statically reachable from an entry, and not pulled into another chunk, lands here. - 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. manualChunksoverrides. 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 (orundefinedto 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
- Establish a baseline. Run
npx vite buildand record the entry chunk size from the build summary. This is the number every later step is measured against. - Generate a treemap. Add
rollup-plugin-visualizer(config below) and rebuild. Opendist/stats.htmland note any vendor module that appears inside more than one chunk — that is duplication you will remove. - 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 underdist/assets/. - Add a
vendor-reactboundary. Apply the function-formmanualChunksabove. Rebuild and confirm a singlevendor-react-[hash].jsexists and the route chunks shrank. - Diff the chunk list. Run
npx vite buildagain and compare the emitted asset list against the baseline (commands in Verification). Confirm React no longer appears in route chunks. - Measure the load. Serve the build with
npx vite previewand 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].jsvalid 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 |
Related
- Dynamic import() code splitting patterns for React — component- and route-level
React.lazy/Suspenseboundaries and CJS interop pitfalls. - Fixing vendor chunk duplication with manualChunks — diagnosing and removing duplicated React copies across chunks.
- Route-based code splitting with Vue Router — lazy route components, prefetch hints, and chunk grouping in Vue.
- Tree-Shaking Mechanics and Dead Code Elimination — why split boundaries must align with
sideEffectsto avoid shipping dead code. - Core Concepts of Modern Bundling — the dependency-graph model underpinning every chunk decision here.