Splitting a Vendor Chunk That Grew Past the Budget
The CI budget check finally fails:
β dist/assets/vendor.a71f0c.js
340.2 kB gzipped (limit: 250 kB) β exceeded by 90.2 kB
The vendor chunk has been growing quietly for two years. Worse than the size is its behaviour across releases: because everything is in one file, bumping any single dependency changes the chunkβs content hash, and every returning user re-downloads all 340 KB to receive a 4 KB patch.
The chunk exists because vendor chunk isolation and third-party management recommended separating dependencies from application code, which was correct. What has been outgrown is treating βvendorβ as a single category.
Root cause: one chunk, many change frequencies
Grouping by origin β everything from node_modules β puts packages with completely different update cadences in one cache unit. The framework changes twice a year; a UI library changes monthly; a feature SDK changes weekly. The chunkβs effective change frequency is the fastest memberβs, so the slowest-changing 200 KB inherits the churn of the fastest-changing 10 KB.
The second problem is reachability. A vendor chunk loaded by the entry contains packages that only a few routes use β a PDF renderer, a rich-text editor, a map library β so every session downloads them regardless of where it goes.
The fix: three groups, one floor
// webpack.config.js β Webpack 5
module.exports = {
optimization: {
// Keep the runtime in its own file so a chunk-map change does not
// invalidate a vendor chunk whose code is untouched.
runtimeChunk: 'single',
moduleIds: 'deterministic',
splitChunks: {
chunks: 'all',
// Floor: below this, a separate request costs more than the duplication.
minSize: 30000,
maxInitialRequests: 5,
cacheGroups: {
// 1. The most stable code in the application. Changes a few times a year.
framework: {
test: /[\\/]node_modules[\\/](react|react-dom|scheduler|vue|@vue)[\\/]/,
name: 'framework',
priority: 40,
enforce: true,
},
// 2. Everything else reachable from the entry: moderate churn.
vendorCommon: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor-common',
chunks: 'initial',
priority: 20,
reuseExistingChunk: true,
},
// 3. Dependencies reached only through dynamic imports stay with the
// route that needs them β never merged into the initial payload.
vendorAsync: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor-async',
chunks: 'async',
priority: 10,
reuseExistingChunk: true,
},
},
},
},
};// vite.config.js β Vite 5+: the same three groups, assigned explicitly
const FRAMEWORK = /node_modules\/(react|react-dom|scheduler|vue|@vue)\//;
export default {
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return;
if (FRAMEWORK.test(id)) return 'framework';
// Rollup keeps async-only dependencies with their importing chunk
// by default, so only the initial vendor surface needs naming here.
return 'vendor-common';
},
},
},
chunkSizeWarningLimit: 250,
},
};The single most valuable line is runtimeChunk: 'single'. Without it, the chunk-to-URL map lives inside the entry chunk, so any chunkβs hash change rewrites the entry β which silently undoes much of the caching benefit the split was meant to deliver. The mechanism is covered in stabilizing chunk hashes to maximize cache hits.
Moving route-scoped libraries out entirely
Splitting redistributes bytes; reachability removes them. Any dependency that only some routes need should be reached exclusively through a dynamic import, at which point the async cache group keeps it out of the initial payload automatically.
// Before: a static import puts the PDF renderer in the initial vendor chunk
// import { renderPdf } from '@pdf/renderer';
// After: only the invoice route reaches it, so it lands in an async chunk
export async function exportInvoice(invoice) {
const { renderPdf } = await import('@pdf/renderer');
return renderPdf(invoice);
}This is usually the larger win. A 340 KB vendor chunk commonly contains 80β120 KB that only two or three routes use, and moving it out reduces the initial payload rather than merely reorganising it β the reachability principle behind component-level code splitting beyond routes.
Step-by-step verification
-
Confirm the group membership. Inspect each emitted vendor chunk and confirm the framework packages are alone in their chunk.
-
Confirm no tiny chunks. Every emitted vendor chunk should clear the size floor. A 6 KB vendor chunk means a rule matched too narrowly.
-
Simulate a dependency bump. Change one feature SDKβs version, rebuild, and diff the emitted filenames. Only that chunkβs hash should change.
-
Simulate an application-code change. Edit one component and rebuild. No vendor chunk hash should change at all.
-
Confirm the initial request count. The entry should pull the runtime, framework, vendor-common, and the route chunk β four requests, not eight.
-
Re-run the budget. Each chunk should now clear the limit, and the initial payload total should be lower if route-scoped libraries were moved out β see enforcing performance budgets in CI.
Edge cases and gotchas
A package that spans groups. A UI library importing framework internals can be pulled into the framework group by a broad pattern. Anchor test patterns to the package boundary rather than matching a substring.
Async chunks duplicating vendor code. If a dependency is reached both statically and dynamically, it can appear in both the initial and async vendor chunks. reuseExistingChunk prevents this; without it, the duplicate is easy to miss.
Too many initial requests. Each initial vendor chunk is a request before the page can hydrate. maxInitialRequests bounds this; on a high-latency connection, five chunks of 60 KB can be slower than two of 150 KB.
HTTP/1.1 clients. Without multiplexing, request count matters far more. If a meaningful share of traffic is still on HTTP/1.1, prefer fewer, larger chunks.
FAQ
How many vendor chunks should an application have?
Two or three that load on every route, plus any number that load per route. Beyond that the request overhead and the runtime bookkeeping start to outweigh the cache benefit, and the rules become hard to reason about. The useful split is a very stable core β framework and runtime β separated from everything else, because that core is what you want to survive every release untouched.
Does splitting a vendor chunk reduce total bytes?
Not on a cold first visit β the same code still has to arrive, now in more files. It reduces bytes on every subsequent visit, because a dependency bump invalidates only the chunk containing that dependency instead of the whole vendor bundle. It also reduces first-visit bytes when part of the split moves route-scoped libraries out of the always-loaded set, which is a reachability change rather than a chunking one.
Should vendor chunks be split by package?
No β one chunk per package is the classic over-splitting mistake. It produces dozens of small files, each with request overhead and runtime registration, and it makes the network panel unreadable. Group by the property you actually care about, which is change frequency, so packages that update together share a chunk and packages that never update stay in a chunk that never changes.
Related
- Vendor Chunk Isolation and Third-Party Management β the parent guide on vendor boundaries
- Isolating Analytics and Tag Manager Scripts From App Chunks β removing third-party instrumentation from the same chunk
- Choosing splitChunks Cache Groups for Shared Modules β the cache-group mechanics in depth
- Stabilizing Chunk Hashes to Maximize Cache Hits β why the runtime chunk must be separate