Cache Busting with Hashing: How to Never Serve a Stale Image Again
Learn how to implement asset versioning and hashing to solve stale cache issues, from backend upload to CDN and Service Worker.

You update the site's logo, deploy, and the client calls complaining they still see the old logo. Support asks them to "clear the browser cache." The client doesn't know what that is. You waste 30 minutes explaining.
That's stale cache. And the solution is simpler than you think: content hashing.
Mental model Think of the content hash as a fingerprint stamped onto the filename. Change the file, the fingerprint changes. New fingerprint means a new URL. New URL means the browser has literally never seen it before, so there's no cache to be stale. You didn't bust the cache. You sidestepped it entirely.
The Problem: Stale Cache
Without versioning, when you update logo.png on the server, the CDN and browser keep serving the old version until the cache expires. That can take hours, days, or weeks.
With a hash in the filename, logo.png becomes logo.a3f8c2b1.png. The browser treats it as a new file and fetches it immediately.
War Story: The Friday Deploy Nobody Saw
Classic scene. Team ships a big homepage redesign Friday at 5pm. Monday morning, the CEO pings: "looks identical to me." Designer swears it shipped. DevOps swears the deploy went green. Everyone's right, and everyone's wrong. The HTML was cached for 7 days on the edge, and half the CSS was still pointing at the old bundle. They spent the weekend arguing instead of celebrating. Google's fonts team solved this exact class of problem years ago by hashing every font file aggressively, and most modern asset pipelines now treat every deployable artifact as immutable by default. Moral: if your users can't tell you deployed, you didn't deploy.
Versioning Strategies
- Content Hash (
logo.a3f8c2b1.png): The hash changes automatically when the content changes. Perfect precision. Used by Webpack, Vite, Turbopack, esbuild. - Query String (
logo.png?v=123): Manual and simple, but some CDNs and proxies still strip query strings from the cache key. - Path Version (
/v2/images/logo.png): Versioning by release, manual. - ETag (HTTP header): Native conditional validation, still requires a round trip to revalidate.
The most reliable approach is content hash in the filename.
How Content Hashing Works
For asset cache busting, MD5 is perfectly adequate. We're not defending against cryptographic attacks, we just want a fingerprint that changes when the file changes. If MD5 still feels icky, swap in xxhash (faster) or sha256 (slower, fine). The build is not your bottleneck.
Backend: Upload with Hash and Deduplication
The hash is generated from the processed buffer, not the original. The same file reprocessed with the same parameters generates an identical hash.
Cache Headers: The Right Configuration
The immutable directive tells the browser: "this file will never change at this URL." The browser won't even make a conditional request.
Frontend: Build Tools Do the Heavy Lifting
Vite
In your code, you import normally:
Responsive Images with Independent Hashes
Each variant has its own hash because the content is different.
The Golden Rule of Deployment
HTML must never have long cache. It's the entry point that references the hashed assets. If the HTML gets cached, the user loads old assets. Use Cache-Control: no-cache for HTML.
Assets with hash in the name: aggressive caching (1 year, immutable). HTML: no cache or very short cache.
When NOT to Hash
Hashing is brilliant, but some files have fixed names by contract with the outside world:
- HTML entry points (
index.html): this is the door. If you hash it, nobody knows where the door is. Short cache, no hash. robots.txt: crawlers look for that exact name. No hash, ever.sitemap.xml: same story, search engines expect the canonical path.favicon.ico: trickier than it looks. Browsers sniff/favicon.icoaggressively and cache it for absurd durations. You can reference a hashed favicon via<link rel="icon">in HTML, but keep a fallback at the root path.- Well-known URLs (
/.well-known/*, OAuth callbacks, webhooks): the name is the contract. - Service workers (
sw.js): the registration URL must stay stable, and browsers already revalidate it on a tight cycle. Hash the assets the worker caches, not the worker itself.
Rule of thumb: if somebody else decides the URL, you can't hash it.
CDN Purge vs Hashing: You Still Need Both
Hashing doesn't retire your CDN purge button. It just makes you use it less.
| Scenario | Hashing handles it? | Need purge? |
|---|---|---|
New version of app.css | Yes, new hash = new URL | No |
New index.html deploy | No, filename is fixed | Yes, or short TTL |
robots.txt update | No | Yes |
| Hot bugfix in a non-hashed asset | No | Yes |
| Wrong file uploaded to a hashed URL | No, URL is locked | Yes, urgent |
Hashing covers 95% of traffic (the assets). Purge is your fire extinguisher for HTML, contractual URLs, and "oh no" moments.
FAQ
What hash length should I use? 8 to 12 hex characters. 8 gives you ~4 billion combinations, collision odds are negligible for a single project. 12 is overkill but cheap. Under 8 starts feeling risky at scale.
Is MD5 safe for this? Yes, for fingerprinting. MD5 is broken for cryptography (collision attacks against signed data), not for "did this file change." You're not signing a certificate, you're labeling a box. The known MD5 collisions require an attacker crafting both inputs, which isn't a threat model when you're hashing your own build output. Use SHA-256 if your team gets uncomfortable, the performance difference is irrelevant at build time.
Should source maps be hashed too?
Yes, same rules. Hash them and reference via //# sourceMappingURL=app.a3f8c2b1.js.map. Bonus: don't serve source maps publicly in production unless you mean to. Gate them behind auth or only upload them to your error tracker (Sentry, Datadog).
Does atomic deploy matter?
A lot. If HTML goes live referencing app.NEW_HASH.js before that asset exists on the edge, users get 404s during the deploy window. The order is non-negotiable: upload assets first, flip HTML last, purge HTML cache. Vercel, Netlify, Cloudflare Pages, and most modern static hosts handle this atomically out of the box. If you're rolling your own with S3 + CloudFront or an Nginx box, you own the ordering, and you will get it wrong at least once.
What about a stale client that already has the old HTML?
This is the edge case everyone forgets. A user loaded your page yesterday, left the tab open, comes back today. Their HTML still references app.OLD_HASH.js, which still exists on your CDN (immutable, remember?), so it loads fine. They see yesterday's app. That's a feature, not a bug, as long as you keep old hashed assets around for a while. Don't aggressively delete them on deploy, retain at least the last 2 or 3 deploys. For critical updates (security, broken API contract), ship a version check that forces a reload.
Key Takeaways
Content hashing solves stale cache definitively. The flow is:
- Build tool generates a content hash and renames the file
- Server serves with
Cache-Control: immutable - When updating, the new hash generates a new URL = automatic cache miss
- No CDN invalidation needed
Implement this once and never ask a user to "clear the cache" again.