Hashing Vendor Bundles vs App Bundles

Application code and third-party dependency code change on completely different clocks: your product code moves several times a day, while the packages under node_modules move a handful of times a month. If both end up inside one output file, every application edit rewrites the bytes of code your users already have cached — so the two should be hashed, emitted, and cached as separate units.

This page covers the cache-hit-ratio maths behind splitting vendor code out, the mechanism by which a shared runtime and module-id map silently leak application changes into the vendor hash, how often a vendor chunk genuinely changes in practice, the point at which further splitting starts costing more than it saves, and a concrete floor for chunk size. It closes with matching Webpack and Vite configurations and a verification run that proves the vendor hash survived an application-only edit.

Two Clocks in One Output Directory

Consider a typical product repository over twelve consecutive production deploys. Feature work, copy fixes, and bug fixes touch application modules on every single one of them. The dependency set — React, a date library, a charting package, an HTTP client — changes twice: once when a patch release lands via a scheduled dependency bump, once when a new package is added for a feature.

If everything compiles into main.[contenthash:8].js, all twelve deploys produce a new filename. Returning users download the full payload twelve times, including the ~180 KB of framework code that was byte-identical across all of them. If dependencies live in their own vendor.[contenthash:8].js, that file gets a new name twice, and the other ten deploys are served straight from the browser and edge cache.

Deploy cadence of app versus vendor chunks Across twelve consecutive production deploys the application chunk receives a new content hash every time, while the vendor chunk changes only on the two deploys that touched the dependency set. Vendor and app chunks change on different clocks twelve consecutive production deploys App bundle 12 new hashes Vendor bundle 2 new hashes 1 2 3 4 5 6 7 8 9 10 11 12 new content hash byte-identical, cache hit
Twelve deploys: the app chunk rehashes on all of them, the vendor chunk only on the two that touched dependencies.

The Cache-Hit-Ratio Argument

The argument for splitting is arithmetic, not aesthetics. Let V be the compressed size of the dependency code, A the compressed size of the application code, d the number of deploys in a period, and v the number of those deploys that changed dependencies.

With a single bundle, a returning user who visits after every deploy transfers d × (V + A). With a vendor split, the same user transfers d × A + v × V. The bytes saved are (d − v) × V.

Plug in realistic numbers for the twelve-deploy window above: V = 180 KB, A = 60 KB, d = 12, v = 2. Unsplit, that is 2,880 KB per returning user. Split, it is 720 + 360 = 1,080 KB — a 62% reduction in repeat-visit transfer, achieved with about eight lines of build configuration.

The same ratio shows up at the edge. Every deploy publishes a brand-new URL that no point of presence has seen before, so the first request per PoP is a guaranteed miss that travels to origin. Splitting cuts the number of cold objects per release, which is why hit ratio measured over a release window climbs as soon as vendor code stops being republished. The cache key architecture page covers how those per-URL keys are formed at the edge.

The saving is bounded, though. If your dependency footprint is 20 KB and your application code is 400 KB, (d − v) × V is small and the split buys almost nothing. Measure V and A before assuming the split is worth it.

How the Runtime and Module-Id Map Leak App Changes

A vendor split that still rehashes on every deploy is the most common way this goes wrong, and the cause is almost always the module registry rather than the dependency code itself.

Bundlers do not emit raw module source. They emit modules keyed by an identifier, plus a small runtime that resolves those identifiers, maps chunk ids to output filenames, and handles dynamic import(). Two mechanisms put application-derived data inside the vendor chunk:

  • Module ids. Under Webpack’s default moduleIds: 'natural' (or 'named' in development), ids are assigned by traversal order. Adding one application module shifts the ids of everything downstream, including dependency modules — different bytes, different contenthash, even though no package changed.
  • The chunk-id-to-filename map. The runtime holds a table mapping every async chunk id to its hashed filename. If the runtime is bundled into the vendor chunk, then adding a lazy route to the application rewrites that table and therefore the vendor bytes.

