Choosing a splitChunks Strategy in Webpack 5: Vendor Chunk vs. Per-Package Splitting
Webpack 5's splitChunks default duplicates vendor code across entries. Compare async-only, single-vendor, and per-package cacheGroup strategies, with a working config and hash-stability validation.
12 Jul 2025, 00:05 UTC

If your multi-entry Webpack app ships the same copy of React or lodash in every entry bundle, the fix is optimization.splitChunks — but the real decision is how far to take it. Split everything into one vendor chunk and you get great cache hits but a giant first download. Split per package and you get fine-grained caching but more requests and more config to maintain. This guide compares the three supported options, then shows a concrete configuration and how to validate that it actually works.
Everything below targets Webpack 5. Defaults changed between major versions (Webpack 4 capped initial requests at 3, for example), so confirm your version first with npm ls webpack in your project directory.
The decision and its constraints
Code splitting here means separating third-party code in node_modules from your application code so browsers can cache it independently. The constraints pulling against each other:
- Cache longevity: vendor code changes rarely, app code changes every deploy. You want vendor bytes cached across deploys.
- First-load size: a single megabyte-scale vendor chunk delays first paint even when most of it is unchanged from last week.
- Request count: more chunks means more parallel requests.
maxInitialRequestsandmaxAsyncRequests(both default to 30 in Webpack 5) cap this; raising the caps has diminishing returns even on HTTP/2 and can hurt on HTTP/1.1. - Duplication: Webpack 5's default config only splits async chunks (
chunks: 'async'), so code imported synchronously by multiple entries is duplicated until you opt intochunks: 'all'.
Comparing the three supported options
| Strategy | Cache hit rate | First-load cost | Config risk | Best fit |
|---|---|---|---|---|
| Default (async-only splitting) | Low — vendor code duplicated per entry | Moderate, but repeated across entries | Lowest; no custom config | Small apps, single entry, heavy dynamic imports |
Single vendors chunk (chunks: 'all') | High — one bundle survives app deploys | One large download up front; any dependency bump invalidates the whole chunk | Low, but watch total size | Most multi-entry apps with a moderate dependency set |
| Per-package / framework cacheGroups (e.g., a separate react chunk) | Highest granularity — bumping one library only invalidates its chunk | More requests; risk of hitting request caps | Highest — priority and enforce interact non-obviously | Large apps with a few very big, independently updated frameworks |
The trade-off in one sentence: option (b) optimizes for the common case where a deploy changes app code but not dependencies, while option (c) optimizes for the rarer case where you update one large dependency and want returning users to re-download only that library. Option (a) is what you get by doing nothing, and it is usually the wrong default for multi-entry apps.
A concrete implementation: vendors chunk with stable hashes
Splitting only pays off if chunk filenames stay stable across builds. That requires three things together: [contenthash] in output filenames, deterministic module IDs, and a single runtime chunk (so the module-to-chunk manifest doesn't force hash changes into your vendor bundle). Here is a complete webpack.config.js excerpt:
module.exports = {\n mode: 'production',\n entry: {\n main: './src/main.js',\n admin: './src/admin.js',\n },\n output: {\n filename: '[name].[contenthash].js',\n chunkFilename: '[name].[contenthash].js',\n clean: true,\n },\n optimization: {\n moduleIds: 'deterministic',\n runtimeChunk: 'single',\n splitChunks: {\n chunks: 'all',\n cacheGroups: {\n vendors: {\n test: /[\\\\/]node_modules[\\\\/]/,\n name: 'vendors',\n priority: -10,\n },\n },\n },\n },\n};Key points: chunks: 'all' extends splitting to initial (synchronous) chunks, which is where multi-entry duplication lives. The vendors group matches anything under node_modules into one named chunk. priority: -10 keeps it above the built-in default group (priority -20) without fighting other groups you add later. If you add a framework-specific group, give it a higher priority (e.g., 0 or 10) and a narrower test like /[\\\\/]node_modules[\\\\/](react|react-dom|scheduler)[\\\\/]/ — a higher-priority group silently wins, so an overly broad high-priority test can defeat the split you intended.
One caveat: if you use mini-css-extract-plugin, CSS has its own cacheGroups (defaultVendors and default) and behaves differently from JS splitting — don't assume this config covers your styles.
Validating the result
Don't assume the split worked; check it. From your project root, with webpack installed locally:
npx webpack --mode production --json > stats.jsonFeed stats.json to webpack-bundle-analyzer or the stats viewer and confirm two things: each node_modules package appears in exactly one chunk, and the vendors chunk size is reasonable for your app (a multi-hundred-kilobyte vendor chunk may be fine; a multi-megabyte one argues for option (c) or for auditing which dependencies you actually ship).
Then verify cache stability, which is the whole point:
- Run a production build and record the output filenames.
- Edit one application source file (a comment is enough) and rebuild.
- Diff the filenames: the
vendors.*.jsandruntime.*.jshashes should be unchanged; only the app chunk hash should change.
Finally, load the built page in DevTools with network throttling, confirm the chunk count and sizes look sane, and reload to verify the vendor chunk is served from cache. If the vendor hash changes on every build despite no dependency changes, the usual suspects are a missing runtimeChunk: 'single' or non-deterministic module IDs.
Limitations
A single vendors chunk means any dependency update — even a patch to one small library — invalidates the entire vendor bundle for returning users. If you update dependencies frequently, per-package groups amortize better. Conversely, aggressive splitting can exceed maxInitialRequests, at which point Webpack merges groups back together in ways that may surprise you. Measure with the stats output rather than assuming either extreme is right, and re-check after major dependency or Webpack upgrades.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.