Choosing splitChunks Cache Groups for Shared Modules

Scenario: a mid-size dashboard application has 14 route-level chunks, each independently pulling in a shared formatCurrency and formatDate utility module, plus a date-manipulation library. A webpack-bundle-analyzer treemap reveals the same 34 KB of utility code physically duplicated across nine of the fourteen chunks — because each chunk imported it directly and no cacheGroup was configured to extract it. Total duplicated weight across the build: roughly 280 KB uncompressed, all of it re-downloaded and re-parsed on every route navigation even though the underlying code never changes between routes.

stats.json (webpack-bundle-analyzer summary, before fix)
  ./src/utils/formatCurrency.js   present in 9 chunks   34.1 KB × 9 = 306.9 KB duplicated
  ./node_modules/date-fns/        present in 6 chunks    112 KB × 6 = 672.0 KB duplicated

The opposite failure mode — one vendors cache group so broad it swallows everything — is equally common: a team adds a single catch-all test: /node_modules/ group with no priority tiers, and every third-party dependency, from a 4 KB polyfill to a 220 KB charting library, lands in one 480 KB chunk that busts its cache on every dependency bump, even unrelated ones. Both problems are solved the same way: a deliberate cacheGroups design that uses priority, minChunks, test, reuseExistingChunk, and enforce together rather than relying on Webpack’s defaults.

Both failure modes surface downstream of the same optimizer stage covered in the Webpack chunk generation lifecycle: the seal phase resolves every cacheGroup against the module graph before any chunk gets a content hash, so a cache-group design mistake at this stage propagates directly into the emit phase’s hash stability. A cache-group design that fixes the duplication problem above but reshuffles which modules land in which chunk on every deploy trades one performance problem for another — see deterministic chunk hashing for long-term caching for why chunk membership stability, not just chunk count, determines whether repeat visitors actually benefit from the fix.

Root cause: no explicit boundary between “shared enough to extract” and “vendor enough to isolate”

Webpack’s default splitChunks configuration (chunks: 'async' prior to explicit configuration, or chunks: 'all' once set) only extracts a module into a shared chunk when it is imported from more than one chunk and meets the default minChunks: 1/minSize thresholds evaluated by the built-in default and defaultVendors cache groups. Application-level utility modules — code you wrote yourself, not a node_modules dependency — are exactly the kind of shared code the default defaultVendors group ignores, because its test regex only matches node_modules paths. Without a custom cache group targeting ./src/utils/, that code stays duplicated wherever it’s imported.

This distinction between “shared application code” and “vendor code” is central to the broader Webpack 5 vs Vite 5 splitChunks decision guide, which covers how the same design problem plays out differently once you’re evaluating Vite’s manualChunks instead. If your team is migrating between the two bundlers rather than tuning an existing Webpack setup, the sibling guide on migrating splitChunks config from Webpack to Vite covers the concept-by-concept translation once your cacheGroups design here is finalized. The broader network-delivery consequences of getting this wrong — and how it interacts with route-based code splitting and dynamic import strategies — show up as slower repeat navigations even when the initial page load looks fine.

Designing the cache groups

The fix requires three named groups layered by priority: a narrow group for the shared application utilities, a narrow group for the date-manipulation vendor dependency, and a broad catch-all for everything else. Each group’s minChunks reflects how many chunks actually need to share it before extraction is worth the extra HTTP request.

// webpack.config.js — Webpack 5
module.exports = {
  optimization: {
    runtimeChunk: 'single',
    splitChunks: {
      chunks: 'all',
      minSize: 12000,        // below this, extraction overhead isn't worth a new request
      maxInitialRequests: 8,  // cap parallel requests on the initial page load
      cacheGroups: {
        // Narrowest, highest priority: our own shared utility code, not node_modules
        sharedUtils: {
          test: /[\\/]src[\\/]utils[\\/](formatCurrency|formatDate)\.js$/,
          name: 'shared-utils',
          minChunks: 2,          // extract once 2+ route chunks import it
          priority: 30,
          reuseExistingChunk: true,
          enforce: true          // always emit this chunk even if it's under minSize
        },
        // Narrow vendor group for a specific dependency worth isolating on its own cadence
        dateVendor: {
          test: /[\\/]node_modules[\\/]date-fns[\\/]/,
          name: 'date-vendor',
          minChunks: 1,
          priority: 20,
          reuseExistingChunk: true
        },
        // Broad catch-all — runs last, only claims what higher-priority groups didn't
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor',
          minChunks: 1,
          priority: 10,
          reuseExistingChunk: true
        }
      }
    }
  }
};

Why each field is set the way it is:

  • sharedUtils.priority: 30 is the highest value in the config, so this group’s test is evaluated before dateVendor or vendor can claim the same module — necessary because formatCurrency.js lives outside node_modules and would otherwise never be claimed by either vendor group, but the explicit priority also protects it if the path pattern ever overlaps with a future rule.
  • enforce: true on sharedUtils guarantees the group produces a chunk even if its total size falls under the global minSize: 12000 — without it, a small shared-utils bundle under 12 KB would get silently merged back into whichever chunk imported it first, defeating the whole point of extracting it.
  • dateVendor gets its own group instead of falling into the generic vendor catch-all because date-fns updates on an independent release cadence; isolating it means a date-fns patch bump busts only a 40 KB chunk instead of the entire vendor bundle.
  • vendor.priority: 10 is deliberately the lowest value so it only claims node_modules modules that no higher-priority group already matched — this is what prevents the “one giant vendor chunk” failure mode from the opening scenario.
  • minChunks: 2 on sharedUtils (versus minChunks: 1 on the vendor groups) reflects that application utility code is only worth extracting once genuinely shared; a vendor dependency imported by even a single chunk still benefits from isolation because it changes on its own release cycle regardless of import count.

