Migrating splitChunks Config from Webpack to Vite

Scenario: a team is porting a React SPA from Webpack 5 to Vite 5+ for faster local development. The existing webpack.config.js has a splitChunks block with six cacheGroups, tuned over eighteen months of production incident post-mortems, that keeps the initial JS payload at 132 KB gzipped. After a first-pass Vite migration that copies the test regexes into a manualChunks function, the production build regresses to a single 610 KB vendor chunk and Largest Contentful Paint jumps from 1.8 s to 3.4 s in the next synthetic monitoring run.

dist/assets/  (after naive migration)
  index-9f3a21.js         41 KB
  vendor-b71c40.js       610 KB   ← every cacheGroup collapsed into Rollup's default promotion

This happens because a cacheGroups block is not a portable data structure — it is a set of rules evaluated by an optimizer with its own precedence semantics. Copying the regexes without also porting the semantics behind priority, minSize, minChunks, and reuseExistingChunk silently drops most of the grouping logic. This guide translates each Webpack splitChunks concept into its closest Vite/Rollup manualChunks equivalent, worked through a single realistic config end to end.

Root cause: cacheGroups rules do not map one-to-one onto manualChunks branches

optimization.splitChunks is declarative. Webpack’s optimizer collects every cacheGroup, evaluates each one’s test against every module in the graph, and where multiple groups match the same module, resolves the conflict using priority (higher wins) and reuseExistingChunk (prefer an already-created chunk over creating a duplicate). Size constraints — minSize, maxSize, maxAsyncRequests — are applied globally across the resolved groups afterward, potentially splitting an oversized group or merging an undersized one back into a sibling.

manualChunks in Rollup, which is what Vite’s build.rollupOptions.output.manualChunks configures, is imperative. It is a single function called once per module with that module’s resolved ID, and whatever string it returns is the chunk name — full stop. There is no post-hoc size rebalancing, no priority field, and no reuseExistingChunk flag; “reuse” happens naturally only because the same if branch runs for every module with a matching ID, which is the same outcome by different means.

This distinction is why the migration in the scenario above failed: the naive port defined six if conditions but, because none of the test regexes for narrower groups (React, a charting library, a forms library) were checked before a catch-all id.includes('node_modules'), every module matched the catch-all first and the narrower branches never ran. The parent Webpack 5 vs Vite 5 splitChunks decision guide covers when it’s worth doing this migration at all versus keeping Webpack; this page assumes the decision is already made and focuses purely on the translation. For the inverse problem — designing cacheGroups correctly in the first place, before any migration — see the sibling guide on choosing splitChunks cache groups for shared modules.

A second, quieter failure mode shows up when a dependency ships both a CommonJS main entry and an ESM module entry without a proper exports map. Webpack and Vite can resolve the same package to different physical files in that situation, which means a test regex matching a specific node_modules subpath in Webpack may match a different file — or fail to match at all — once the same package is resolved through Vite’s esbuild pre-bundling step. The full mechanics of this divergence are covered in understanding ES Modules vs CommonJS in bundlers; if a translated manualChunks branch silently stops matching a package after migration, check dual-package resolution before assuming the if ordering is at fault.

Concept-by-concept translation table

Webpack splitChunks concept Vite / Rollup manualChunks equivalent Notes
cacheGroups.<name>.test An if (id.includes(...)) check inside the function Match on /node_modules/<pkg>/ with surrounding slashes to avoid partial-name collisions
cacheGroups.<name>.priority The order of if/return statements Highest-priority group becomes the first if block
cacheGroups.<name>.name The string literal returned from the matching branch No auto-naming; you must choose every name explicitly
reuseExistingChunk: true Implicit — the same branch runs for every matching module No configuration needed; this is the default behavior of a function-based API
minChunks: 2 Rollup’s default module-promotion heuristic Applies automatically to any module that falls through to return undefined
minSize / maxSize No equivalent — must be enforced with a separate post-build size check See the worked example below
chunks: 'all' No equivalentmanualChunks already applies to both sync and async import graphs by default Nothing to configure
runtimeChunk: 'single' No equivalent name, but the effect is achievable with a dedicated manualChunks group plus stable chunkFileNames Covered in the worked example

