RuntimeChunk and Vendor Hash Invalidation
You change one string in src/App.jsx, run a production build, and vendors.a1b2c3d4.js comes out as vendors.9e8f7a6b.js — 260 KB of React and lodash that every returning visitor now has to download again even though node_modules has not moved in weeks. This page explains the two mechanisms that leak an application edit into a third-party bundle, and the exact Webpack 5 settings that stop it.
The short version: Webpack embeds a numeric identifier for every module inside every chunk that contains that module, and it embeds a chunk-id-to-filename map inside whichever chunk carries the runtime. If either numbering scheme shifts, the bytes of unrelated chunks shift with it, and [contenthash] faithfully reports a different file. Fixing this is a prerequisite for the immutable caching model described in the Webpack output hashing reference, and it is the practical payoff of insisting on deterministic build outputs.
Where the Application Edit Leaks In
A Webpack bundle is not a concatenation of source files. It is an object literal keyed by module id, wrapped in a small runtime that resolves those ids at execution time:
// vendors.js, simplified — this is what actually lands on disk
(self.webpackChunkapp = self.webpackChunkapp || []).push([
[216],
{
418: function (module, exports, __webpack_require__) {
// react-dom internals
var scheduler = __webpack_require__(651);
module.exports = { render: function () { /* ... */ } };
},
651: function (module) {
module.exports = { unstable_now: function () { return Date.now(); } };
},
},
]);
Both the outer chunk id (216) and the inner module ids (418, 651) are literal integers written into the file. Under optimization.moduleIds: 'natural' those integers are assigned in module-graph traversal order. Insert one import './NewBanner' at the top of your entry file and the traversal order changes: 418 becomes 419, 651 becomes 652, and the vendor chunk’s bytes change even though not a single line of React changed. The [contenthash] is doing its job correctly — the file genuinely differs.
The second leak is the runtime. The runtime holds __webpack_require__.u, the function that turns a chunk id into a filename including its content hash. With the default optimization.runtimeChunk: false, that map is inlined into every entry chunk. Any async chunk anywhere in the build that rotates its hash rewrites the map, which rewrites the entry chunk, which rotates the entry chunk’s own hash.
Settings That Control Vendor Hash Stability
Webpack 5 already picks good defaults in mode: 'production' for two of these. The ones that bite are the settings a hand-written or inherited config overrides, and runtimeChunk, whose default is still the unhelpful one.
| Setting | Default in production | Effect on vendor hash stability |
|---|---|---|
optimization.runtimeChunk |
false |
Inlines the chunk-id map into each entry chunk. Any async chunk hash change rewrites the entry. Set 'single'. |
optimization.moduleIds |
'deterministic' |
Derives ids from the module’s resolved request, so ids survive insertions elsewhere. Overriding to 'natural' or 'size' renumbers vendor modules on any app edit. |
optimization.chunkIds |
'deterministic' |
Same guarantee for chunk ids, which appear in the runtime map and in every import() call site. |
optimization.realContentHash |
true |
Hashes post-minification bytes. With false, a Terser version bump changes output without changing the hash. |
optimization.mangleExports |
'deterministic' |
Stabilises the short export names Webpack generates. 'size' reassigns them by usage frequency, which app code influences. |
splitChunks.cacheGroups.<name>.name |
derived from the module set | A string literal ('vendors') pins membership to a name; a function that hashes the module set produces a new chunk name whenever membership shifts. |
splitChunks.cacheGroups.<name>.test |
none | A broad node_modules regexp keeps all third-party code in one chunk; a narrow one lets stray packages fall back into the app chunk. |
splitChunks.maxSize |
0 (off) |
Splits oversized chunks into numbered parts. Reduces the blast radius per dependency bump, but multiplies chunk count and id-space pressure. |
output.hashDigestLength |
20 |
Global truncation length when a placeholder has no :N suffix. Does not affect stability, only URL length and collision headroom. |
Only runtimeChunk needs changing on a stock Webpack 5 production config. Everything else in the table is a warning about what not to override — which matters because starter templates, webpack-merge overlays, and framework wrappers frequently do override them.
Module Id Strategies Compared
optimization.moduleIds accepts four documented values. The trade-off is between bundle size, readability in devtools, and cross-build stability.
named deserves a word of nuance. It is stable across builds, because a module’s path does not change when you add a sibling import. It is rejected for production only because it inflates every chunk with human-readable path strings. If you are debugging a hash-churn incident, temporarily switching to named is a legitimate diagnostic: two builds’ vendor chunks become diffable line by line, and you can see precisely which module moved.
chunkIds follows the same four-value vocabulary and the same reasoning. Its 'deterministic' value hashes the chunk’s name or its module set, so an async route added in one corner of the app does not renumber the chunk ids referenced from the vendor chunk’s dynamic imports.
A Complete Config
The following is a full, runnable webpack.config.js that pins every setting from the table. It assumes webpack@5.90, webpack-cli@5, mini-css-extract-plugin@2.8, and webpack-manifest-plugin@5.
// webpack.config.js
const path = require('path');
const webpack = require('webpack');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
module.exports = {
mode: 'production',
devtool: 'hidden-source-map',
entry: {
app: './src/index.js',
},
output: {
path: path.resolve(__dirname, 'dist'),
publicPath: '/',
filename: 'assets/js/[name].[contenthash:8].js',
chunkFilename: 'assets/js/[name].[contenthash:8].chunk.js',
assetModuleFilename: 'assets/media/[name].[contenthash:8][ext]',
clean: true,
},
module: {
rules: [
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, 'css-loader'],
},
],
},
optimization: {
runtimeChunk: 'single',
moduleIds: 'deterministic',
chunkIds: 'deterministic',
mangleExports: 'deterministic',
realContentHash: true,
splitChunks: {
chunks: 'all',
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 20,
reuseExistingChunk: true,
enforce: true,
},
shared: {
minChunks: 2,
name: 'shared',
chunks: 'all',
priority: 10,
reuseExistingChunk: true,
},
},
},
},
plugins: [
new webpack.ids.DeterministicModuleIdsPlugin({ maxLength: 6 }),
new webpack.ids.DeterministicChunkIdsPlugin({ maxLength: 5 }),
new MiniCssExtractPlugin({
filename: 'assets/css/[name].[contenthash:8].css',
chunkFilename: 'assets/css/[name].[contenthash:8].chunk.css',
}),
new WebpackManifestPlugin({
fileName: 'manifest.json',
publicPath: '/',
filter: (file) => file.isChunk,
}),
],
};
Two details in that config are load-bearing beyond the obvious.
name: 'vendors' is a string literal, not a function. Webpack’s default naming for an automatic cache group derives the chunk name from the set of chunks the group is shared between. Add a second entry point, or move a shared component into a lazy route, and the derived name changes — which changes the filename before [contenthash] is even computed. A literal name makes membership changes show up as a hash rotation on one file rather than as a rename.
reuseExistingChunk: true prevents Webpack from creating a duplicate chunk when a module already lives in a suitable parent chunk. Without it, refactors that promote a module between routes produce short-lived duplicate vendor chunks whose ids consume id space and shift neighbours.
Deterministic Ids Are Not Infinite
deterministic computes a numeric id by hashing the module’s identifier and truncating to maxLength digits — three by default for modules, three for chunks. On collision, Webpack expands the length for the whole compilation. That expansion is the failure mode: a monorepo build that crosses the threshold from 999 to 1000 distinct modules can renumber a large fraction of its ids in a single commit, rotating almost every chunk hash at once. The symptom looks like a non-deterministic build, but it is deterministic — it is just discontinuous.
Setting maxLength explicitly, as the config above does, buys headroom before that cliff. Six digits gives a million-slot id space, which is generous for any single compilation while adding at most three bytes per module reference. The same reasoning applies to hash length itself: 8 hex characters is the right default for a normal app, and monorepos with thousands of chunks should move to 12–16 characters, as the collision-avoidance guidance for large frontend projects sets out.
splitChunks.maxSize interacts with this directly. Capping vendor chunks at, say, 244000 bytes turns one 900 KB vendors file into four smaller ones, so a single dependency bump invalidates a quarter of the vendor payload instead of all of it. The cost is four times the chunk ids, four times the manifest entries, and four HTTP requests instead of one. Enable it when your vendor chunk is large and churns often; leave it off when the vendor chunk is stable and you value fewer round trips.
// webpack.config.js — optional, for large and frequently-updated vendor payloads
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
maxSize: 244000,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 20,
reuseExistingChunk: true,
enforce: true,
},
},
},
},
};
Verification: Prove the Vendor Hash Held
The only convincing test is an empirical one — build, make a trivial application-only edit, build again, and diff the manifests. Anything that rotates in the second manifest is inside your change’s blast radius.
#!/bin/bash
# blast-radius.sh — measures which hashed assets a trivial app edit invalidates
set -euo pipefail
MARKER_FILE="src/build-marker.js"
WORK=$(mktemp -d)
# Build A — the baseline
rm -rf dist
npx webpack --config webpack.config.js --mode production
cp dist/manifest.json "$WORK/manifest-a.json"
# A deliberately trivial, application-only change
printf 'export const BUILD_MARKER = "%s";\n' "$(date +%s)" > "$MARKER_FILE"
grep -q build-marker src/index.js || \
printf "\nimport './build-marker.js';\n" >> src/index.js
# Build B — same dependencies, one changed app module
rm -rf dist
npx webpack --config webpack.config.js --mode production
cp dist/manifest.json "$WORK/manifest-b.json"
python3 - "$WORK/manifest-a.json" "$WORK/manifest-b.json" <<'PY'
import json, sys
a = json.load(open(sys.argv[1]))
b = json.load(open(sys.argv[2]))
rotated = held = 0
for key in sorted(set(a) | set(b)):
before, after = a.get(key, "(absent)"), b.get(key, "(absent)")
if before == after:
held += 1
print(f"HELD {key}")
else:
rotated += 1
print(f"ROTATED {key}\n {before}\n -> {after}")
print(f"\n{rotated} rotated, {held} held")
PY
rm -rf "$WORK"
On a correctly configured build the output is exactly three rotations — the app chunk, the runtime chunk, and the app stylesheet if the edit touched CSS — with vendors.js listed as HELD. If vendors.js shows up as ROTATED, work down this list in order: confirm optimization.moduleIds really resolves to 'deterministic' (npx webpack --config webpack.config.js --mode production --json prints the resolved options), confirm no webpack-merge overlay is replacing the optimization block wholesale, and confirm your edit did not add a new node_modules import — which legitimately changes vendor membership.
Fold this script into CI as a nightly job rather than a per-commit gate. Run it against main, record the rotated-versus-held ratio, and alert when the vendor chunk rotates on a commit that did not touch package-lock.json. That single alert catches most of the ways a config regression sneaks back in — a merged starter template, a plugin that injects its own optimization block, a dependency that starts emitting a timestamp.
When to Reconsider
You ship a fresh dependency set on almost every release. If package-lock.json changes weekly, the vendor chunk is going to rotate weekly regardless of id strategy, and the engineering spent on maxSize tuning buys little. The compensating move is a smaller vendor chunk, not a more stable one — split by package family so that a date-fns bump does not invalidate React.
Your app is served entirely to first-time visitors. Landing pages, campaign microsites, and embedded widgets with no repeat traffic gain nothing from vendor stability, because nobody has the old chunk cached. Optimise for total transfer size and cold-start parse time instead; a single merged bundle often wins outright, and the trade-off is the same one weighed in the comparison of content hashing against semantic versioning.
You are already on a Rollup-based toolchain. Rollup and Vite express the same idea through output.manualChunks and a different id scheme, and the tuning knobs do not transfer. The Rollup asset optimization guide covers the equivalent chunking controls there.
A single vendor chunk exceeds what you want on the critical path. Chunk stability and chunk size pull in opposite directions. If your vendor chunk is 900 KB, the right first move is to code-split it behind routes, not to freeze its hash.
Frequently Asked Questions
Does runtimeChunk: 'single' cost an extra HTTP request?
Yes, one request for a file that is typically 2–5 KB. On HTTP/2 and HTTP/3 that request is multiplexed on the existing connection and costs a few milliseconds, against a saving of the entire vendor chunk on every subsequent release. Inline it into the HTML with html-webpack-plugin if the extra request genuinely matters — but then the runtime is no longer independently cacheable, so the HTML must be revalidated, which it already is.
Why is my vendor hash still rotating even with moduleIds: 'deterministic'?
The most frequent cause is that the setting is not actually applied. Framework wrappers and webpack-merge calls that pass a whole optimization object replace it rather than merging into it. Dump the resolved config with npx webpack --config webpack.config.js --mode production --json and read the optimization block that Webpack actually used. The second most frequent cause is a loader writing a timestamp, an absolute path, or a random nonce into module output, which is the same class of defect covered under debugging phantom hash changes in CI.
Should runtime.js be served with Cache-Control: immutable?
Yes. It carries a [contenthash] like any other chunk, so its URL changes when its content changes. It rotates on nearly every deploy, which is fine — it is small, and the immutable header still prevents revalidation requests for the copy a browser already holds.
Can I keep chunkIds: 'named' in production for better error reporting?
You can, and it stays stable across builds, but it writes route names into the runtime map and into every import() call site. That is usually the wrong trade: source maps give you the same readability at zero payload cost. Use hidden-source-map and upload the maps to your error tracker instead.
Related
- Webpack output hashing setup — parent guide covering the full output-naming and manifest configuration
- Deterministic build outputs — the general requirement that identical source produce identical bytes
- Content hashing vs semantic versioning — why file-scoped fingerprints beat manual version strings for cache invalidation
- Rollup asset optimization — the equivalent chunking and hash-stability controls outside the Webpack ecosystem
- Fixing missing asset hashes in Webpack 5 — diagnosis when the hash is absent rather than unstable
- Build Tool & Framework Asset Pipeline Integration — the wider bundler and pipeline section this page belongs to