Verifying the duplication is actually fixed

  1. Rebuild with stats output. Run webpack --json=stats.json and reload it into webpack-bundle-analyzer: npx webpack-bundle-analyzer stats.json.
  2. Confirm shared-utils appears exactly once. In the treemap, formatCurrency.js and formatDate.js should now appear inside a single shared-utils-[hash].js box, not duplicated inside nine separate route chunks.
  3. Confirm date-vendor is isolated from the generic vendor chunk. The date-vendor-[hash].js chunk should contain only date-fns — nothing else with a node_modules path should appear inside it.
  4. Check the generic vendor chunk’s remaining size. It should have shrunk by roughly the size of whatever date-vendor absorbed, and should no longer be the single largest asset in the build.
  5. Re-run the duplication check. grep -c "formatCurrency" stats.json (or the equivalent module-search feature in the analyzer UI) should report the module in exactly one output chunk, not nine.
  6. Measure repeat-navigation payload. In Chrome DevTools’ Network panel, navigate between two routes that both previously duplicated formatCurrency.js; the second route’s request should show the shared-utils chunk served from cache (disk cache or a 304), not re-downloaded.
  7. Confirm cache stability across an unrelated deploy. Ship an app-code-only change that doesn’t touch date-fns or the shared utilities, and verify date-vendor and shared-utils hashes are unchanged — this is the direct payoff of separating them from the generic vendor group.

Edge cases and gotchas

Gotcha 1 — priority ties resolved by declaration order, not intent

When two cache groups have the same priority value, Webpack resolves the tie using object key declaration order in the cacheGroups block — the group declared first wins for any module both groups match. This is easy to miss because it works correctly by accident until someone reorders the config object during a refactor, at which point a previously-isolated group silently stops receiving its expected modules. Fix: never leave two cache groups at the same priority if their test regexes could ever overlap; assign explicit, distinct priority values even when a tie currently “happens” to resolve correctly.

Gotcha 2 — enforce: true bypassing minSize hides a genuinely tiny, wasteful chunk

Setting enforce: true to guarantee a shared-utils chunk gets created also means Webpack will happily emit a 3 KB chunk if that’s all the matched modules add up to — which is small enough that the extra HTTP/2 request roundtrip may cost more than the duplication it was meant to prevent. Diagnostic signal: a shared-utils-[hash].js file consistently under 8-10 KB across builds. Fix: either fold genuinely tiny shared modules directly into the code that imports them (accepting minor duplication) or merge them into a slightly broader shared group so the enforced chunk is large enough to justify its own request.

Gotcha 3 — maxInitialRequests silently dropping a configured cache group

maxInitialRequests (default 30 in Webpack 5, but often tightened in performance-conscious configs, as in the example above set to 8) caps how many chunks a single entry point can pull in on initial load. If an entry point’s dependency graph already produces 8 initial chunks before the optimizer even reaches a lower-priority cache group, that group’s modules get merged back into whatever chunk claimed them by fallback, even though the cacheGroups block itself is configured correctly. Diagnostic signal: a configured cache group appears in stats.json’s optimizationBailout messages, or simply doesn’t appear as a separate chunk despite a correct test regex. Fix: raise maxInitialRequests incrementally, or reduce the number of higher-priority groups competing for the same initial request budget, and recheck the stats output after each change.

Gotcha 4 — a broad test regex accidentally re-absorbing an already-isolated group

Adding a new, broader cache group after sharedUtils and dateVendor are already working correctly can silently swallow them if its test regex is unscoped and its priority is set higher than expected — for example, a later commonVendor group added with test: /[\\/]src[\\/]/ and priority: 25 would outrank sharedUtils (priority: 30 is fine here, but a copy-pasted value of 35 elsewhere would not be) and reclaim the utility modules into a broader, less useful grouping. Diagnostic signal: a previously-isolated chunk name disappears from a fresh stats.json after an unrelated config change. Fix: keep a single source-of-truth comment block at the top of the cacheGroups object listing every group’s priority in descending order, and treat any new group’s priority as a deliberate insertion into that ordered list rather than an arbitrary number.

FAQ

Why does a shared module still get duplicated even with minChunks: 2 set?

minChunks only counts chunks that reach the optimizer as candidates for that specific cache group — if a higher-priority group’s test regex claims the module first, or if maxInitialRequests/maxAsyncRequests caps the number of chunks a given entry point can pull in, the duplication check never runs for that module in that context. Check priority ordering and request caps before assuming minChunks itself is broken.

What does reuseExistingChunk actually prevent?

Without reuseExistingChunk: true, Webpack can create a brand-new chunk containing the exact same set of modules as an already-existing chunk, simply because a second cache group matched the same modules through a different path. Setting reuseExistingChunk: true tells the optimizer to reuse the existing chunk instead of duplicating its contents into a second file with a different hash.

When should I use enforce: true on a cache group?

Use enforce: true when a cache group must always produce a separate chunk regardless of the global minSize threshold — for example, isolating a small but frequently-changing internal shared-utilities package so its churn never busts a larger vendor chunk’s hash. Without enforce: true, a group whose matched modules fall below minSize gets silently merged back into a sibling chunk, defeating the isolation you configured it for.