Fingerprinting Assets for Immutable Caching
After working through this guide you will be able to give every static asset a filename derived from its own contents, serve those files with a one-year immutable lifetime, keep the HTML that references them deliberately short-lived, and delete the cache-purge step from your deploy pipeline because nothing that is cached can ever go out of date.
Fingerprinting is the one caching technique that removes a problem rather than managing it. Every other approach — TTL tuning, tag purges, stale windows — exists because a URL can change what it returns. Give the URL a name derived from a hash of its bytes and that can no longer happen: app.4f3a1c9e.js either exists with exactly those contents or does not exist at all. The cache can hold it forever, the browser never revalidates it, and a deploy publishes new URLs instead of invalidating old ones. What remains is a single short-lived document — the HTML — that points at the current set of names.
Key implementation objectives:
- Derive each asset filename from a hash of its own bytes, not from a build number or a timestamp.
- Serve fingerprinted files with
Cache-Control: public, max-age=31536000, immutable, and the referencing HTML with a lifetime measured in minutes. - Deploy atomically and keep the previous build’s files in place, so a client mid-navigation never requests a name that has already been deleted.
- Make the build deterministic, so identical source produces identical hashes and unchanged files keep their cached lifetime.
Prerequisites and environment setup
You need a build step that can rewrite references (a bundler, a static site generator, or an asset pipeline), somewhere to upload the output, and the ability to set response headers per path prefix at the CDN or object store.
node --version # v18 or later for the examples below
npx vite --version # or webpack 5 / esbuild / rollup
aws --version # if deploying to S3 + CloudFront
npx wrangler --version
Decide up front where fingerprinted output will live. Putting all hashed files under a single prefix — /assets/ or /_next/static/ — is worth doing purely because it makes the header rule a one-liner instead of a pattern-matching exercise. Everything under that prefix is immutable; everything outside it is not.
Step 1 — Hash on content, not on build identity
There are two families of fingerprint, and only one of them delivers the benefit.
A content hash is computed from the bytes of the output file. Identical output produces an identical name, so a stylesheet that did not change between deploys keeps its URL and stays cached in every browser and every edge that already holds it. A build id — a timestamp, a commit SHA, a CI run number — changes on every deploy regardless of whether anything in the file changed, which renames every asset every time and throws away the entire cache on each release. Build ids are simpler to implement and strictly worse; the only case for one is a bundler that cannot do better.
In Vite the content hash is the default, and the relevant knob is where the files land:
// vite.config.js
export default {
build: {
assetsDir: "assets",
rollupOptions: {
output: {
entryFileNames: "assets/[name].[hash].js",
chunkFileNames: "assets/[name].[hash].js",
assetFileNames: "assets/[name].[hash][extname]",
},
},
manifest: true, // emits .vite/manifest.json mapping source -> hashed name
},
};
In webpack, insist on contenthash and not hash or chunkhash — the distinction is exactly the one above:
// webpack.config.js
module.exports = {
output: {
filename: "assets/[name].[contenthash:8].js",
chunkFilename: "assets/[name].[contenthash:8].js",
assetModuleFilename: "assets/[name].[contenthash:8][ext]",
clean: false, // see step 4 — do not delete the previous build
},
};
Eight hex characters is plenty. It is 32 bits of hash over a namespace of at most a few thousand files, and the failure mode of a collision is a build error rather than a corrupted deploy.
The build should also emit a manifest mapping logical names to hashed ones. Your template layer reads it to write the correct <script src> and <link href>, and anything else that needs to reference an asset — a service worker precache list, a preload header, an email template — reads the same file rather than guessing.
Step 2 — Set the two header policies
Fingerprinting is only half a technique; the headers are the other half. Two rules, and the contrast between them is the whole design.
# Fingerprinted assets — the name encodes the contents
Cache-Control: public, max-age=31536000, immutable
# HTML and anything else without a hash in its name
Cache-Control: public, max-age=0, s-maxage=60, must-revalidate
max-age=31536000 is one year, the largest value the specification suggests honouring. immutable is the meaningful addition: without it, a browser still issues a conditional revalidation when the user reloads the page, and you pay a round trip per asset for nothing. With it, a reload skips the request entirely. Support is broad and the directive is safely ignored where it is not understood.
The HTML policy deliberately gives the browser nothing and the shared cache a little. max-age=0, must-revalidate means a client always checks; s-maxage=60 lets the CDN absorb the traffic so that check costs you an edge hit rather than an origin hit. That one-minute shared window is also the upper bound on how long a deploy takes to become visible, which is a far more comfortable number to reason about than “until the cache expires”.
Why filename hashing beats query-string versioning
app.js?v=4f3a1c looks equivalent and is not, for a reason that only shows up at the CDN. Query strings are part of the cache key only if the CDN is configured to include them, and plenty of configurations strip or normalise them for hit-ratio reasons — the same normalisation discussed in customizing cache keys to improve hit ratio. When that happens, ?v=old and ?v=new collapse to the same cached object and your versioning silently stops working. Some intermediate proxies also decline to cache any URL containing a query string at all.
A distinct filename has none of these ambiguities. It is a different path, therefore a different object, on every cache in the world including ones you do not control. Use the query string only where you cannot rename the file — a third-party endpoint, a legacy path someone else links to.
Step 3 — Configure the headers per provider
On Cloudflare Pages or Workers Sites, a _headers file at the output root is the least error-prone route:
/assets/*
Cache-Control: public, max-age=31536000, immutable
/*.html
Cache-Control: public, max-age=0, s-maxage=60, must-revalidate
/
Cache-Control: public, max-age=0, s-maxage=60, must-revalidate
On S3 behind CloudFront, the header is metadata on the object, so it is set at upload time. Sync in two passes — assets first with the long policy, then everything else with the short one:
# 1. hashed assets: long, immutable, safe to upload before the HTML
aws s3 sync ./dist/assets s3://$BUCKET/assets \
--cache-control 'public, max-age=31536000, immutable'
# 2. documents: short lived, uploaded last so they only ever point at
# assets that are already in place
aws s3 sync ./dist s3://$BUCKET \
--exclude 'assets/*' \
--cache-control 'public, max-age=0, s-maxage=60, must-revalidate'
The ordering is not cosmetic. Uploading the HTML first creates a window in which a document references an asset that has not finished uploading, and every visitor in that window gets a broken page.
On nginx serving the build directly:
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
location / {
add_header Cache-Control "public, max-age=0, s-maxage=60, must-revalidate" always;
try_files $uri $uri/index.html =404;
}
Whichever provider you use, the compression settings for these paths matter too — a hashed bundle with a one-year lifetime is the ideal candidate for the most expensive compression level available, because the encode cost amortises across the entire year. See enabling Brotli and Zstandard compression at the edge for the codec side of that trade.
Step 4 — Deploy atomically and keep the old files
This is the step teams skip, and it produces the most confusing incident in the whole pattern.
A user loads your page. The HTML they received names app.4f3a1c.js. Two seconds later a deploy runs, uploads app.7c02de.js, and deletes the old file. The user’s browser — which has not yet requested the script, or is lazily fetching a route chunk five minutes into the session — asks for 4f3a1c and gets a 404. The page breaks for people who were already using it, which is exactly the population a deploy should be least disruptive to.
The fix is that deletion must lag deployment. Never let the build clean the output directory of previously deployed files (clean: false above), and never run aws s3 sync --delete as part of the deploy. Instead:
- Upload the new build’s assets alongside the old ones.
- Upload the documents last, switching traffic to the new names.
- Retain old assets for at least as long as the longest plausible session — a week is a common, comfortable choice.
- Garbage-collect on a schedule, keeping every asset referenced by any manifest from the retention window.
An S3 lifecycle rule handles step four without any code, provided each build writes to its own prefix, or you can drive it from the union of recent manifests:
# Delete assets not referenced by any manifest from the last 10 builds.
jq -r '.[].file' manifests/*.json | sort -u > keep.txt
aws s3 ls "s3://$BUCKET/assets/" | awk '{print $4}' > have.txt
comm -13 keep.txt have.txt | while read -r f; do
aws s3 rm "s3://$BUCKET/assets/$f"
done
Because old assets survive, a rollback is also trivial: republish the previous documents and they still reference files that never left. There is no purge to coordinate and no window where the HTML and the assets disagree — the version-skew problem disappears along with the purge step. Where you do still need invalidation is for the documents themselves, and for API responses, which is where using cache tags for surrogate-key purging earns its place.
Step 5 — Service workers, preload, fonts, and images
Three interactions are worth handling deliberately.
Service workers. A precache list built from the manifest is ideal, because each entry is already uniquely named and the worker can skip its own revision tracking. The one file that must never be cached aggressively is the service worker script itself: browsers cap its freshness lifetime at 24 hours, but a stale worker is still a stuck user, so serve sw.js with max-age=0, must-revalidate and leave it unhashed. Everything it precaches is hashed; it is not.
Preload and early hints. <link rel="preload"> and 103 Early Hints both name specific URLs, so they must be generated from the manifest rather than hand-written. A preload pointing at a hash that no longer exists costs a wasted request and a console warning on every page view.
Fonts and images. The same rules apply, with one addition: fonts are a strong immutable candidate because they change almost never and are expensive to re-download, so hash them and give them the one-year policy without hesitation. Images benefit equally, but if you transform them at the edge — resizing or reformatting per client — the transformed variants are keyed on request attributes as well as the URL, and the immutable lifetime applies to each variant. That interaction is covered in resizing and reformatting images at the edge.
Verification
Three checks, in order. First, confirm the asset carries the immutable policy and that a repeat request is served from cache:
curl -sI https://www.example.com/assets/app.4f3a1c.js \
| grep -iE 'cache-control|cf-cache-status|age|content-encoding'
# cache-control: public, max-age=31536000, immutable
# cf-cache-status: HIT
# age: 91847
# content-encoding: br
An age in the tens of thousands of seconds on a HIT is the signal you want here — it means the object has been sitting at the edge across many deploys, untouched.
Second, confirm the document is not immutable, which is the mistake that bricks a site:
curl -sI https://www.example.com/ | grep -i cache-control
# cache-control: public, max-age=0, s-maxage=60, must-revalidate
Third, confirm the previous build’s assets are still reachable after a deploy:
# take a hashed URL from the manifest of the PREVIOUS build
curl -so /dev/null -w '%{http_code}\n' https://www.example.com/assets/app.4f3a1c.js
# 200
A 404 there means step 4 is not implemented and you are breaking in-flight sessions on every release.
Troubleshooting
Stale HTML pointing at a deleted hash
Symptom: a small, persistent trickle of 404s on /assets/* paths, often from clients on slow networks or long-lived tabs. A document cached somewhere — a shared proxy, a corporate cache, a browser that ignored your must-revalidate — still names an old asset. Confirm by checking which hashes are being requested against your retention window:
awk '$7 ~ /^\/assets\// && $9 == 404 {print $7}' access.log | sort | uniq -c | sort -rn | head
The fix is retention, not purging: keep old assets long enough that no plausible cached document outlives them. If the 404s are for hashes older than your retention, shorten the document’s s-maxage and verify no intermediary is over-caching the HTML. As a safety net, have the application reload the page once when a dynamic import fails, so a user with a stale document self-heals instead of hitting a blank screen.
immutable on something that actually changes
Symptom: a fix ships, but a subset of users keep the old behaviour indefinitely and no purge helps. Somebody applied the long policy to an unhashed path — /config.json, /manifest.json, or the site root. This is the worst failure in the pattern, because a browser holding an immutable response will not revalidate it and a CDN purge does not reach browsers. Audit what carries the long policy:
for p in / /index.html /config.json /sw.js /assets/app.4f3a1c.js; do
printf '%-28s %s\n' "$p" "$(curl -sI "https://www.example.com$p" | grep -i '^cache-control' | tr -d '\r')"
done
Recovery from a client-side immutable mistake requires changing the URL, since you cannot reach the cached copy: rename the file and update the references. Prevent recurrence by scoping the rule to a path prefix that only fingerprinted output is written to, so a file can only get the long policy by being hashed.
Hashes change on every build
Symptom: every deploy renames every asset even when nothing changed, so returning visitors re-download the whole bundle each release. The build output is not deterministic. The usual culprits are an embedded build timestamp, an absolute path baked into a source map or module id, a dependency ordering that varies with filesystem iteration, and locale-dependent formatting. Diagnose by building twice from a clean checkout and comparing:
rm -rf dist && npm run build && cp -r dist /tmp/build-a
rm -rf dist && npm run build && cp -r dist /tmp/build-b
diff -rq /tmp/build-a /tmp/build-b
Any difference is the bug. Set SOURCE_DATE_EPOCH for tooling that honours it, replace Date.now() in build-time constants with a value derived from the source, use deterministic module ids (deterministic in webpack’s optimization.moduleIds), and sort any file list you glob. The payoff is direct: a stable hash on an unchanged file means that file stays cached in every browser that already has it, which is most of the benefit of fingerprinting in the first place.
The edge is serving the right file but the wrong encoding
Symptom: an asset is correct in one browser and corrupted in another after a compression change. Because the object is immutable, the edge will happily keep serving a variant encoded under the old settings for a year. Changing codecs or compression levels does not retroactively re-encode what is stored. Purge the affected prefix once after a compression change — one of the few times a fingerprinted asset needs purging at all — and confirm content-encoding and vary on the way back.
Frequently Asked Questions
Does immutable actually do anything, given max-age is already a year?
Yes, on reload. Without immutable a browser revalidates cached subresources when the user reloads the page, producing a conditional request and a 304 per asset; with it, the browser skips the request entirely. On a page with forty assets that is forty round trips saved on every reload, which is exactly when a user is already unhappy.
What if two different files hash to the same name? With a 64-bit or longer digest truncated to eight or more hex characters over a few thousand files, the probability is negligible, and bundlers detect a collision at build time rather than emitting two files with one name. If you are hashing a very large number of assets, widen the truncation to twelve characters rather than switching strategy.
Should the hash go in the filename or the directory?
Either works; the filename is more common and easier to sweep. A per-build directory (/assets/7c02de/app.js) makes retention and garbage collection simpler because you can expire a whole prefix, but it renames every asset on every build even when the bytes are unchanged — which forfeits the cache reuse that content hashing exists to provide. Prefer the filename.
How long should I keep old builds’ assets?
Longer than your longest realistic session, which for most applications means a week. The cost is object storage, which is trivial; the cost of getting it wrong is a 404 on a lazy-loaded chunk for someone who was mid-task. Sweep on a schedule against the union of recent manifests rather than deleting on deploy.
Can I stop purging the cache entirely once assets are fingerprinted? For the assets, yes — that is the point, and it is why fingerprinting is the recommended alternative to purging static files. You will still purge documents and API responses, since those keep stable URLs with changing contents, and you may need a one-off purge after changing compression settings. What disappears is the per-deploy asset purge and the version-skew window it was papering over.