Cache Busting

Caching

Giving a web asset a new URL whenever its content changes, so every cache treats it as a new object and fetches it. It deletes nothing: a version query string (?v=2) or, more reliably, a content hash in the filename (style.a1b2c3.css) simply stops matching the old cache entry.

Also known as Fingerprinting.

9 min read Updated Aug 30, 2026

Full Explanation

Cache busting is a naming strategy. You give a resource a new URL every time its content changes. Browsers, CDNs, and every other HTTP cache then treat it as a brand-new object and fetch it from the origin. It is not a purge, and it deletes nothing. The old copy stays stored under its old URL until it expires or is evicted. You simply stop asking for it (web.dev, HTTP caching). It is not a speed-up in itself either. What it buys you is permission to cache aggressively. Once a URL can never serve different bytes, a long freshness lifetime carries no risk of staleness. The same idea is called fingerprinting when the new name is a digest of the file's content.

How it works

An HTTP cache chooses which stored response to serve using a cache key. A cache key is “composed from, at a minimum, the request method and target URI used to retrieve the stored response”. Because “many HTTP caches in common use today only cache GET responses”, they “therefore only use the URI as the cache key” (RFC 9111 section 2). Caches may add more to the key: request header fields named by Vary, or the referring site in a double-keyed browser cache. But the URI is always in it. While a stored response is fresh it is reused “without validation (checking with the origin server to see if the cached response remains valid for this request)” (RFC 9111 section 1). So to publish an update immediately, you change the URI. The new request no longer matches the old stored entry. The cache misses, and the request reaches the origin.

RFC 8246 section 1 describes this pattern directly. Some content providers use “versioned” URLs. The RFC explains: “when these resources need an update, they are simply published under a new URL, typically embedding an identifier unique to that version of the resource in the path, and references to the sub-resource are updated with the new path information.”

There are two common ways to change the URL:

  • Query string versioning, e.g. style.css?v=2. It is trivial to add by hand. But it only busts caches that include the query string in their cache key. That is configurable per vendor rather than guaranteed (see What CDNs do).
  • Filename (content) hashing, e.g. style.a1b2c3d4.css. The digest sits in the path, so the path itself changes. Any cache keyed on the URI then treats it as a different object. The digest is derived from the bytes. So the name changes when, and only when, the content changes. For example, webpack's [contenthash] “will add a unique hash based on the content of an asset”. Vite gives referenced assets hashed file names. And Rails fingerprinting “makes the name of a file dependent on its content”.

A build timestamp or build number also changes the URL, but it changes even when the file is byte-identical. So every deploy forces a re-download of assets that did not change. Only a content-derived name gives you the update without the waste.

Why it matters for a CDN

A CDN is a shared cache. It “stores responses for reuse by more than one user”. A browser is a private cache. It is “dedicated to a single user” (RFC 9111 section 1). That asymmetry is the whole reason cache busting exists. You can purge the shared copies. A Cloudflare single-file purge, for instance, removes a resource “across all data centers” (Cloudflare, purge by single-file). But you have no reach into your users' browsers at all. So after a deploy, “different users might end up using different versions of the file when the page is constructed: users who just fetched the resource use the new version, while users who cached an earlier (but still valid) copy use an older version of its response” (web.dev). Cache busting removes that window. As soon as the referencing document points at new hashed URLs, every user's next request is for a URL nobody has cached.

It also makes long caching safe. Because a hashed URL can never serve changed content, you can give it a large max-age in its Cache-Control header. RFC 8246 gives Cache-Control: max-age=31536000, immutable as its worked example. The immutable extension exists specifically for this pattern. A user agent cannot tell that a URL is versioned. So a reload still fires conditional requests that all come back 304. immutable “indicates that the origin server will not update the representation of that resource during the freshness lifetime of the response”, letting clients skip them (RFC 8246 section 2). Treat it as an optimisation rather than the mechanism. It “will be ignored in some browsers” (web.dev).

What CDNs do

Path changes always change the cache key. Query-string handling is the part that genuinely varies. That is why path-based hashing is the portable choice:

  • Cloudflare: the default cache key includes the “URI with query string”. So ?v=2 busts by default. A “Cache Level of Ignore Query String creates a Cache Key that includes all the elements in the default cache key, except for the query string in the URI”. That would defeat query-string busting. Note the per-plan split: Ignore query string and Sort query string are available on Free through Enterprise. Choosing which parameters enter the key is Enterprise-only (Cloudflare, cache keys).
  • Fastly: “By default, Fastly uses the URL and the Host of a request (plus a special, internal Fastly variable for purging purposes) to create unique HTTP objects”. So the query string is in the key. Removing it is an explicit opt-in cache-key change: hashing on req.url.path, req.http.host instead (Fastly, manipulating the cache key).
  • CloudFront: query-string forwarding and caching are set per cache policy. The default is not on your side. The AWS-managed CachingOptimized policy, the usual choice for static assets, is documented as including “Query strings included in the cache key: None” (AWS, managed cache policies). So ?v=2 does not bust it. Where you do cache on query strings, CloudFront treats parameter order and letter case as distinct. It caches “two separate versions of the object” for reordered parameters (AWS, cache based on query strings).
  • Nginx can act as a reverse-proxy cache, often the layer sitting behind or beside the CDN. The documented default is proxy_cache_key $scheme$proxy_host$request_uri;. So the query string is part of the key unless someone overrides it (nginx docs).

