skip to content

When hosting static assets behind a CDN, how do Cache-Control headers and versioned (content-hashed) URLs work together to let you cache aggressively while still rolling out updates safely?

level: middleimportance: must knowfreq 80%

answer

  1. content-hash filenames
  2. immutable long max-age for hashed assets
  3. no-cache on entry HTML
  4. s-maxage vs max-age
  5. purge as fallback not primary strategy

basics

~20 s

Cache-Control tells browsers and the CDN how long to keep a file before checking again. Putting a unique hash in the filename means a changed file gets a brand-new URL, so you can cache old URLs forever without ever serving stale content under a new version.

solid answer

~50 s

Cache-Control headers (max-age, s-maxage, immutable, no-cache) set the caching policy per response — how long a browser or CDN edge can serve a cached copy without revalidating with origin. For assets that never change once published, you want the longest possible cache lifetime, but that's only safe if the URL itself changes whenever the content changes — typically by embedding a content hash or version string in the filename. That way stale cache is a non-issue: the old URL's content genuinely never changes, so caching it forever is correct, and a new deploy is just a new URL that starts fresh at the edge. The one file that must stay uncached or short-TTL is the entry document (e.g. index.html) that references those hashed filenames, since it's the pointer that has to update on every deploy.

go deeper

for a junior

Should know that a Cache-Control header controls how long something is cached and that changing a filename is one way to force a fresh copy.

for a middle

Should describe the hashed-filename-plus-long-TTL pattern and know that the referencing HTML/manifest needs to stay uncached.

for a senior

Should articulate the max-age vs s-maxage split, the reasoning that immutable is only safe when the URL is provably tied to content, and diagnose staleness bugs from a described symptom.

for a principal

Should reason about this as the core mechanism that makes CDN caching correctness-safe by construction rather than TTL-tuning-dependent, and discuss how it interacts with rollback (old hashed assets must remain available) and multi-region purge propagation delay.

## What Cache-Control tells the chain `Cache-Control` is an HTTP response header that tells every cache in the chain — the browser's local cache, any CDN edge PoP, and intermediate proxies — how to treat a given response. The relevant directives for static hosting are: | Directive | What it does | |---|---| | `max-age` | how many seconds a cache may serve the response without revalidating | | `s-maxage` | the same but specifically for shared/CDN caches, letting you set a different TTL for the CDN than for browsers | | `no-cache` | must revalidate with origin before reuse | | `no-store` | never cache at all | | `immutable` | tells the browser the resource will never change for the lifetime given, so it won't even issue a conditional revalidation request on things like reload | Origin attaches these headers to each file, and the CDN honors them when deciding how long to keep its edge copy before treating it as stale. ## The deployment problem The problem this alone doesn't solve is deployment: if a file is served at a fixed URL like `/app.js` and you set a one-year `max-age` on it, then ship a bug fix, every cache holding the old copy will keep serving the buggy version for up to a year. Shortening the TTL doesn't really fix this either; it just trades staleness risk for cache-effectiveness (constant revalidation traffic to origin, undermining the whole point of the CDN). **The actual solution is to change the URL whenever the content changes.** Build tooling (webpack, Vite, esbuild) computes a content hash of each output file and bakes it into the filename — `app.3f2a1c9.js` — so any change to the file's bytes produces a different filename. Because that URL is now permanently tied to one specific, immutable set of bytes, it becomes safe, even correct, to cache it as aggressively as possible with a long, `immutable` `Cache-Control`. There is no scenario in which that URL's content changes, so there's no staleness risk to guard against. ## The one file that can't follow the rule That leaves one file that can't follow this rule: whatever document references the hashed filenames, typically `index.html` or a generated asset manifest. That file's content must change on every deploy, so it's given `no-cache` (always revalidate) or a very short `max-age`, ensuring browsers and edge caches check back on every load or near-every load. This produces a clean split: - a small, cheap-to-revalidate **entry point** that's always fresh, - fronting a large body of **hashed assets** that can be cached essentially forever. The trade-off is entirely in that split — you're accepting slightly slower delivery of one small file in exchange for maximal, risk-free caching of everything else, which for a typical app is 95%+ of total byte volume. ## Failure modes Failure modes cluster around getting this split wrong. - **A team forgets to hash a frequently-changing asset** (e.g. a logo file with a fixed name but aggressive `max-age`): updates to it simply won't show up for users with a warm cache until the TTL lapses — a classic 'why isn't my change showing up' bug that's often mistakenly fixed by manually purging the CDN rather than fixing the caching strategy. - **Conversely, the entry HTML is accidentally cached long-term** (e.g. a misconfigured CDN rule that overrides origin-set `Cache-Control`): users can get stuck on an old version indefinitely, referencing hashed asset URLs that may have been cleaned up from storage, producing 404s on chunk loads. Another subtlety is that `s-maxage` and `max-age` diverging is intentional and useful — you might want a CDN to hold a copy for a week while browsers only trust their local copy for an hour, so a CDN-level purge propagates to end users faster than waiting out browser cache lifetimes, since the CDN is the layer you control directly. ## Where it shows up A concrete real-world instance of this is any modern SPA/JAMstack deployment pipeline — e.g. a Next.js or Vite build publishing hashed static asset output to a CDN with a one-year `immutable` `Cache-Control`, while the HTML shell or a manifest file is served with `no-cache`, so each deploy is visible to users essentially immediately without any purge step being required at all.

  • Why set a different s-maxage on the CDN than max-age for browsers, rather than the same value for both?
    The CDN is infrastructure you control and can purge directly, so it's safe to trust it for longer; browsers are millions of independent caches you can't reach, so a shorter browser TTL bounds the worst-case staleness a user could ever see. This split lets you push a fix out to the CDN quickly via purge while still capping how long any individual browser could serve a stale copy even without a purge.
  • What happens if you set Cache-Control: immutable on a URL that is NOT actually content-hashed, e.g. a fixed /logo.png?
    You get exactly the staleness bug the pattern is meant to prevent: browsers and CDNs will refuse to even revalidate the file for the stated max-age, so updating the logo has no visible effect for any cached client until the TTL fully expires. immutable is only safe on URLs whose content is provably tied one-to-one with the URL, which is precisely what content hashing guarantees.
  • If a CDN purge is available, why not just rely on short TTLs and purge on every deploy instead of hashing filenames?
    Short TTLs mean constant revalidation traffic to origin from every edge PoP, undermining the bandwidth/cost benefits of caching in the first place, and a purge only clears the CDN layer — it can't reach already-cached copies sitting in users' browsers. Hashed filenames avoid both problems: caching stays maximally effective, and there's no stale browser cache to worry about because old URLs are simply never revisited.

Like giving every edition of a book a new ISBN instead of reprinting under the same ISBN — bookstores can stock the old edition forever without worrying it'll silently change, and readers just need to check the catalog (the entry page) for which ISBN is current.

saying these in an interview costs you the question

  • Suggests relying solely on manual CDN purges as the deploy strategy instead of hashed URLs
  • Doesn't distinguish CDN-facing (s-maxage) from browser-facing (max-age) cache lifetimes
  • Sets immutable/long TTL on a non-hashed, mutable filename
  • Forgets the entry document (HTML/manifest) needs a short or no-cache policy
  • Confuses no-cache with no-store

context