Deterministic Chunk Hashing for Long-Term Caching

A team ships a two-line copy fix to a marketing page. Nothing in react, lodash-es, or any other third-party dependency changed. Yet the deploy log shows every vendor chunk re-emitted under a new filename — vendor.8f3a1c.js becomes vendor.2b7e94.js — and every returning visitor re-downloads 300–500 KB of code that is byte-for-byte identical to what they already have cached. Multiply that across a fleet of repeat visitors and the waste compounds fast: a site with 50,000 weekly returning sessions and a 380 KB vendor chunk burns roughly 19 GB of avoidable egress on a release that changed nothing about its dependencies.

This isn’t a caching-header problem. Cache-Control: public, max-age=31536000, immutable is already configured correctly on the CDN. The filenames themselves are unstable — the bundler is computing a different hash for content it has effectively already produced, because something upstream of the hash function shifted between builds. Left unaddressed, this caps the achievable cache hit rate around 40–60% even on projects with disciplined vendor isolation; corrected, the same projects consistently clear a 92%+ cache hit rate across consecutive deploys.

Architectural context: where hashing sits in the pipeline

Chunk hashing is the very last stage of the build pipeline — after the resolver has built the module graph, after chunk partitioning has decided which modules belong to which output file, and after the chunk generation lifecycle has assembled the final asset list. Because it runs last, it inherits every source of non-determinism from every stage before it. If the resolver assigns a different internal ID to a module because an unrelated file was added somewhere else in the tree — a side effect of how the module resolution order traverses the graph — and that ID leaks into an emitted chunk’s bytes, the hash for that chunk changes even though none of its own source lines changed.

This matters most for vendor chunk isolation strategies: splitting react, lodash-es, and other rarely-changing dependencies into a chunk of their own only pays off if that chunk’s filename actually stays put across deploys. Configuring manualChunks for vendor isolation in Vite gets you a stable chunk boundary; deterministic hashing is what keeps that boundary’s filename stable once you have it. The two problems get confused because they present the same symptom — an unexpectedly large re-download — but the fix lives in a different configuration block, and choosing between Webpack’s splitChunks and Vite’s chunking model in the first place is covered in Webpack 5 vs Vite 5 splitChunks — when to choose which.

Naming the hash: contenthash vs chunkhash vs hash

Webpack 5 exposes three hash placeholders, and picking the wrong one silently defeats long-term caching before any module-ID work even matters.

  • hash is a single value computed once per compilation and shared by every emitted asset. It changes on every build regardless of which files actually changed — a CSS tweak busts every JS filename too. Never use it for production output filenames.
  • chunkhash is derived from a chunk’s fully assembled content, including every module bundled into it. It stays stable only if both the chunk’s own modules and the exact set of modules assigned to that chunk stay identical between builds. A boundary shift — one module moving from the vendor chunk to the app chunk — busts it even with zero line-level changes.
  • contenthash is computed from the literal bytes of the final emitted file, independently per asset, after module IDs and chunk assignment are already locked in. It’s the correct default for both JS and CSS filenames — but it can only be as stable as the inputs feeding into it. A perfectly-computed contenthash still produces a new value for an untouched module if that module’s own numeric ID shifted.

Vite’s Rollup-based output hashing works differently: Rollup renders each chunk’s final code and then hashes the rendered bytes, which is already conceptually closer to contenthash than to Webpack’s older chunkhash. That’s good news, but it means Vite users who see hash churn are almost never fighting the hash algorithm — they’re fighting chunk boundary instability instead, which the manualChunks section below addresses directly.

Placeholder Scope Changes when… Long-term caching verdict
hash Entire compilation Anything at all changes, anywhere Never use for production filenames
chunkhash One chunk’s full content Any module in the chunk changes, or chunk membership shifts Stable only if boundaries never move
contenthash One emitted file The file’s own final bytes change Correct default, but inherits upstream instability
Rollup [hash] (Vite) One rendered chunk The chunk’s rendered code changes, or its module set shifts Effectively contenthash — boundary shifts are the real risk

The practical takeaway: switching a Webpack config from [hash] to [contenthash:8] is necessary but not sufficient. It only pays off once the module IDs and chunk boundaries feeding into that hash are themselves deterministic, which is what the rest of this page configures.

Why unrelated hashes bust: module IDs and the runtime chunk

Two upstream mechanisms account for nearly every case of a chunk hash changing without a corresponding content change.

