skip to content

Your team ships a new JS bundle under the same URL /app.js on every deploy, and users keep complaining they're stuck on stale versions after a release even though the file changed on the server. What CDN caching mistake is likely happening, and how would you fix Cache-Control and the deployment strategy to solve it for both this JS bundle and images that rarely change?

level: middleimportance: must knowfreq 80%

answer

  1. max-age vs s-maxage
  2. content-hashed filename = no purge needed
  3. no-cache still caches, just revalidates; no-store never caches
  4. immutable skips revalidation entirely
  5. stale-while-revalidate serves old while refetching in background

basics

~20 s

The file's cache setting is probably telling browsers and the CDN to keep the old copy for too long, and reusing the same filename gives no signal that anything changed. Fix: give changed files new filenames whenever the content changes (like app.abc123.js) and cache those forever, while keeping the URL that points to the latest version very short-lived.

solid answer

~40 s

The likely cause is a long max-age on a mutable URL — /app.js is being cached by browsers and CDN edges for hours, so old bytes keep being served after each redeploy. The standard fix is cache-busting via content-hashed filenames: emit app.<hash>.js and mark it Cache-Control: public, max-age=31536000, immutable, since the filename itself changes whenever the content does, removing any staleness risk. The entry point that references the hashed filename (index.html or a build manifest) gets a short or zero TTL, e.g. no-cache or max-age=0, must-revalidate, so clients always fetch the latest pointer, which then references the correctly-versioned, aggressively-cached asset. This pattern avoids purging for almost every deploy — you only ever need to force-refresh the small, cheap entry-point file, not the large asset.

go deeper

for a junior

Should know Cache-Control controls how long content is cached, and that changing max-age changes freshness.

for a middle

Should know the difference between max-age, s-maxage, no-cache, and no-store, and be able to design a cache-busting filename scheme.

for a senior

Should reason about purge propagation cost across a PoP fleet, choose between purge-based and version-based invalidation per content type, and use stale-while-revalidate to hide origin latency.

for a principal

Should design the end-to-end cache-control policy for an entire platform — tiered TTLs, tag-based bulk invalidation, and safe defaults that prevent accidental caching of private data — balanced against origin cost, staleness SLAs, and rollback speed.

## What Cache-Control declares `Cache-Control` is the HTTP response header the origin uses to tell every downstream cache — the browser, the CDN edge, and any intermediate shield — how long a response may be reused and under what conditions. The **key directives** are: | Directive | What it does | |---|---| | `max-age` | seconds a response is fresh for, honored by all caches including the browser | | `s-maxage` | overrides `max-age` specifically for shared/proxy caches like a CDN edge, letting you cache longer at the edge than in an individual browser | | `no-cache` | may be stored, but must be revalidated with the origin, typically via a conditional request, before each reuse | | `no-store` | must never be stored at all, used for genuinely sensitive responses | | `must-revalidate` | once stale, don't serve it even if the origin is unreachable — fail rather than serve outdated content | | `immutable` | a newer addition telling caches this exact URL will never change, so skip revalidation entirely even on a user-initiated refresh | Together these directives form a contract: the origin declares how stale a response is allowed to get before every cache layer must refresh it. ## Why the extra machinery exists The reason this machinery exists is that TTL-based expiry alone can't cover every situation. A long TTL is great for performance and origin offload, but it means an edit at the origin doesn't show up until the TTL lapses everywhere it's cached. Two complementary strategies solve this: 1. **Versioned (cache-busting) URLs** — embedding a content hash or build version in the filename, so the filename itself changes whenever the bytes do — sidestep the staleness problem entirely for build assets like JS/CSS/images: because a changed file is a brand-new URL, there's no old cached copy to invalidate, and the old URL's cached copy simply becomes irrelevant and ages out naturally. This is the standard pattern used by modern bundlers (webpack, Next.js, Vite) and is why static asset distribution through a CDN pairs so naturally with `immutable`, far-future `Cache-Control` headers — a year-long `max-age` is safe precisely because the URL is guaranteed content-addressed. 2. **Manual purge/invalidation** — when content isn't naturally versioned this way — a CMS-authored article page, for instance, still living at a stable canonical URL — purging is the tool. The one piece that can't be content-hashed, the entry point that references the current hash (an HTML page, a JSON manifest, or an app-shell file), instead gets a very short or zero TTL so that clients always discover the latest hash quickly, cascading the correct freshness down to a single small, cheap-to-refetch file. Both browsers and CDNs also support `stale-while-revalidate`, which lets a cache serve the (now slightly expired) cached copy immediately while asynchronously refetching a fresh copy in the background for the next request, hiding origin latency from the user at the cost of occasionally serving content a few seconds out of date. ## The central trade-off The central trade-off is between long TTLs plus explicit invalidation versus short TTLs: - **Long TTLs plus explicit invalidation** — fast for most requests, but stale until someone remembers to purge or the content is content-addressed. - **Short TTLs** — always close to fresh, but a constant trickle of origin traffic even when nothing changed, since every PoP re-checks or re-fetches far more often. Purging itself isn't free either: propagating an invalidation instruction to every edge PoP in a global fleet isn't instantaneous, and firing broad purges frequently is rate-limited and can trigger a correlated burst of cache misses back to origin. ## Failure modes - **The most common failure mode in practice** is exactly the one in this scenario: treating a mutable, frequently-changing URL as if it were `immutable` by giving it a long `max-age`, so the CDN and every user's browser happily keep serving bytes from three deploys ago. - **A more dangerous variant** of the same mistake is applying a public, long `max-age` to a response that actually contains personalized or sensitive data — a shared cache will then serve one user's private response to a different user requesting the same URL, an actual data leak rather than just a staleness bug. Both are fixed by matching the `Cache-Control` policy to how the URL actually behaves: `immutable` and long-lived only for genuinely content-addressed assets, short-lived or private for anything that varies per request or per user.

  • What's the difference between max-age and s-maxage in a Cache-Control header?
    max-age applies to every cache, including an individual user's browser; s-maxage overrides it specifically for shared/proxy caches like a CDN edge, letting you cache more aggressively at the edge (say, an hour) while still forcing a user's own browser to a shorter freshness window if desired.
  • Why is purging content across a CDN's edge network harder than just deleting one file?
    A purge instruction has to propagate to every PoP holding a copy, and that propagation isn't instantaneous, so there's a window where some regions serve stale content while others already have the fix; large CDNs offer both fine-grained URL purges and broader tag-based purges specifically to manage that cost and scope.
  • What does stale-while-revalidate=30 do differently from a plain max-age?
    It lets the CDN serve the stale cached copy immediately once it expires, while asynchronously refetching a fresh copy from origin in the background, so users never wait on a synchronous origin round trip — at the cost of occasionally serving content up to 30 seconds out of date.

Cache-Control is like the expiration date stamped on a milk carton at every store in a chain; instead of recalling every carton from every store the moment the recipe changes, a smart dairy just prints a new product code on the new batch, so old and new stock can coexist on the shelf without anyone confusing them.

saying these in an interview costs you the question

  • Thinks purging is instant and free at any scale
  • Doesn't know no-cache still allows storage (just requires revalidation) versus no-store which forbids storage entirely
  • Defaults to manual purging on every deploy instead of cache-busting filenames
  • Sets a long public max-age on a response containing per-user or personalized data
  • Confuses max-age with s-maxage or doesn't know a CDN can honor a different TTL than the browser

context