The fix is two settings that work together: pin ids to a content-derived hash rather than traversal order, and evict the runtime into a chunk of its own. Webpack calls these optimization.moduleIds: 'deterministic' and optimization.runtimeChunk: 'single'; Rollup and Vite derive ids from resolved module paths already, so only the shared-module boundary needs attention.

Runtime placement and vendor hash stability When the module registry and chunk filename map live inside the vendor chunk, editing application code rewrites the vendor bytes. Extracting the runtime into its own chunk keeps the vendor hash stable across application-only edits. Runtime inside vendor Runtime extracted app chunk edited daily vendor chunk + runtime map app edit renumbers the shared id map vendor hash changes on every app edit app chunk edited daily vendor chunk packages only runtime chunk carries the id map vendor hash holds across app edits
Left: the shared runtime drags application churn into the vendor hash. Right: extracting it isolates the churn in a chunk of two or three kilobytes.

How Often a Vendor Chunk Really Changes

Teams routinely overestimate this. Four events actually rewrite dependency bytes:

  1. A direct dependency version change in package.json, whether a feature-driven upgrade or a security patch.
  2. A transitive resolution change recorded in the lockfile — a caret range resolving to a newer patch on a fresh install. This is invisible in package.json and is the reason npm ci against a committed lockfile matters; a floating install can rehash the vendor chunk with no commit to blame.
  3. Adding or removing an import that pulls a new package (or drops the last import of one) into the chunk’s module set.
  4. A bundler or minifier upgrade that changes code generation. Terser output differs subtly between minor versions, so a toolchain bump rehashes everything at once.

Note that only the second is outside your direct control, and pinning removes it. On a repository with a weekly dependency-bump job, the realistic figure is two to five vendor hash changes per month against 40–60 application deploys. Anything materially above that is a determinism defect, not a dependency cadence — deterministic build outputs walks through isolating the cause.

Split Strategy Decision Table

Split strategy Chunks emitted Vendor hash stability Request count Compression
Single bundle 1 None — rehashes every deploy Lowest (1) Best — one large window
App + vendor 2 High — only on dependency change Low (2) Very good
App + vendor + runtime 3 Highest — id map isolated Low (3) Very good; runtime is 2–4 KB
App + framework + other vendor 4 Highest for the framework chunk Moderate (4) Good
Per-package vendor chunks 30–200 Perfect per package High; HTTP/2 header cost adds up Poor — many sub-10 KB windows
Fully automatic granular chunks 100+ Perfect, but irrelevant Very high Worst — dictionary reuse collapses

The third row is the default recommendation for a single-application repository. The fourth is worth it when the framework (React, Vue, Angular and their runtimes) is a large, near-frozen block and the remaining dependencies churn more often — splitting them means a utility-library bump does not invalidate the framework block.

Where Further Splitting Turns Negative

Two costs grow as chunk count grows.

Request and header overhead. HTTP/2 multiplexes streams over one connection, which removes the six-connection bottleneck but not the per-request cost. Each additional chunk still means a request line, an HPACK-compressed header block, a response header block, a stream slot, and a round of priority scheduling. On a warm connection the marginal cost is small; on a cold connection over a high-latency mobile link, 120 chunks means the browser spends measurable time in the scheduler before it can start executing anything. The dependency graph also serialises: the runtime must arrive and parse before it can resolve the URLs of chunks it references.

Compression ratio. Gzip and Brotli build their dictionary from bytes already seen in the stream. A 200 KB chunk lets the compressor find long repeated sequences across the whole payload; ten 20 KB chunks each restart with an empty window, and repeated tokens common to all of them — framework internals, minified helper names, license banners — get re-encoded ten times. In practice, Brotli level 11 on a single 200 KB bundle lands near a 3.6× ratio; the same content split into twenty 10 KB chunks lands closer to 2.9×, with the gap widening as chunks shrink. Brotli’s static dictionary softens this for web-typical text, but it does not eliminate it.

