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.

Change Frequency Decides the Grouping A single vendor chunk containing packages with weekly, monthly and yearly cadences invalidates weekly. Split into three groups, only the fast-changing group invalidates weekly. One chunk β€” invalidates weekly vendor.[hash].js β€” 340 KB framework β€” changes twice a year ui library β€” changes monthly feature SDKs β€” change weekly the chunk inherits the fastest cadence: 340 KB re-downloaded every week Three groups β€” each keeps its cadence framework.[hash].js β€” 148 KB cached for months vendor-common.[hash].js β€” 96 KB cached for weeks route-scoped libraries β€” 96 KB not loaded unless the route is visited a weekly SDK bump re-downloads 96 KB, not 340 KB β€” and 96 KB never loads at all Group by how often the code changes, not by where it came from

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,
  },
};
Why the Runtime Chunk Must Be Separate The chunk-to-URL map changes whenever any chunk hash changes; keeping it inside the entry rewrites the entry on every release. Runtime inside the entry entry.[hash].js app code + chunk map any chunk change rewrites it Runtime extracted runtime.[hash].js β€” tiny, changes often entry.[hash].js β€” stable across releases A few hundred bytes move; hundreds of kilobytes stop being invalidated

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.

Bytes Re-downloaded per Release Before the split, any dependency bump re-downloads the whole 340 KB vendor chunk. After, a feature SDK bump re-downloads 96 KB and a framework bump 148 KB. Bytes a returning user re-downloads after one dependency bump Before 340 KB After β€” SDK bump 96 KB β€” framework and route chunks stay cached After β€” framework bump 148 KB β€” a few times a year at most 0 180 KB 360 KB Same total code, far fewer bytes over the life of a returning session

Step-by-step verification

  1. Confirm the group membership. Inspect each emitted vendor chunk and confirm the framework packages are alone in their chunk.

  2. Confirm no tiny chunks. Every emitted vendor chunk should clear the size floor. A 6 KB vendor chunk means a rule matched too narrowly.

  3. Simulate a dependency bump. Change one feature SDK’s version, rebuild, and diff the emitted filenames. Only that chunk’s hash should change.

  4. Simulate an application-code change. Edit one component and rebuild. No vendor chunk hash should change at all.

  5. Confirm the initial request count. The entry should pull the runtime, framework, vendor-common, and the route chunk β€” four requests, not eight.

  6. 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.