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: 30is the highest value in the config, so this group’stestis evaluated beforedateVendororvendorcan claim the same module — necessary becauseformatCurrency.jslives outsidenode_modulesand 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: trueonsharedUtilsguarantees the group produces a chunk even if its total size falls under the globalminSize: 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.dateVendorgets its own group instead of falling into the genericvendorcatch-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: 10is deliberately the lowest value so it only claimsnode_modulesmodules that no higher-priority group already matched — this is what prevents the “one giant vendor chunk” failure mode from the opening scenario.minChunks: 2onsharedUtils(versusminChunks: 1on 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
- Rebuild with stats output. Run
webpack --json=stats.jsonand reload it intowebpack-bundle-analyzer:npx webpack-bundle-analyzer stats.json. - Confirm
shared-utilsappears exactly once. In the treemap,formatCurrency.jsandformatDate.jsshould now appear inside a singleshared-utils-[hash].jsbox, not duplicated inside nine separate route chunks. - Confirm
date-vendoris isolated from the generic vendor chunk. Thedate-vendor-[hash].jschunk should contain onlydate-fns— nothing else with anode_modulespath should appear inside it. - Check the generic
vendorchunk’s remaining size. It should have shrunk by roughly the size of whateverdate-vendorabsorbed, and should no longer be the single largest asset in the build. - 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. - 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 cacheor a304), not re-downloaded. - Confirm cache stability across an unrelated deploy. Ship an app-code-only change that doesn’t touch
date-fnsor the shared utilities, and verifydate-vendorandshared-utilshashes 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.
Related
- Webpack 5 vs Vite 5 splitChunks — When to Choose Which — parent decision guide comparing this cacheGroups approach against Vite’s manualChunks
- Migrating splitChunks Config from Webpack to Vite — sibling guide for porting this exact cacheGroups design to Vite’s manualChunks
- Vendor Chunk Isolation & Third-Party Management — the broader strategy this cache-group design feeds into
- Webpack Chunk Generation Lifecycle Explained — how the optimizer’s cache-group resolution fits into the full compilation lifecycle
- Deterministic Chunk Hashing for Long-Term Caching — why isolating date-vendor and shared-utils preserves cache-hit rate across deploys