Configuring Vite manualChunks for Vendor Isolation
Symptom: after running vite build on a mid-size React SPA, dist/assets/ contains a single vendor-[hash].js file of 1.4 MB. The browser Network waterfall shows this file blocking the critical rendering path, driving Largest Contentful Paint above 3 s. Deploying a minor feature update β one that does not touch any third-party dependency β still busts the vendor hash and forces all repeat visitors to re-download the full 1.4 MB.
dist/assets/
index-BcDeFgHi.js 22 KB β app code
vendor-AaAbBbCc.js 1 432 KB β every node_modules dep merged together
This is the default Rollup chunking behaviour that Vite inherits. Fixing it requires an explicit manualChunks configuration inside build.rollupOptions.output.
Root Cause: How Rollup Decides What Goes in a Shared Chunk
Vite delegates bundle generation to Rollup. Without a manualChunks override, Rollup applies its module promotion heuristic: any module imported by more than one entry point or async chunk is automatically promoted to a shared chunk. Because most node_modules are imported from both the main entry and one or more lazy routes, they all meet the threshold and collapse into one file.
This behaviour is documented in the vendor chunk isolation and third-party management approach, which sits within the wider route-based code splitting and dynamic import strategies architecture. The fix is to supply a manualChunks function that overrides module-promotion decisions with explicit semantic groupings before Rollup evaluates its automatic heuristics.
The diagram below shows what changes between the two states:
Exact Configuration Fix
Add the manualChunks function to vite.config.ts. The function receives the resolved module ID β an absolute filesystem path β and returns a string chunk name or undefined to let Rollup decide.
// vite.config.ts β Vite 5+
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id: string) {
if (!id.includes('node_modules')) return; // only split third-party code
// 1. Shared runtime helpers β resolve first to prevent duplication
const sharedRuntime = ['tslib', '@babel/runtime', 'core-js'];
if (sharedRuntime.some(dep => id.includes(`/node_modules/${dep}/`))) {
return 'shared-runtime';
}
// 2. React ecosystem β stable across minor app releases
if (
id.includes('/node_modules/react/') ||
id.includes('/node_modules/react-dom/') ||
id.includes('/node_modules/scheduler/')
) {
return 'react-vendor';
}
// 3. Utility libraries β change on package bumps, not feature work
if (
id.includes('/node_modules/lodash') ||
id.includes('/node_modules/date-fns/')
) {
return 'utils-vendor';
}
// 4. Network / data layer
if (
id.includes('/node_modules/axios/') ||
id.includes('/node_modules/graphql/')
) {
return 'network-vendor';
}
// 5. Catch-all for remaining node_modules
return 'vendor';
},
// Deterministic filename pattern β required for long-term cache headers
chunkFileNames: 'assets/[name]-[hash].js',
},
},
},
});Key points in the ordering:
- The
sharedRuntimeguard runs first so thattslib(a transitive dependency of bothdate-fnsandaxios) always resolves to one chunk regardless of which rule would otherwise claim it. - Path matching uses the full
/node_modules/<package>/segment with surrounding slashes to avoid false matches (e.g.react-querymatching a barereacttest). - Returning
undefinedfor non-node_modulespaths preserves Rollupβs default app-code chunking, including lazy-route boundaries produced by dynamic import patterns for on-demand loading.
Step-by-Step Verification
-
Run the build and inspect the output directory.
npx vite build ls -lh dist/assets/*.jsExpected output:
dist/assets/ index-BcDeFgHi.js 22 KB react-vendor-XxYyZz.js 148 KB utils-vendor-AaBbCc.js 210 KB network-vendor-DdEeFf.js 96 KB shared-runtime-GgHhIi.js 38 KB vendor-JjKkLl.js 54 KB β catch-all remainderIf any file exceeds 400 KB, narrow its
manualChunksrule to split the group further. -
Confirm zero duplicate-module warnings.
Rollup prints
[!] (!) Some chunks are exceeding the assets size limitor duplicate-module messages to stderr. If you see them, a shared sub-dependency is being claimed by two rules β add it to thesharedRuntimearray. -
Verify chunk composition with source-map-explorer.
npx source-map-explorer dist/assets/react-vendor-*.jsThe interactive treemap must show only
react,react-dom, andschedulerβ notlodash-esoraxios. Repeat for each named chunk. -
Check browser Network tab on a repeat visit.
- Load the app in Chrome DevTools (Network panel, disable cache OFF for the second visit).
- Deploy a feature change that modifies only app code.
- Reload: only
index-[hash].jsshould show200; all vendor chunks should show304or be served from(disk cache). The vendor hashes must not have changed.
-
Validate
Cache-Controlheaders are set toimmutable.curl -I https://your-domain.com/assets/react-vendor-XxYyZz.js | grep -i cache-control # Expected: Cache-Control: public, max-age=31536000, immutableIf the header is missing, configure your CDN or static host to add
immutableto all/assets/*.jsresponses (Viteβs dev server does not emit this; it must be set at the infrastructure layer). -
Automate the check in CI.
# Enforce vendor chunk count and size limits node -e " const fs = require('fs'); const assets = fs.readdirSync('dist/assets').filter(f => f.endsWith('.js')); const vendor = assets.filter(f => /^(react-vendor|utils-vendor|network-vendor|shared-runtime|vendor)-/.test(f)); vendor.forEach(f => { const kb = Math.round(fs.statSync('dist/assets/' + f).size / 1024); if (kb > 400) throw new Error(f + ' exceeds 400 KB: ' + kb + ' KB'); }); if (vendor.length > 6) throw new Error('Too many vendor chunks: ' + vendor.length); console.log('Vendor chunks OK:', vendor.length, 'chunks'); "
Edge Cases and Gotchas
Gotcha 1 β Monorepo workspace packages incorrectly treated as vendors
In a monorepo, internal packages resolve through node_modules/@my-org/shared-utils, which means a bare id.includes('node_modules') check will assign them to the vendor catch-all. Those packages change with every commit, so giving them an immutable-cache label is wrong.
Fix: add a workspace namespace guard before the vendor rules:
// vite.config.ts β Vite 5+ (monorepo variant)
manualChunks(id: string) {
if (id.includes('/node_modules/@my-org/')) return; // treat as app code
if (!id.includes('node_modules')) return;
// β¦ rest of the rules unchanged β¦
}Gotcha 2 β Over-segmentation duplicates sub-dependencies
Splitting every package family into its own chunk (e.g. a separate chunk for zod, a separate chunk for immer, a separate chunk for clsx) causes Rollup to inline any transitive helpers not yet claimed by a rule. The result is the same 10 KB helper appearing in five chunks β increasing total parse time rather than reducing it.
Diagnosis: run npx vite build --debug and search the output for Generated chunks. If you see the same module path listed under multiple chunk names, your rules are too granular.
Fix: merge low-traffic packages into the catch-all vendor group. A good rule of thumb: only isolate packages that exceed 80 KB individually or that are updated on a cadence independent of all other packages in the group.
Gotcha 3 β SSR / Nuxt / SvelteKit builds ignore client manualChunks
Server-side rendering frameworks run a second Rollup pass for the server bundle. The build.rollupOptions.output.manualChunks key in the Vite config applies only to the client build; the server build externalises most node_modules and does not generate vendor chunks. Defining manualChunks in the wrong configuration block causes a build error in Nuxt 3 (manualChunks is not a function).
Fix for Nuxt 3:
// nuxt.config.ts β Vite 5+ (client-only manualChunks)
export default defineNuxtConfig({
vite: {
build: {
rollupOptions: {
output: {
manualChunks(id: string) {
if (!id.includes('node_modules')) return;
if (id.includes('/node_modules/vue/') || id.includes('/node_modules/@vue/')) {
return 'vue-vendor';
}
return 'vendor';
},
},
},
},
},
});Do not add the same configuration under nitro.rollupConfig β that targets the server bundle and manualChunks has no meaningful effect there.
FAQ
Why does Vite produce one giant vendor chunk by default?
Rollupβs default heuristic merges all node_modules imports that appear in more than one chunk into a single shared chunk to maximise HTTP/2 multiplexing. Without an explicit manualChunks function there is no semantic boundary between a UI framework and a utility library β they all land together.
Can manualChunks break tree-shaking?
Yes. Assigning an entire package family (e.g. every /node_modules/lodash-es/ path) to a named chunk forces Rollup to emit the whole package rather than only the exports that are statically imported. Scope a manualChunks rule to packages that are already fully imported, or pair it with "sideEffects": false in the dependencyβs package.json to keep tree-shaking active.
How do I prevent tslib from being duplicated across vendor chunks?
Add a high-priority shared-runtime group at the top of your manualChunks function that matches tslib, @babel/runtime, and core-js before any other rule runs. This guarantees a single shared-runtime-[hash].js chunk that every other vendor chunk references.
Related
- Vendor Chunk Isolation and Third-Party Management β parent page covering the full isolation strategy including Webpack 5
splitChunksand SSR alignment - Dynamic Import Patterns for On-Demand Loading β how lazy-loaded routes interact with the vendor chunk graph
- Preventing Waterfall Requests with Dynamic Import Maps β sibling deep-dive covering request scheduling after chunks are split
- How to Implement React.lazy with Route Transitions β sibling deep-dive for wiring isolated vendor chunks into lazy-route boundaries