Chunk size bands Chunks below twenty kilobytes compress poorly and cost a request each; chunks between twenty and one hundred fifty kilobytes are the practical sweet spot; chunks above that invalidate too much content per edit. Where a chunk-size floor and ceiling come from each chunk costs one request and gets its own compression window under 20 KB ratio collapses header overhead dominates payload 20 KB to 150 KB compression window stays useful boundaries track real change rates request count stays in single digits over 150 KB one edit invalidates a large payload split by change rate 20 KB 150 KB compressed chunk size
The practical band: a 20 KB floor protects the compression window, a 150 KB ceiling limits how much a single edit invalidates.

The Rule of Thumb

Set the floor at 20 KB compressed — roughly 60–80 KB raw for minified JavaScript. Below that, the compression loss plus header overhead cancels the caching win unless the chunk is genuinely immortal (a framework core that changes twice a year). Keep the ceiling near 150 KB compressed, and prefer to split at a change-rate boundary rather than a size boundary: two chunks that always change together should be one chunk, regardless of their combined size.

Both bundlers express the floor directly. Webpack’s splitChunks.minSize is measured in raw bytes before compression, so 60000 is the equivalent knob; Vite has no size floor for manual chunks, so you enforce it by choosing coarse groups rather than per-package ones.

Webpack: splitChunks

This configuration produces exactly three long-lived chunks — runtime, framework, other vendor — plus the application entry.

// webpack.config.js
const path = require('path');

module.exports = {
  mode: 'production',
  entry: { app: './src/index.js' },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js',
    clean: true
  },
  optimization: {
    moduleIds: 'deterministic',
    chunkIds: 'deterministic',
    runtimeChunk: 'single',
    splitChunks: {
      chunks: 'all',
      minSize: 60000,
      maxAsyncRequests: 8,
      maxInitialRequests: 6,
      cacheGroups: {
        framework: {
          test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
          name: 'framework',
          priority: 40,
          enforce: true,
          reuseExistingChunk: true
        },
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor',
          priority: 20,
          reuseExistingChunk: true
        },
        default: false
      }
    }
  }
};

enforce: true on the framework group bypasses minSize, which matters because a stripped React runtime can fall under 60000 raw bytes. priority: 40 guarantees the framework test wins over the broader vendor test for the same modules. default: false disables Webpack’s built-in group for shared application modules, which would otherwise create small chunks below your floor. The Webpack-specific behaviour of these options is covered further in the Webpack output hashing reference.

Vite: manualChunks

The equivalent Rollup layout, expressed as a function so the framework test runs before the general one:

// vite.config.js
import { defineConfig } from 'vite';

const FRAMEWORK = /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/;

export default defineConfig({
  build: {
    outDir: 'dist',
    manifest: true,
    rollupOptions: {
      output: {
        entryFileNames: 'assets/[name].[hash:8].js',
        chunkFileNames: 'assets/[name].[hash:8].js',
        assetFileNames: 'assets/[name].[hash:8][extname]',
        manualChunks(id) {
          if (FRAMEWORK.test(id)) return 'framework';
          if (id.includes('node_modules')) return 'vendor';
          return undefined;
        }
      }
    }
  }
});

Two differences from Webpack are worth knowing. First, Rollup has no separate runtime chunk: its import-resolution code is small and lives in the entry chunk, so the leak described earlier does not apply in the same way — but a module moving between framework and vendor still rehashes both. Second, returning undefined leaves the module under Rollup’s own chunking, which keeps route-level code splitting intact. Deeper treatment of the Rollup side lives in the Rollup asset optimization guide.

Verification: Prove the Vendor Hash Held

Configuration is a claim; a double build across an application-only edit is the proof. Run this from a clean checkout with a committed lockfile:

#!/usr/bin/env bash
# verify-vendor-hash.sh — build, edit app code only, rebuild, compare vendor hashes
set -euo pipefail

