How to Choose Between Content Hash and Version Hash for Static Assets
Selecting the wrong fingerprinting strategy causes stale asset delivery, 404 Not Found errors after deployment, and CDN purge budgets that spiral out of control. This guide maps concrete symptoms to root causes, provides a decision matrix for content hash vs version hash selection, and shows exact bundler configurations for both strategies.
The Core Distinction
Content hash derives the URL fingerprint directly from the bytes of a single file. Only files that change get new URLs. Assets that do not change keep the same URL and stay cached indefinitely at every CDN edge node.
Version hash (also called a build hash or release tag) derives the fingerprint from a release identifier — a package.json version string, a CI run ID, or a Git commit SHA. Every asset in the release shares the same prefix or suffix, even if 90% of the files are byte-identical to the previous release.
The tradeoff is between cache efficiency and operational simplicity.
Symptom-Based Diagnosis
Before choosing a strategy, check what is failing in your current deployment:
Symptom: Users see outdated JavaScript immediately after a deploy
Run this against your CDN origin to see what the edge is serving:
curl -sI -H 'Pragma: no-cache' https://cdn.yourdomain.com/assets/main.a1b2c3d4.js \
| grep -iE 'cache-control|age|etag|x-cache'
An age header with a high value (e.g., age: 86400) means the CDN is not invalidating stale content. The root cause is usually one of:
- The filename did not change (version hash not updated, or content hash not configured).
- The CDN cache key ignores the filename path (e.g., normalises query strings but not paths).
- The HTML entry point is also cached and still references old asset URLs.
Symptom: A CDN purge job is triggered for hundreds of files on every deploy, even though only two files changed
This is the classic failure mode of version hashing. The release version increments, every asset URL changes, and the CDN must re-fetch everything. The fix is content hashing.
Symptom: 404 errors for assets immediately after deploy
This indicates the HTML is referencing new hashed URLs that the CDN has not yet fetched from origin. See cache-key architecture for the atomic deploy sequence that prevents this race condition.
Decision Matrix
| Factor | Favour content hash | Favour version hash |
|---|---|---|
| Deploy frequency | Daily or continuous | Weekly or monthly |
| Build determinism | Strictly enforced | Hard to guarantee |
| CDN purge cost | Must minimise per-file purges | Full-release purge is acceptable |
| Compliance/auditing | Per-file change tracking | Single-version audit trail |
| Rollback pattern | Revert HTML manifest | Route traffic to prior version directory |
| SRI enforcement | Regenerate per build (automated) | Stable within a minor update |
| Team size | Large team, many PRs | Small team, monolithic releases |
| Build output size | Thousands of chunks | Handful of bundled files |
For most projects deploying at least weekly, content hashing is the better default. It is what Webpack, Vite, Rollup, and esbuild all produce out of the box.
What Actually Changes on the Next Deploy
The decision matrix is abstract until you count objects. Take a build that emits 300 fingerprinted files — a vendor chunk, forty route chunks, a stylesheet per route, and a couple of hundred icons and fonts. A typical feature branch touches two source modules, which after bundling changes four output files.
Under content hashing, four filenames change. The other 296 URLs are byte-identical to the previous release, so every edge node and every returning browser keeps serving them from cache with zero origin traffic. Under version hashing, all 300 URLs change, because the release identifier is part of every path. The edge has to refill 300 objects from origin on the first request in each pop, and returning browsers re-download the entire asset set even though 98.7% of the bytes are unchanged.
The bandwidth arithmetic follows directly. If those 300 objects total 6 MB compressed and you deploy twelve times a day, version hashing forces roughly 72 MB of origin refill per pop per day, plus a full re-download for every user who returns after each deploy. Content hashing moves the same feature for about 80 KB. On a site with meaningful traffic the difference shows up as origin egress cost and as a first-visit-after-deploy latency spike, not as anything visible in the build log.
The counter-argument is not about bytes. Version hashing gives you a single identifier that names the whole release, which makes some operational questions trivially answerable: which release is this browser running? is one header or one path segment away. With content hashing you answer that question through the manifest, which is why the manifest becomes a first-class deployment artefact rather than a build by-product.
Content Hashing: Full Configuration
Content hashing is the default in every modern bundler. The key is ensuring you use the right token ([contenthash] not [hash] in Webpack) and that deterministic module IDs are enabled.
Webpack 5
// webpack.config.js
module.exports = {
mode: 'production',
output: {
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].chunk.js',
assetModuleFilename: 'assets/[name].[contenthash:8][ext]',
clean: true
},
optimization: {
moduleIds: 'deterministic',
chunkIds: 'deterministic',
runtimeChunk: 'single',
splitChunks: {
chunks: 'all'
}
}
};
The runtimeChunk: 'single' setting isolates the Webpack runtime into its own small file. Without it, the runtime chunk (which contains module ID mappings) changes on every build, cascading hash changes into your vendor bundle even when vendor code is unchanged.
Vite
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
entryFileNames: 'entry/[name]-[hash:8].js',
chunkFileNames: 'chunks/[name]-[hash:8].js',
assetFileNames: 'assets/[name]-[hash:8][extname]'
}
}
}
});
For a complete walkthrough of Vite hash configuration options, see how to configure content hashing in Vite production builds.
CDN cache headers for content-hashed assets
# nginx — serve any path containing a hex fingerprint as immutable
location ~* -[a-f0-9]{8,16}\. {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary Accept-Encoding;
}
The immutable directive tells the browser to not even send a revalidation request within the asset’s max-age window. Because the filename changes when content changes, there is no risk of serving stale content.
Version Hashing: Configuration and Use Cases
Version hashing is appropriate when build determinism is hard to guarantee, when you need instant rollback without touching CDN purge, or when compliance requires a single identifiable version identifier across all assets.
Nginx version-prefix routing
# nginx — map /assets/v<semver>/* to a release directory
location ~ ^/assets/v([0-9]+\.[0-9]+\.[0-9]+)/(.+)$ {
alias /var/www/releases/v$1/static/$2;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
Rollback is a one-line config change: update the version prefix in the HTML template and reload Nginx. No CDN purge required because the previous version’s URLs are still cached.
CloudFront cache policy for version-prefixed paths
{
"CachePolicyConfig": {
"Name": "Immutable-Versioned-Assets",
"DefaultTTL": 31536000,
"MaxTTL": 31536000,
"MinTTL": 0,
"ParametersInCacheKeyAndForwardedToOrigin": {
"CookiesConfig": { "CookieBehavior": "none" },
"HeadersConfig": { "HeaderBehavior": "none" },
"QueryStringsConfig": { "QueryStringBehavior": "none" }
}
}
}
Strip cookies and query strings from cache keys. Only the URL path — which includes the version prefix — differentiates objects.
Deriving the version prefix from a Git SHA
A package.json version is a poor release identifier for continuously deployed apps because it does not change on every merge. A short Git SHA does, and it is available in every CI environment. Feed it into the bundler as the public path prefix so that no source file has to know the release identifier:
// webpack.config.js — version-prefixed public path from CI metadata
const { execSync } = require('node:child_process');
const release =
process.env.RELEASE_SHA ||
execSync('git rev-parse --short=12 HEAD').toString().trim();
module.exports = {
mode: 'production',
output: {
path: require('node:path').resolve(__dirname, `dist/${release}`),
publicPath: `/assets/${release}/`,
filename: '[name].js',
chunkFilename: '[name].chunk.js',
clean: true
},
optimization: {
moduleIds: 'deterministic',
chunkIds: 'deterministic',
runtimeChunk: 'single'
}
};
Every emitted URL becomes /assets/9f8e7d6c5b4a/main.js. The previous release stays on disk and at the edge under its own prefix, which is what makes the rollback a routing change rather than a rebuild. The equivalent in Vite is the base option, set to the same /assets/${release}/ string; Vite rewrites every import and every CSS url() reference to match.
Rollback Mechanics Compared
The two strategies fail differently, and rollback is where the difference is sharpest. Version hashing wins on wall-clock recovery time; content hashing wins on everything you do between incidents.
The content-hash path is longer but not risky, provided you kept the previous manifest. Store asset-manifest.json as a build artefact keyed by release, and the rollback becomes: fetch the artefact, confirm every file it names still exists at origin, deploy the matching HTML, purge the HTML. The step that catches teams out is the second one — if a cleanup job deletes old fingerprinted files from the bucket after each deploy, the previous manifest points at objects that no longer exist and the rollback produces a page of 404s. Keep at least the last five releases’ assets, or disable the cleanup entirely and let lifecycle rules expire objects after 30 days.
The Hybrid Pattern
The most pragmatic production setup combines both strategies:
- HTML entry points (
index.html,_document.html): served withCache-Control: no-cache, must-revalidateso browsers always fetch the latest HTML. The HTML contains all the content-hashed asset URLs. - JavaScript and CSS bundles: content-hashed (
[contenthash:8]), served withCache-Control: public, max-age=31536000, immutable. - Vendor bundles: content-hashed — they change rarely and benefit most from long-term caching.
- Critical CSS for above-the-fold rendering: optionally version-hashed to allow instant global rollback if a bad CSS deploy causes visual regressions.
This pattern is described in detail in cache-key architecture.
Verification After Switching Strategy
After changing your hashing strategy, verify that the CDN is serving the right content:
# 1. Check that the new fingerprinted URL returns a fresh response
curl -sI https://cdn.yourdomain.com/assets/main.NEW_HASH.js \
| grep -iE 'cache-control|age|etag|x-cache'
# 2. Confirm the old URL returns 404 (content-hash strategy) or still serves the old file (version-hash)
curl -sI https://cdn.yourdomain.com/assets/main.OLD_HASH.js \
| grep -i 'http/'
# 3. Confirm the HTML entry point has been updated to reference the new hash
curl -s https://yourdomain.com/ | grep -o 'main\.[a-f0-9]*\.js'
For strategies on what to do when you need to undo a deploy, see content hashing vs semantic versioning.
When to Reconsider Your Choice
Content hashing becomes problematic when:
- The build is non-deterministic and you cannot fix it quickly — hashes change on every CI run, defeating the immutable-cache benefit.
- Your CDN has a per-file purge rate limit and you need to purge thousands of assets simultaneously.
- Your team operates entirely from release branches with long freeze periods — version hashing aligns better with that operational model.
Version hashing becomes problematic when:
- Deploy frequency is high and the CDN purge cost scales with it.
- Micro-frontend or module federation setups share assets across independently versioned apps — a shared asset’s version prefix is ambiguous.
- You adopt SRI, because SRI requires per-file hashes that effectively give you content hashing at the integrity layer anyway.
- Storage grows without bound: every release keeps a full copy of every asset under its own prefix, so a daily-deploying team accumulates a year of duplicated vendor bundles unless a lifecycle rule prunes them.
A third position exists and is worth naming: content hashing plus a release-scoped directory, giving paths like /assets/9f8e7d6c/main.a1b2c3d4.js. You get the instant prefix-flip rollback of version hashing and the byte-level change detection of content hashing, at the cost of losing cross-release edge cache reuse — the same bytes live at two URLs when two releases contain them. That trade is usually worth it only if your rollback SLA is measured in seconds and your asset payload is small enough that refilling the edge is cheap. Whichever of the three you land on, record the decision alongside the build config rather than leaving it implicit in a filename pattern, and generate an asset manifest so the mapping from logical name to deployed URL is a file you can diff rather than a convention you have to remember.
Frequently Asked Questions
Can I use both content hashes and version hashes in the same deployment?
Yes. The hybrid pattern described above is common in production. Use version-based identifiers for HTML and version-sensitive entry points, and content hashes for everything those documents reference.
Does content hashing guarantee zero CDN purge costs?
No. The HTML entry points themselves are typically not fingerprinted and must be purged (or served with short TTLs) so browsers pick up updated asset URLs. The cache-key architecture guide covers how to structure this without a global purge.
How does SRI interact with version hashes?
SRI checks the byte content of the file, not the URL. If a version hash increments but the file bytes do not change, the SRI hash remains valid. If the file bytes change, the SRI attribute must be regenerated regardless of which URL strategy you use.
Which strategy does the browser cache prefer on a repeat visit?
Neither — the browser has no concept of a strategy. It matches on the exact URL. That is precisely why content hashing wins on repeat visits: 296 of 300 URLs are character-for-character identical to the ones already in the HTTP cache, so they are served locally with no network request at all, while a version-hash deploy presents 300 URLs the cache has never seen.
What happens to old version-hashed assets during a rollback?
Nothing — they are still cached at the CDN. You redirect traffic to the previous version prefix (by updating your HTML template or nginx config) and the CDN serves the old objects from cache without touching origin. This is the primary operational advantage of version hashing.
Related
- MD5 vs SHA-256 for assets — parent page covering algorithm comparison and bundler defaults
- Safely truncating content hash length — collision probability at 8, 12, and 16 hex characters
- Cache-key architecture — atomic deploy sequencing and CDN cache key design
- Content hashing vs semantic versioning — broader comparison of fingerprinting approaches