Sequential module IDs. With Webpack’s older natural or numeric ID strategies, each module is assigned an integer in the order the compiler resolves it. That integer is baked into three places: the module’s own wrapper reference table, every other module’s internal require(N) call that points at it, and the runtime manifest mapping chunk IDs to URLs. Insert one new module anywhere in the dependency graph and every module resolved after it can be renumbered — and if any renumbered module lives inside your vendor chunk, that chunk’s emitted bytes (and therefore its hash) change, even though its own source is untouched.

The inlined runtime chunk. By default, Webpack embeds its bootstrap — __webpack_require__, the installed-chunks map, and the chunk-to-URL manifest — into whichever entry chunk first requires it. Every time an async chunk is added, removed, or renamed, that manifest changes, and because the runtime is duplicated into every entry point by default, the change ripples into every entry chunk’s hash simultaneously, even entries that have nothing to do with the chunk that actually changed.

Both mechanisms leave a visible trail in the compiler’s own output, so you don’t have to guess which one is responsible before fixing it. Running webpack --json=stats.json on two consecutive builds and diffing the modules[].id field for every entry under node_modules will show a wall of renumbered IDs if sequential assignment is the culprit — often hundreds of shifted entries from a single new component elsewhere in the tree. If instead the module IDs are stable but the vendor chunk’s files array still differs between builds, the runtime chunk is the more likely cause, and grepping the vendor asset for __webpack_require__.m or an inlined chunk-to-URL object literal will confirm the manifest wasn’t extracted.

The fix for both is the same two-line change: hash module IDs from their own resolved path instead of assigning them by traversal order, and pull the runtime out into a dedicated file so manifest churn can’t touch entry or vendor bytes.

The diagram below traces a single new module through both configurations. On the left, inserting NEW renumbers an existing module and busts the vendor chunk that contains it. On the right, the same insertion leaves every existing module ID untouched.

Sequential vs deterministic module IDs: hash cascade comparison Left panel: inserting a new module under sequential numeric IDs shifts module C's ID from 2 to 3, and because C lives in the vendor chunk, the vendor chunk's hash changes even though A, B, and C's own source did not. Right panel: with deterministic, path-derived IDs, inserting the same new module leaves A, B, and C's IDs unchanged, so the vendor chunk stays byte-identical and only the app chunk containing the new module re-hashes. BEFORE — sequential module IDs inserting NEW renumbers every module after it A → id 0 B → id 1 NEW → id 2 (inserted) C → id 2 → 3 (shifted) vendor.chunk.js (A, B, C) hash: CHANGED app.chunk.js (NEW) hash: changed vendor chunk busts even though A, B, and C never changed AFTER — deterministic module IDs each ID depends only on the module's own path A → id 9f2 B → id a13 NEW → id c88 (inserted) C → id 4e1 (unchanged) vendor.chunk.js (A, B, C) hash: stable app.chunk.js (NEW) hash: changed vendor chunk stays byte-identical — only app re-hashes, as expected

Bundler-specific configuration: Webpack 5 and Vite 5+

Fixing this requires three settings in Webpack 5 and a different discipline entirely in Vite 5+, because the two tools produce non-determinism through different mechanisms.

// webpack.config.js — Webpack 5
module.exports = {
  optimization: {
    // Hash module IDs from their resolved file path instead of assigning
    // them sequentially — an unrelated new module no longer renumbers
    // every module resolved after it.
    moduleIds: 'deterministic',
    // Extract the __webpack_require__ bootstrap and the chunk-to-url
    // manifest into their own file so adding or removing an async chunk
    // doesn't touch every entry chunk's bytes.
    runtimeChunk: 'single',
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor',
          priority: 10,
          reuseExistingChunk: true,
        },
      },
    },
  },
  output: {
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js',
  },
};
// vite.config.js — Vite 5+
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // Rollup already hashes final rendered bytes (contenthash-equivalent);
        // the instability to fix here is chunk BOUNDARIES, not the algorithm.
        // Pin the vendor boundary with a static mapping rather than relying
        // on Rollup's automatic module-promotion heuristic, which can move a
        // shared dependency to a different chunk when an unrelated async
        // import is added elsewhere in the graph.
        manualChunks: {
          'vendor-react': ['react', 'react-dom'],
          'vendor-utils': ['lodash-es', 'date-fns'],
        },
        entryFileNames: 'assets/[name]-[hash].js',
        chunkFileNames: 'assets/[name]-[hash].js',
      },
    },
  },
});

The Webpack side fixes an ID-assignment problem; the Vite side fixes a boundary-assignment problem. Neither config transfers directly to the other tool, which is one reason teams migrating between the two frequently reintroduce hash churn they thought they’d already solved — a scenario covered step by step in migrating splitChunks config from Webpack to Vite.