hashes_of() {
  find dist -name '*.js' -printf '%f\n' \
    | sed -E 's/\.[0-9a-f]{8}\.(chunk\.)?js$//' \
    | paste -d' ' - <(find dist -name '*.js' -printf '%f\n' \
        | grep -oE '[0-9a-f]{8}') \
    | sort
}

npm ci
npm run build
find dist -name '*.js' -printf '%f\n' | sort > /tmp/build-a.txt

# Application-only edit: append a comment to an app source file.
echo "// cache-busting probe $(date +%s)" >> src/index.js

npm run build
find dist -name '*.js' -printf '%f\n' | sort > /tmp/build-b.txt

git checkout -- src/index.js

echo "--- filenames that changed between builds ---"
diff /tmp/build-a.txt /tmp/build-b.txt || true

echo "--- vendor and framework chunks ---"
if diff <(grep -E '^(vendor|framework)\.' /tmp/build-a.txt) \
        <(grep -E '^(vendor|framework)\.' /tmp/build-b.txt) > /dev/null; then
  echo "PASS: vendor and framework hashes unchanged across an app-only edit"
else
  echo "FAIL: dependency chunks rehashed — check moduleIds and runtimeChunk"
  exit 1
fi

A correct configuration prints a diff listing exactly two changed filenames — the application entry and the runtime chunk — followed by PASS. If vendor also appears in the diff, you have the id-map leak: confirm moduleIds: 'deterministic' is set, confirm runtimeChunk: 'single' is set, and confirm no application module is being pulled into the vendor group by an overly broad test regex.

Wire the script into CI as a post-build step so a future configuration change cannot quietly reintroduce the leak. It costs one extra build, so schedule it on the main branch rather than on every pull request.

When to Reconsider

Keep one bundle when the application ships as a single small payload — a widget, an embed, a marketing page enhancement — under about 50 KB compressed in total. Two requests to save 15 KB on repeat visits is a poor trade against the extra connection scheduling.

Keep one bundle when dependency code is a minority of the payload. If node_modules accounts for under 20% of compressed bytes, (d − v) × V is too small to matter, and you are adding configuration surface for a rounding error.

Do not split per package. The temptation is strong in monorepos, where per-package chunks look tidy. The compression loss across dozens of sub-10 KB chunks reliably exceeds the caching benefit, and per-package hashes multiply the chunk count that has to stay unique — a concern the hash collision guide treats in detail.

Reconsider the framework split if you upgrade the framework as often as you deploy. The split only pays when the chunk is stable; a team on a nightly canary of its UI library gains nothing from isolating it.

Frequently Asked Questions

Should the vendor chunk be loaded before or after the app chunk?

Emit both as <script type="module"> tags in the head, vendor first, and let the browser fetch them in parallel. Module scripts are deferred by default and execute in document order, so the ordering in the markup determines execution order without blocking the parser. Do not add async to either — that would let the application chunk execute before its dependencies are evaluated.

Does a separate runtime chunk hurt performance?

Barely. A Webpack runtime chunk for a typical application is 2–4 KB before compression and under 1.5 KB after. It rehashes on nearly every deploy, which is exactly the point: it absorbs the churn that would otherwise land on a 180 KB vendor chunk. The one real cost is that it must be fetched and executed before any other chunk can resolve, so inline it into the HTML if you are optimising the critical path aggressively.

How do I decide which packages belong in a framework chunk?

Sort your dependencies by compressed size, then by how many times their version changed in the last six months of lockfile history. Anything large and near-frozen belongs in the stable group; anything small or fast-moving belongs with the general vendor chunk. git log -p --follow package-lock.json gives you the change history without extra tooling.

Will splitting change my Cache-Control headers?

No. Every chunk is content-hashed, so all of them are served with public, max-age=31536000, immutable and the HTML entry point stays no-cache. The split changes how often a given URL is replaced, not how any individual URL is cached.