Stabilizing Chunk Hashes to Maximize Cache Hits

Three consecutive releases, zero dependency bumps, three different vendor chunk hashes:

release-212   vendor.a93f7c.js   398 KB
release-213   vendor.5e1d20.js   398 KB   ← same size, different hash
release-214   vendor.c40b88.js   398 KB   ← same size, different hash again

git diff release-212..release-214 -- package-lock.json returns nothing — dayjs, zod, and framer-motion are exactly the versions they were three releases ago. Yet every deploy forces 100% of returning visitors to re-download the full 398 KB vendor bundle, because its filename changes every time, and the CDN’s cache-hit dashboard for that asset path sits stuck under 20%.

The team’s first instinct is usually to suspect the CDN configuration or a misbehaving cache-control header, since that’s where the symptom is observed. Both check out fine: Cache-Control: public, max-age=31536000, immutable is present on every response, and the origin is serving the correct files. The problem isn’t that the cache is being bypassed — it’s that the bundler is legitimately emitting a different file every time, for a chunk whose actual dependency payload never moved.

Root cause: stable content, unstable IDs or boundaries

This is exactly the diagnostic gap described in deterministic chunk hashing for long-term caching: the vendor chunk’s dependency set hasn’t moved, but something feeding into how that chunk gets compiled has. There are two independent mechanisms that produce this exact symptom, and it’s worth ruling out both rather than guessing.

Module ID drift (Webpack). By default, Webpack assigns each module a numeric ID in the order it resolves the dependency graph. Add a new page component, remove an old one, or even reorder an import statement in an unrelated file, and every module resolved after that point can be renumbered. If any renumbered module lives inside the vendor cache group, the group’s compiled bytes — and therefore its content hash — change, even though the actual dayjs/zod/framer-motion source is untouched.

Chunk boundary drift (Webpack and Vite/Rollup). Separately from ID assignment, both bundlers can silently reassign which chunk a shared module belongs to. In Webpack, crossing a minChunks threshold when a second lazy route starts importing a previously single-use module promotes it into a shared cache group it wasn’t in before. In Rollup, an equivalent automatic module-promotion heuristic can move a dependency into a different generated chunk once the number of importing chunks changes. Either way, the vendor chunk’s membership changed, which busts its hash just as thoroughly as an ID shift would.

Diagnosing which one you have

Before applying a fix, confirm which mechanism is in play. This script diffs the module ID assigned to every node_modules module across two builds’ stats.json output:

// diff-module-ids.js — run against two stats.json exports from consecutive builds
const fs = require('fs');
const [prevPath, currPath] = process.argv.slice(2);
const prev = JSON.parse(fs.readFileSync(prevPath, 'utf8'));
const curr = JSON.parse(fs.readFileSync(currPath, 'utf8'));

const idsByName = (stats) =>
  Object.fromEntries(
    stats.modules
      .filter((m) => m.name.includes('node_modules'))
      .map((m) => [m.name, m.id])
  );

const prevIds = idsByName(prev);
const currIds = idsByName(curr);

let shifted = 0;
for (const name of Object.keys(prevIds)) {
  if (currIds[name] !== undefined && currIds[name] !== prevIds[name]) {
    shifted += 1;
    console.log(`${name}: id ${prevIds[name]} -> ${currIds[name]}`);
  }
}
console.log(`${shifted} node_modules module(s) changed ID between builds`);
npx webpack --mode=production --json=stats-212.json
# ...make an unrelated app-only change...
npx webpack --mode=production --json=stats-214.json
node diff-module-ids.js stats-212.json stats-214.json

If the script reports dozens of shifted IDs for packages you never touched, you have module ID drift. If it reports zero shifted IDs but the vendor chunk’s file list still differs between builds (check stats.chunks[].files), you have boundary drift instead. Many real-world cases have both, since boundary drift is often what triggers the resolution-order change that causes ID drift downstream.

The fix: deterministic IDs plus a pinned boundary

For module ID drift, the fix is two settings in webpack.config.js:

// webpack.config.js — Webpack 5 (minimal fix for ID drift)
module.exports = {
  optimization: {
    // IDs are hashed from each module's own resolved path, not assigned
    // by traversal order — an unrelated new module can no longer
    // renumber modules that were already resolved.
    moduleIds: 'deterministic',
    // Extracts the chunk-to-URL manifest so adding or removing an async
    // route elsewhere in the app can't touch the vendor chunk's bytes.
    runtimeChunk: 'single',
  },
};

For boundary drift, the fix is to stop letting the bundler decide membership automatically and instead pin it explicitly. In Webpack, that means a test regex scoped tightly enough that a threshold crossing elsewhere can’t add unexpected members:

// webpack.config.js — Webpack 5 (pin cache group membership)
module.exports = {
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/](dayjs|zod|framer-motion)[\\/]/,
          name: 'vendor',
          chunks: 'all',
          enforce: true, // ignore minSize/minChunks thresholds for this group
        },
      },
    },
  },
};

On Vite/Rollup, the equivalent is a static manualChunks mapping instead of a function that could be affected by which chunks currently import a module:

// vite.config.js — Vite 5+ (pin vendor boundary explicitly)
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor-core': ['dayjs', 'zod'],
          'vendor-motion': ['framer-motion'],
        },
      },
    },
  },
});

Both enforce: true and a static manualChunks object share the same intent: remove the bundler’s discretion to reassign a module once the app grows, so the only thing that can change a vendor chunk’s hash is a real change to one of the packages named in it.

Step-by-step verification

  1. Confirm zero ID drift. Run the diff-module-ids.js script from above across two builds with no dependency changes. Expect 0 node_modules module(s) changed ID between builds.

  2. Confirm the vendor filename survives an app-only change. Edit a component that never imports dayjs, zod, or framer-motion, rebuild, and check that the vendor chunk’s filename is byte-identical to the previous build — not just similarly sized.

  3. Add a new lazy route and re-check. This is the real stress test for boundary drift: add a second route that imports one of the pinned vendor packages, rebuild, and confirm the vendor chunk’s filename is still unchanged, since enforce: true (or the static manualChunks map) should prevent any threshold-based reassignment.

  4. Inspect the chunk composition with a treemap. Run npx source-map-explorer dist/vendor.*.js and confirm the treemap shows only the three pinned packages — no unexpected inclusions, which would indicate the test regex is too broad.

  5. Watch the CDN cache-hit ratio across real deploys. After shipping the fix, monitor the hit ratio for the vendor asset path across the next 3–5 releases. It should climb from whatever baseline it was stuck at toward 90% or higher, with misses limited to releases that actually bump one of the three pinned packages.

Edge cases and gotchas

minChunks threshold crossing looks like random churn but isn’t. The first time a second route starts importing a module that used to be single-use, Webpack promotes it into a shared cache group — a real, one-time boundary move, not a bug. If enforce: true wasn’t in place yet when that promotion happened, the resulting hash change is expected and shouldn’t be chased as a regression; it’s exactly the kind of event pinning the boundary going forward is meant to prevent from recurring.

CI runner reordering. Restoring a dependency cache on a different CI runner, or a monorepo workspace hoisting packages in a different order between runs, can change module resolution order for the exact same commit. This is a common false lead when a hash “changes for no reason” between two CI runs — optimization.moduleIds: 'deterministic' neutralizes it, since the ID no longer depends on resolution order at all.

Barrel-file import reordering. Running an import-sort lint autofix across a large barrel file can, in rare configurations, alter the order in which the resolver first encounters transitive dependencies. Under moduleIds: 'deterministic' this is inert, but it’s worth recognizing as the shape of change that used to matter before the fix was in place, so a stray lint autofix doesn’t get mistaken for the cause when hashes are already stable.

Regex-based cache groups drifting as the dependency tree grows. A test pattern written against today’s three vendor packages can silently start matching a fourth once a new dependency is added under a similar path — for example, a future dayjs-plugin-* package matching a loosely-scoped dayjs regex. This isn’t a bug in the fix itself, but it’s worth re-auditing the test pattern or the manualChunks list whenever a new dependency is added near the pinned group, since an accidental inclusion changes the group’s composition (and hash) just as surely as an unpinned boundary would.

FAQ

Why does my vendor chunk hash change even though package-lock.json is untouched?

Because the vendor chunk’s compiled bytes depend on more than just which packages are installed — they also depend on the internal numeric module IDs Webpack assigns during resolution. Adding, removing, or reordering any module anywhere in the graph, including in application code, can renumber modules resolved after that point. If a renumbered module lives inside the vendor cache group, its compiled reference table changes, and the chunk’s content hash changes with it, even though every dependency version is identical to the previous release.

Is moduleIds: ‘deterministic’ enough on its own, or do I also need runtimeChunk: ‘single’?

You need both for full stability. moduleIds: ‘deterministic’ stops module IDs from depending on resolution order, but Webpack’s bootstrap runtime — the chunk-to-URL manifest — still changes shape whenever any chunk is added or removed anywhere in the app. Without runtimeChunk: ‘single’ to extract that manifest into its own file, it stays inlined in an entry chunk and can still touch that entry’s hash on releases that add or remove an async route, independent of module ID stability.

Can this happen on Vite even without any Webpack-style module IDs?

Yes, but through a different mechanism. Rollup doesn’t assign the sequential numeric IDs that cause Webpack’s version of this bug; instead, its automatic chunking heuristic can promote or demote a shared dependency into a different chunk once the number of chunks that import it crosses an internal threshold — often triggered by adding a new lazy route elsewhere in the app. The fix is the same in spirit: replace automatic assignment with an explicit, pinned manualChunks mapping so chunk membership can’t shift on its own.

How do I know the fix actually worked in production, not just locally?

Ship a deploy that changes only application code, then check two things: the vendor chunk’s filename in the deployed build manifest should be byte-identical to the previous release, and your CDN’s cache-hit-ratio dashboard for the vendor asset path should climb toward 90% or higher over the next several releases. A single unchanged filename after one deploy is a good sign; a sustained high hit ratio across multiple releases is the real confirmation.