Framework integration

Stable chunk hashes matter beyond the build log the moment a framework or a service worker hardcodes a chunk name. React’s lazy and Vue’s defineAsyncComponent both trigger a dynamic import() that the bundler turns into a named async chunk — and if that chunk’s [name] segment isn’t pinned, every deploy invalidates any prefetch hint or precache entry that referenced it by name.

// routes.jsx — React 18, paired with the Webpack 5 chunkFilename above
const BillingPage = React.lazy(() =>
  import(/* webpackChunkName: "billing" */ './pages/BillingPage')
);
// Emits billing.[contenthash:8].chunk.js — the "billing" name segment stays
// fixed across deploys; only the hash suffix changes, and only when
// BillingPage's own code changes.
// App.vue script setup — Vue 3, paired with the Vite manualChunks config above
import { defineAsyncComponent } from 'vue';

const BillingPage = defineAsyncComponent(() =>
  import('./pages/BillingPage.vue')
);
// Rollup derives the chunk's [name] segment from the source filename by
// default; combine with build.rollupOptions.output.chunkFileNames to keep
// that segment stable while only the hash segment reflects real changes.

Service workers add a second layer: a precache manifest generated at build time (Workbox, vite-plugin-pwa, or a hand-rolled manifest) bakes in the exact hashed filenames from that build. If a deploy changes hashes for reasons unrelated to real content changes, the precache manifest churns unnecessarily on every release, and stale service workers can end up requesting chunk URLs that no longer exist — the exact failure mode walked through in invalidating service worker cache after deploy.

Quantified impact of stabilizing the hash pipeline

  • Cache hit rate on vendor and shared chunks across consecutive deploys: 92–96% once deterministic module IDs and an extracted runtime are combined, up from 40–60% with default sequential IDs.
  • Repeat-visit bandwidth avoided: roughly 75% of total JS payload for visitors who already hold a cached vendor chunk, since only the changed chunk requires a network round trip.
  • Reload latency on repeat visits: 300–500 ms faster when the vendor chunk is served from disk cache (304 or memory cache) instead of re-downloaded over the network.
  • CI artifact diff noise: shrinks from touching every entry chunk on every commit to touching only the one or two chunks whose modules genuinely changed, making bundle-size regression review far easier to reason about.

Common pitfalls

Entire app re-downloads on every deploy despite unchanged dependencies. Root cause: the default moduleIds: 'natural' (or unset) strategy assigns sequential numeric IDs by resolution order. Diagnostic signal: diff two consecutive stats.json outputs — module IDs differ for files that were never touched. Fix: optimization.moduleIds: 'deterministic'.

Only the vendor chunk seems to bust even though only app code changed. Root cause: the runtime manifest is inlined into an entry chunk instead of extracted. Diagnostic signal: grep the emitted vendor chunk for __webpack_require__.m or a chunk-to-URL object literal — if it’s present, the runtime wasn’t extracted. Fix: optimization.runtimeChunk: 'single'.

Vite/Rollup vendor chunk hash changes despite unchanged dependencies. Root cause: Rollup’s automatic module-promotion heuristic reassigned a shared dependency to a different chunk when an unrelated async boundary was added elsewhere in the graph. Diagnostic signal: compare the chunk file lists between two builds — a module previously emitted inside vendor-XXXX.js now appears in a differently-named chunk. Fix: replace automatic chunking with an explicit manualChunks mapping.

contenthash still changes despite the source being textually identical. Root cause: a non-deterministic minifier pass (parallel Terser workers without a pinned seed) or an absolute build path leaking into the output via import.meta.url or a source map comment. Diagnostic signal: diffing two builds with zero source changes shows only whitespace, ordering, or path differences. Fix: pin minimizer worker count and disable comments that embed the local build path.

Mixing [hash] and [contenthash] placeholders inconsistently. Root cause: legacy config that still uses [hash] (compilation-wide) for chunkFilename while filename correctly uses [contenthash]. Diagnostic signal: every single asset changes name on every build, not just the ones with real changes. Fix: replace every remaining [hash] placeholder in output config with [contenthash:8].

Hash digest truncated too aggressively. Root cause: [contenthash:4] or shorter truncates the digest to a handful of hex characters, which is enough entropy for a small app but starts colliding once a build emits more than a few hundred chunks — two genuinely different files can end up sharing a filename prefix, and some CDNs treat that as a cache poisoning risk. Diagnostic signal: a build warning about a filename collision, or two different chunk contents served under what should be a unique URL. Fix: use [contenthash:8] as a floor for anything beyond a small single-page app, and only shorten further if you’ve measured your actual chunk count against the collision odds.