Worked translation

The Webpack config below is the (simplified) source of truth for the migration in the opening scenario:

// webpack.config.js — Webpack 5 (source config being migrated away from)
module.exports = {
  optimization: {
    runtimeChunk: 'single',
    splitChunks: {
      chunks: 'all',
      minSize: 15000,
      maxSize: 260000,
      cacheGroups: {
        reactCore: {
          test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
          name: 'react-core',
          priority: 30,
          reuseExistingChunk: true
        },
        charts: {
          test: /[\\/]node_modules[\\/](recharts|d3-.*)[\\/]/,
          name: 'charts-vendor',
          priority: 20,
          reuseExistingChunk: true
        },
        forms: {
          test: /[\\/]node_modules[\\/](react-hook-form|zod)[\\/]/,
          name: 'forms-vendor',
          priority: 20,
          reuseExistingChunk: true
        },
        commons: {
          minChunks: 2,
          name: 'commons',
          priority: 5,
          reuseExistingChunk: true
        }
      }
    }
  }
};

Translated into Vite, preserving priority order as if order and re-adding minSize/maxSize as a post-build check rather than a runtime rule:

// vite.config.ts — Vite 5+ (translated config)
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          // priority 30 → first branch checked
          if (id.includes('/node_modules/react/') || id.includes('/node_modules/react-dom/')) {
            return 'react-core';
          }
          // priority 20 (tie broken by declaration order, same as Webpack would)
          if (id.includes('/node_modules/recharts/') || /\/node_modules\/d3-[^/]+\//.test(id)) {
            return 'charts-vendor';
          }
          if (id.includes('/node_modules/react-hook-form/') || id.includes('/node_modules/zod/')) {
            return 'forms-vendor';
          }
          // no explicit "commons" branch — Rollup's own module-promotion heuristic
          // already extracts anything imported from 2+ chunks, matching minChunks: 2
          if (id.includes('node_modules')) return 'vendor';
        },
        chunkFileNames: 'assets/[name]-[hash].js'
      }
    }
  },
  // runtimeChunk: 'single' has no manualChunks equivalent by name, but Vite/Rollup
  // already emits one shared entry-runtime module when chunkFileNames is deterministic —
  // verify this in the checklist below rather than assuming parity.
});
// scripts/check-chunk-sizes.mjs — replaces minSize / maxSize, which have no manualChunks equivalent
import { readdirSync, statSync } from 'node:fs';

const MIN_KB = 15, MAX_KB = 260;
const dir = 'dist/assets';

for (const file of readdirSync(dir).filter(f => f.endsWith('.js'))) {
  const kb = Math.round(statSync(`${dir}/${file}`).size / 1024);
  if (kb < MIN_KB) console.warn(`WARN  ${file} is ${kb} KB — below the ${MIN_KB} KB floor, consider merging`);
  if (kb > MAX_KB) console.error(`FAIL  ${file} is ${kb} KB — exceeds the ${MAX_KB} KB ceiling, split it further`);
}

Run node scripts/check-chunk-sizes.mjs immediately after vite build in CI. Because manualChunks has no built-in equivalent to minSize/maxSize, this script is the only thing standing between a passing build and a chunk that silently regresses past the target range described in vendor chunk isolation and third-party management.