Watch out for

  • Query-string busting fails silently when any cache on the path ignores or normalises the query string: a Cloudflare Ignore Query String cache level, a CloudFront CachingOptimized policy, a custom Fastly key built from req.url.path. You ship, the origin has the new file, and the edge keeps serving the old one. Path-based hashing cannot fail this way.
  • The hash must come from the content. Hashing a build timestamp or CI build ID busts every asset on every deploy, unchanged ones included. You pay full re-download cost for nothing.
  • You cannot cache-bust the document that references the assets. HTML files are “(almost!) never going to include versioning information, since no one will bother to use your web app if they need to remember that the URL to visit is https://example.com/index.34def12.html” (web.dev). So the entry document needs a short freshness lifetime or no-cache. That directive “instructs the browser that it must revalidate with the server every time before using a cached version of the URL”. Cache the HTML for a day and your users keep asking for last week's hashes.
  • Deleting the previous build's hashed files can break users who are still on the old HTML. Vite documents exactly this: “When a new deployment occurs, the hosting service may delete the assets from previous deployments”. So a visitor from before the deploy hits an import error for a chunk that no longer exists. Vite's guidance is to “make sure to set Cache-Control: no-cache on the HTML file, otherwise the old assets will be still referenced” (Vite, load error handling).
  • immutable “only applies during the freshness lifetime of the stored response” (RFC 8246 section 2). It suppresses revalidation, but it never moves anyone onto a new version. Only a fresh referencing document that names the new URL does that.
  • Old hashed URLs are never requested again, so nothing prompts a cache to remove them. They age out or get evicted on the cache's own terms. If you need them gone sooner, purge is a per-object operation. If you have added anything to the cache key, you “will need to send a purge for each combination of the URL and value you add” (Fastly).

Best practice

  • Use content-hash filenames for anything that can change. Never reuse a filename for different bytes.
  • Serve hashed assets with a one-year max-age and, as an optimisation, immutable. Serve the referencing document with a short TTL or no-cache so the new names are picked up on the next visit.
  • Do not rely on query-string busting through any cache whose key you do not control or cannot inspect. Path hashes need no vendor cooperation.
  • Let the bundler or asset pipeline generate the hashes: webpack, Vite, esbuild, Rails. Do this instead of hand-maintained version numbers that someone will forget to bump.
  • Keep the previous build's hashed assets served for a while after a deploy. Then clients still running the old document do not fail on chunks you have already deleted.
  • Pair the strategy with your cache-control policy. Keep targeted purge of specific URLs for the cases cache busting cannot cover, such as the HTML itself.

Examples

Build tools add a content hash to each file name:

# Webpack output
style.a1b2c3d4.css   # hash changes when content changes
app.e5f6g7h8.js

# HTML references the hashed filename
<link rel="stylesheet" href="/static/style.a1b2c3d4.css">

# Cache-Control for hashed assets: cache forever
Cache-Control: public, max-age=31536000, immutable

Nginx sets a different policy per file type:

# Hashed assets: cache for 1 year
location ~* \.[a-f0-9]{8}\.(css|js|png|woff2)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

# HTML: short cache, always revalidate
location ~* \.html$ {
    add_header Cache-Control "public, max-age=60, must-revalidate";
}

Frequently Asked Questions

Giving a web asset a new URL whenever its content changes, so every cache treats it as a new object and fetches it. It deletes nothing: a version query string (?v=2) or, more reliably, a content hash in the filename (style.a1b2c3.css) simply stops matching the old cache entry.

Build tools add a content hash to each file name:

# Webpack output
style.a1b2c3d4.css   # hash changes when content changes
app.e5f6g7h8.js

# HTML references the hashed filename
<link rel="stylesheet" href="/static/style.a1b2c3d4.css">

# Cache-Control for hashed assets: cache forever
Cache-Control: public, max-age=31536000, immutable

Nginx sets a different policy per file type:

# Hashed assets: cache for 1 year
location ~* \.[a-f0-9]{8}\.(css|js|png|woff2)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

# HTML: short cache, always revalidate
location ~* \.html$ {
    add_header Cache-Control "public, max-age=60, must-revalidate";
}

Yes. Cache Busting is also known as Fingerprinting. Giving a web asset a new URL whenever its content changes, so every cache treats it as a new object and fetches it. It deletes nothing: a version query string (?v=2) or, more reliably, a content hash in the filename (style.a1b2c3.css) simply stops matching the old cache entry.

Related CDN concepts include:

  • Cache Key — The cache key is the identifier a cache derives from a request to decide which …
  • max-age — max-age is a Cache-Control response directive giving the seconds a stored response may be reused …