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.

Why a one-line app edit rewrites vendors.js Left card shows natural module ids with the runtime inlined into app.js, so an added import renumbers every module and rotates the app, vendor and CSS hashes together. Right card shows deterministic ids with an extracted runtime chunk, where only app.js and runtime.js rotate. Why a one-line app edit rewrites vendors.js module ids are embedded in every chunk that contains those modules moduleIds: 'natural' app.js app modules + runtime vendors.js renumbered on every edit runtime inlined into app.js app.js, vendors.js and CSS all rotate together moduleIds: 'deterministic' app.js app modules only vendors.js ids hashed from paths runtime.js chunk map lives here only app.js and runtime.js rotate on an app edit
Left: natural ids plus an inlined runtime couple every chunk together. Right: deterministic ids and an extracted runtime confine the change to the chunks that actually changed.

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.

optimization.moduleIds strategies A four-row matrix. Natural emits sequential integers and is not stable. Named emits module paths and is development-only. Size emits size-ranked integers and is not stable. Deterministic emits hashed integers and is stable across builds at a small size cost. optimization.moduleIds strategies strategy id emitted stable? size cost natural sequential integers 0, 1, 2, 3 no smallest named module path as id ./src/app.js dev only largest size ordered by byte size size-ranked ints no smallest deterministic hash of module path 917, 4218 yes small
Only `deterministic` survives an unrelated edit; `named` is stable too but writes full paths into production bytes, so it is reserved for development.

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.

Blast radius of a one-line app edit Before stabilising, all 434 KB of app, vendor and CSS bytes rotate. After extracting the runtime and pinning deterministic ids, only the 176 KB app chunk and stylesheet rotate while the 258 KB vendor chunk is served from cache. Bytes a returning visitor refetches after one app edit measured against a 434 KB production build before moduleIds: natural app.js vendors.js css 434 KB after runtimeChunk: single app.js vendors.js — hash held css 176 KB hash rotated hash held, served from edge cache
Stabilising ids and extracting the runtime cuts the refetch from 434 KB to 176 KB on a build whose only change was one line of application code.

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.