Step-by-step verification

  1. Extract the old chunk manifest. Run the last known-good Webpack build with webpack --json=stats.json and note each cache group’s resulting chunk name and gzipped size from the stats output or webpack-bundle-analyzer. If the stats output shows chunk membership you don’t recognize, revisit the Webpack chunk generation lifecycle to confirm which optimizer phase produced it before you start translating.
  2. Build the translated Vite config with vite build and list the output: ls -lh dist/assets/*.js.
  3. Match chunk names one-for-one. Every named group from the Webpack manifest (react-core, charts-vendor, forms-vendor) should have a same-named counterpart in the Vite output. A missing name means its if branch never matched — check for typos in the /node_modules/<pkg>/ path segment.
  4. Run the size-check script from above and resolve any FAIL lines before proceeding.
  5. Diff total initial payload. Sum the gzipped size of every chunk loaded on first paint (exclude lazy route chunks) for both builds; the Vite number should be within roughly 5% of the Webpack baseline — a larger gap indicates a missed grouping.
  6. Confirm cache stability on a no-dependency-change deploy. Make an app-code-only commit, rebuild, and confirm none of the vendor chunk hashes changed. This validates that the shared-runtime handling implicitly covers what runtimeChunk: 'single' did explicitly in Webpack — see deterministic chunk hashing for long-term caching for the full mechanics of why hash stability depends on this.
  7. Load both builds in Chrome DevTools’ Network panel side by side and confirm the initial request count for each stays within the 3-7 range appropriate for HTTP/2 multiplexing.

Edge cases and gotchas

Gotcha 1 — test regexes that reference chunk names, not just module paths

Some mature Webpack configs write cacheGroups rules keyed on chunks(chunk) => chunk.name === 'admin' rather than a plain test regex, scoping the group to a specific entry point. manualChunks receives only the module ID, never the consuming chunk’s name, so this pattern has no direct translation. The workaround is to inspect id for a directory convention instead — for example, scoping a rule to id.includes('/src/admin/') if the admin feature’s code lives in a dedicated directory — which requires a small refactor of the source tree if no such convention exists yet.

Gotcha 2 — optimization.splitChunks.name functions that hash the module list

A small number of configs use a function for name that generates a hash-based name from the sorted list of matched modules, to avoid manually naming dozens of ad-hoc groups. There’s no equivalent auto-naming in manualChunks; every returned string is used verbatim as the chunk’s stem. Migrating this pattern means either accepting a larger set of hand-named groups or writing a helper that computes a similar deterministic name from the module ID before returning it — but note that changing the naming algorithm changes every existing chunk’s filename, busting the CDN cache for one deploy.

Gotcha 3 — CI scripts that grep for specific Webpack chunk filenames

Deploy scripts, CSP allowlists, or smoke tests that reference a literal filename fragment like vendors~main (Webpack’s own auto-generated naming convention) will silently stop matching anything once the build switches to Vite’s chunkFileNames: 'assets/[name]-[hash].js' pattern with hand-chosen names. Audit grep -r "vendors~" . (or whatever fragment your Webpack build used) across CI configs, nginx rules, and CSP headers before flipping the switch, and update each reference to the new Vite chunk names.

FAQ

Does every Webpack cacheGroup need its own manualChunks rule?

No. Cache groups that exist only to catch overflow from minSize/maxSize splitting, rather than to express a semantic grouping, usually collapse into Vite’s default catch-all vendor rule. Keep a dedicated manualChunks branch only for groups your team actually reasons about — a React vendor chunk, a charting-library chunk — and let everything else fall through to a single generic vendor group.

What happens to minChunks: 2 when there is no manualChunks equivalent?

Rollup’s own module-promotion heuristic already behaves like an implicit minChunks: 2 for any module not claimed by an explicit manualChunks rule — a module imported from more than one chunk is automatically promoted to a shared chunk. If your Webpack config used minChunks: 2 as the sole condition for a cache group with no test regex, you can usually drop that group entirely and let Rollup’s default promotion handle it, then verify with a treemap that it landed where expected.

How do I migrate a splitChunks config that used the automatic name: true option?

name: true delegated chunk naming to Webpack’s internal heuristic based on the cache group’s matched modules. There is no equivalent auto-naming in manualChunks — you must return an explicit string for every group. Use the same names your Webpack build produced (inspect the old stats.json output) so downstream tooling like CDN cache rules or CSP allowlists that reference specific chunk name patterns keep working.