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.
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, differentcontenthash, 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.
How Often a Vendor Chunk Really Changes
Teams routinely overestimate this. Four events actually rewrite dependency bytes:
- A direct dependency version change in
package.json, whether a feature-driven upgrade or a security patch. - 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.jsonand is the reasonnpm ciagainst a committed lockfile matters; a floating install can rehash the vendor chunk with no commit to blame. - Adding or removing an import that pulls a new package (or drops the last import of one) into the chunk’s module set.
- 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.
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.
Related
- Content hashing vs semantic versioning — up: when a content digest beats a release label in an asset filename
- Webpack output hashing setup —
contenthashtemplates, deterministic ids, and cache group configuration - Rollup asset optimization — chunk emission, manual grouping, and manifest output in Rollup and Vite
- Deterministic build outputs — removing the non-determinism that makes a vendor hash move without a dependency change
- Preventing hash collisions in large frontend projects — truncation length once chunk counts run into the thousands
- Static asset fingerprinting fundamentals — up: the full fundamentals section