Verification workflow

  1. Build twice with zero source changes. Run the production build, note every vendor and shared chunk filename, then run it again without touching any file. Every filename in both categories should be byte-identical.

    npx webpack --mode=production --json=stats-1.json
    npx webpack --mode=production --json=stats-2.json
    diff <(jq '.assets[].name' stats-1.json | sort) <(jq '.assets[].name' stats-2.json | sort)
    # Expect zero diff output for vendor and shared chunk filenames
  2. Change only app code and rebuild. Edit a single component that has no relationship to vendor dependencies, then rebuild. Only the chunk(s) containing that component should show a new hash; the vendor chunk filename must be unchanged.

  3. Inspect the Network panel on a real repeat visit. Deploy the change, load the app once, redeploy an unrelated fix, then reload. Vendor and shared chunk requests should show (disk cache) or 304; only the changed chunk should show a fresh 200.

  4. Gate it in CI. Compare the previous release’s vendor chunk hash against the current build’s before merging, and fail the pipeline if they differ without a corresponding package.json dependency change:

    # ci-check-vendor-hash.sh — fails if vendor hash drifted without a dep bump
    PREV_VENDOR=$(git show HEAD~1:dist/manifest.json | jq -r '.vendor')
    CURR_VENDOR=$(jq -r '.vendor' dist/manifest.json)
    if [ "$PREV_VENDOR" != "$CURR_VENDOR" ] && ! git diff HEAD~1 -- package-lock.json | grep -q '^[+-]'; then
      echo "Vendor chunk hash changed with no dependency diff — investigate module IDs" >&2
      exit 1
    fi
  5. Confirm the runtime chunk is actually separate. Check dist/ for a distinct runtime.[contenthash:8].js file (Webpack) and confirm it’s the only file whose size changes on nearly every build — that’s expected, since it holds the manifest.

  6. Track the cache hit ratio over multiple releases, not just one. A single unchanged vendor filename after one deploy is a good sign but not proof — some CDNs and browsers apply caching heuristics that can mask a one-off coincidence. Watch the origin-vs-edge hit ratio for the vendor asset path across at least three to five consecutive releases; it should settle above 92% with misses concentrated on releases that genuinely bumped a dependency.

FAQ

What’s the difference between contenthash, chunkhash, and hash in Webpack 5?

hash is a single value computed once per compilation and shared by every emitted asset — it changes on every build regardless of which files actually changed, so it’s unusable for long-term caching. chunkhash is computed from a chunk’s full assembled content, including every module bundled into it, so it stays stable only if both the chunk’s own modules and the exact set of modules assigned to that chunk stay the same. contenthash is computed from the literal bytes of the final emitted file after all other decisions (module IDs, chunk assignment, minification) are locked in, and is computed independently per file — it’s the correct choice for both JS and CSS filenames, but it can only be as stable as the module IDs and chunk boundaries feeding into it.

Why does my vendor chunk’s hash change when I only edited app code?

Almost always because the module IDs feeding into the vendor chunk shifted, not because the vendor chunk’s own dependencies changed. With the default sequential module ID assignment, adding or removing any module anywhere in the graph — including in app code — can renumber modules resolved after it, and if any of those renumbered modules live in the vendor chunk, its emitted bytes (and therefore its contenthash) change even though none of its source changed. Switching optimization.moduleIds to ‘deterministic’ stops IDs from depending on resolution order.

Does Vite have the same module ID instability as Webpack?

Not in the same form. Rollup — which powers Vite’s production build — doesn’t assign modules a sequential numeric ID that leaks into every reference; it inlines actual code and renders each chunk’s final bytes before hashing, so Vite’s default hashing is already closer to Webpack’s contenthash. The instability Vite projects hit instead comes from chunk boundary shifts: Rollup’s automatic module-promotion heuristic can reassign a shared dependency to a different chunk when an unrelated async import is added elsewhere in the graph. The fix is an explicit manualChunks mapping rather than a moduleIds setting.

What cache hit rate should I expect after switching to deterministic hashing?

Projects that combine optimization.moduleIds: ‘deterministic’ with optimization.runtimeChunk: ‘single’ (Webpack) or an explicit, pinned manualChunks mapping (Vite/Rollup) typically clear a 92%+ cache hit rate on vendor and shared chunks across consecutive deploys, up from roughly 40–60% with default sequential IDs and an inlined runtime. The remaining misses are almost entirely legitimate — chunks whose contents actually changed.