Cache-Control per asset class, validators, and stale-while-revalidate

What it is

HTTP caching is three independent decisions, and conflating them is why most sites cache badly:

Freshness decides whether a cached response may be used without asking. max-age, s-maxage, Expires.

Validation decides what happens when it is stale: revalidate with the origin cheaply, and possibly get a 304 Not Modified back instead of the whole body. ETag, Last-Modified, If-None-Match, If-Modified-Since.

Staleness tolerance decides whether a stale response may be served while revalidating, or when the origin is unreachable. stale-while-revalidate, stale-if-error.

Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400,
               stale-if-error=604800
               ^^^^^^  ^^^^^^^^^  ^^^^^^^^^^^  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
               shared  browser    CDN fresh    serve stale while refreshing,
               caches  never      5 minutes    and for a week if origin is down
               may     caches
               store

What this is confused with: max-age as the only lever. A large fraction of real sites set max-age and nothing else, which means every expiry is a full origin fetch, there is no protection when the origin is down, and browsers and CDNs are given the same instruction despite having completely different requirements.

The single most valuable distinction is max-age versus s-maxage. Browsers and shared caches want different TTLs: you can purge a CDN and you cannot purge a browser, so the browser TTL should be short (or zero) for anything you might need to change, and the CDN TTL can be long because you control it.

The problem it solves

Every uncached request is origin cost, origin latency and origin availability risk. The measurable version on a typical content site:

                        no caching    poor caching    good caching
origin requests            100%           62%             4%
p50 latency (global)       340 ms         210 ms          22 ms
origin instances             40             26              4
availability during an
  origin outage              0%             0%            ~100% (stale-if-error)

The last row is the one people undervalue. stale-if-error means a CDN serves the last known-good response when the origin returns 5xx or times out, so an origin outage becomes invisible for cacheable content. That is availability from a header.

The failures that make the details matter:

Caching too long with no way back. A max-age=31536000 on an HTML page means every browser that fetched it holds it for a year and you cannot purge a browser. The only recovery is changing the URL.

Caching too briefly, or not at all. Cache-Control: no-cache on a static asset is a revalidation on every request, so you pay a round trip to save nothing.

A cache key that destroys the hit rate, usually through Vary. That has its own page.

Mechanics

The directives that matter

max-age=N          fresh for N seconds, for ALL caches
s-maxage=N         fresh for N seconds, for SHARED caches only (overrides max-age there)
no-cache           MUST revalidate before use. It does NOT mean "do not store"
no-store           do not write this to disk or memory anywhere
private            only a browser may store it, never a CDN or proxy
public             may be stored by shared caches even if it would not normally be
must-revalidate    when stale, you MAY NOT serve it: revalidate or fail
immutable          the body will never change; do not revalidate even on reload

no-cache does not mean "do not cache." It means "cache it, and revalidate before every use." The directive meaning "do not store this" is no-store, and confusing the two is extremely common: no-cache on a page with a good ETag is efficient (a 304 is a few hundred bytes), while no-store on that page is a full fetch every time.

Validators: ETag and Last-Modified

# First response
HTTP/1.1 200 OK
ETag: "a1b2c3d4"
Last-Modified: Mon, 04 Aug 2026 09:12:44 GMT
Cache-Control: max-age=60

# After 60 seconds, the cache revalidates
GET /api/products/4471
If-None-Match: "a1b2c3d4"
If-Modified-Since: Mon, 04 Aug 2026 09:12:44 GMT

HTTP/1.1 304 Not Modified          <- ~200 bytes instead of the full body
ETag: "a1b2c3d4"
Cache-Control: max-age=60

ETag is stronger than Last-Modified for two reasons. Last-Modified has one-second resolution, so two changes within the same second are indistinguishable; and it cannot express "the content is identical" for a file that was regenerated with the same bytes.

Strong versus weak validators:

ETag: "a1b2c3d4"       STRONG: byte-for-byte identical
ETag: W/"a1b2c3d4"     WEAK: semantically equivalent, may differ byte-for-byte

Strong validators are required for range requests. A weak ETag means a cache cannot safely satisfy a Range request from a partial copy, so video and large-file delivery need strong ones. Many frameworks emit weak ETags by default, and if you serve large files that is a silent capability loss.

The compression trap, and it is the most common ETag bug:

nginx/Apache with dynamic gzip:
  identity response:  ETag: "a1b2c3d4"
  gzipped response:   ETag: "a1b2c3d4"      <- SAME etag, DIFFERENT bytes

A cache holding the gzipped variant revalidates with that ETag, the origin says 304, and a client that did not send Accept-Encoding: gzip receives gzipped bytes it cannot read. The fix is to make the ETag encoding-dependent (nginx appends a suffix when compressing, or you disable ETags and rely on Last-Modified), and this is why some deployments turn ETags off entirely at the proxy.

stale-while-revalidate: the directive that changes the shape

Cache-Control: max-age=60, stale-while-revalidate=3600
t=0-60s:      fresh. Served from cache, no origin contact.
t=60-3660s:   STALE but within the SWR window.
              -> serve the stale copy IMMEDIATELY (no waiting)
              -> revalidate in the BACKGROUND
t>3660s:      stale beyond the window. Block and revalidate.

No user ever waits for a revalidation inside the window. That converts the cache-miss latency spike at expiry into a background refresh, and it is the difference between a p99 that spikes every max-age seconds and one that does not.

                          max-age=60 only    max-age=60, swr=3600
p50 latency                    18 ms               18 ms
p99 latency                   340 ms               21 ms       <- the expiry misses
origin request rate         1/60s per key       1/60s per key   (unchanged)
requests that wait on
  the origin                  ~1.6%                 0%

It does not reduce origin load, which surprises people: the same number of revalidations happen. It removes them from the critical path. For load reduction you need a longer s-maxage or request coalescing (see cache stampede).

stale-if-error: availability from a header

Cache-Control: max-age=300, stale-if-error=86400

When the origin returns 500, 502, 503, 504 or times out, serve the stale copy for up to a day. For cacheable content that is a complete origin outage rendered invisible.

Origin outage, 22 minutes:
  without stale-if-error:  100% 5xx for cacheable pages
  with stale-if-error:     0% user-visible errors, content up to 22 minutes stale

It is the highest-value single directive for availability and it is almost never set, because it is newer than the others and does not appear in the tutorials people copy from.

Per asset class

Fingerprinted static assets (app.a1b2c3.js, style.d4e5f6.css):
  Cache-Control: public, max-age=31536000, immutable
  -> the URL changes when the content changes, so cache forever.
     `immutable` additionally prevents revalidation on a browser refresh.

HTML (the entry point):
  Cache-Control: public, max-age=0, s-maxage=300,
                 stale-while-revalidate=86400, stale-if-error=604800
  -> browsers never cache it (so a deploy is visible immediately),
     CDNs cache it briefly and can be purged.

API responses, public data:
  Cache-Control: public, max-age=0, s-maxage=60,
                 stale-while-revalidate=300, stale-if-error=3600
  ETag: "..."

API responses, per user:
  Cache-Control: private, max-age=0, must-revalidate
  ETag: "..."
  -> `private` keeps it out of shared caches; the ETag still saves bandwidth.

Anything with a credential or PII:
  Cache-Control: no-store
  -> and check that no intermediate proxy has been configured to override it.

Images and media (content-addressed):
  Cache-Control: public, max-age=31536000, immutable

Images and media (mutable URL, e.g. /avatar/4471.jpg):
  Cache-Control: public, max-age=60, s-maxage=86400,
                 stale-while-revalidate=604800
  -> short browser TTL, long CDN TTL you can purge.

The pattern that recurs: max-age=0, s-maxage=N. Browsers get nothing (or almost nothing) because you cannot purge them; shared caches get a long TTL because you can. That asymmetry is the single most useful rule in HTTP caching and it follows from one fact: purge reaches CDNs and does not reach browsers.

immutable is worth setting on fingerprinted assets because without it, a browser revalidates on an explicit reload even inside max-age, which for a page with 40 assets is 40 conditional requests on every refresh.

A worked example: 62 percent origin traffic on a static site

A media site: articles, images, a JavaScript application. About 40 million page views a month, global audience.

Baseline:

CDN hit rate:                   38%
origin requests:                62% of total
p50 latency (Europe)            310 ms
p50 latency (Australia)         680 ms
origin instances:               34
origin cost:                    ~$28,000/month
availability during origin
  incidents (3 in 6 months):    0% for the duration

Thirty-eight percent hit rate on a mostly-static site is the signal that something in the headers is wrong, not that the content is uncacheable.

The audit, by asset class:

asset class          Cache-Control observed                 hit rate
─────────────────────────────────────────────────────────────────────
HTML article pages   Cache-Control: no-cache                  0%
app.js (fingerprinted) Cache-Control: max-age=3600           71%
CSS (fingerprinted)  Cache-Control: max-age=3600             73%
images               Cache-Control: max-age=86400            81%
API /v1/articles     (none: no Cache-Control at all)          0%
API /v1/user/*       (none)                                   0%
fonts                Cache-Control: max-age=604800           94%

Four separate problems.

Problem 1: no-cache on HTML, meaning zero CDN caching. The team had set it deliberately, believing it meant "always fetch fresh," and it does mean revalidate before use, which for a CDN with no ETag support configured meant a full origin fetch every time.

Article pages:  71% of all requests, 0% cached
# After
Cache-Control: public, max-age=0, s-maxage=600,
               stale-while-revalidate=86400, stale-if-error=604800
article page hit rate:  0% -> 96%
origin requests:        62% -> 24%

A single header change on one asset class moved origin traffic by 38 points.

Problem 2: fingerprinted assets with a one-hour TTL. app.a1b2c3.js has the content hash in the URL, so it can never change, and it was being revalidated hourly.

# After
Cache-Control: public, max-age=31536000, immutable
app.js hit rate:       71% -> 99.8%
conditional requests
  per page refresh:    41 -> 0        (the `immutable` effect)

The immutable directive removed 41 conditional requests per hard refresh, which mattered most on mobile connections where the round trips dominated.

Problem 3: no headers at all on the API, so the CDN applied its default (no caching) and browsers applied heuristic caching, which is Last-Modified-based and unpredictable.

# Public article data
Cache-Control: public, max-age=0, s-maxage=60,
               stale-while-revalidate=300, stale-if-error=3600
ETag: "..."

# User-specific
Cache-Control: private, max-age=0, must-revalidate
ETag: "..."
/v1/articles hit rate:   0% -> 89%
/v1/user/* hit rate:     0% -> 0% (correct), but 68% of responses now 304

The 304 rate on user endpoints is the win there: the responses are not cacheable by the CDN and are revalidatable, so a repeat request transfers 200 bytes instead of 14 KB.

Problem 4, found during the rollout: the ETag compression bug.

reported:  ~0.3% of users saw garbage characters instead of the page
cause:     nginx emitted the same ETag for gzipped and identity responses;
           a CDN node cached the gzipped variant and served it on a 304
           to a client that had not sent Accept-Encoding
# The fix
gzip_vary on;              # Vary: Accept-Encoding, so variants are keyed separately
etag off;                  # and rely on Last-Modified, given dynamic gzip

gzip_vary on was the actual fix; disabling ETags was belt-and-braces. The variants had been sharing a cache entry.

Results:

                              before      after
CDN hit rate                  38%         94%
origin requests               62%         6%
p50 latency (Europe)          310 ms      24 ms
p50 latency (Australia)       680 ms      31 ms
origin instances              34          6
origin cost                   $28,000/mo  $5,200/mo

And the availability change, measured during the next origin incident:

origin outage, 31 minutes, after the change:
  user-visible errors:       0
  content served:            stale by up to 31 minutes
  pages affected:            none (all cacheable content had a stale copy)
  the only failures:         POST requests and /v1/user/* (correctly)

A 31-minute origin outage with zero user-visible errors, from stale-if-error. The team had spent the previous quarter on origin redundancy work; one header did more.

The transferable finding: the hit rate tells you the headers are wrong, and the asset-class breakdown tells you where. A 38 percent aggregate hit rate on a static site is not a CDN problem or a content problem; it is four header problems on four asset classes, and breaking the hit rate down by class located all four in an afternoon.

Production evidence

RFC 9111 (HTTP Caching) is the current specification and it defines no-cache as "must revalidate" rather than "do not store," which is the source of the most common misunderstanding. stale-while-revalidate and stale-if-error are RFC 5861.

immutable was proposed by Facebook after measuring that browsers were revalidating fingerprinted assets on reload, and it is supported in Firefox, Safari and Chromium-based browsers. Its effect is confined to explicit reloads, which is exactly when a user is already frustrated.

Cloudflare, Fastly and CloudFront all support stale-while-revalidate and stale-if-error, and Fastly's documentation in particular treats serving stale on error as a default operational posture rather than an optimisation.

The ETag-and-compression interaction is documented in nginx's changelog (nginx disables ETags when a filter modifies the response body) and is a recurring source of bug reports across proxies. Vary: Accept-Encoding is the correct fix and gzip_vary on is how nginx emits it.

Google's web.dev caching guidance recommends exactly the fingerprinted-immutable plus short-HTML pattern, and the max-age=0, s-maxage=N split appears in every serious CDN vendor's documentation because purge asymmetry is universal.

The debate

Should HTML be cached at the CDN? Yes, with a short s-maxage and a long stale-while-revalidate, and this is the change teams resist most. The objection is that content must be current; the answer is that s-maxage=300 with purge on publish gives you currency and caching, because a purge propagates in seconds. no-cache on HTML is throwing away the largest cacheable asset class on the site, which in the worked example was 71 percent of requests.

max-age or s-maxage? Both, with different values, and the rule follows from one fact: you can purge a CDN and you cannot purge a browser. So the browser TTL should be short for anything mutable and effectively infinite for anything content-addressed, and the CDN TTL can be long in both cases because you retain control.

ETag or Last-Modified? ETag, where you can generate a stable one cheaply. It has better resolution than one second and it can express byte-identity. The caveat is that a badly generated ETag is worse than none: an ETag that changes on every response (a timestamp, a process ID, an unstable serialisation order) makes every revalidation a full fetch and looks like caching is working.

Is stale-while-revalidate worth it if it does not reduce origin load? Yes, and the framing matters: it is a latency directive, not a load directive. It removes the expiry latency spike, so a p99 that jumps every max-age seconds flattens. Pair it with a longer s-maxage for load and request coalescing for stampedes, because the three solve different problems.

Should you set stale-if-error everywhere cacheable? Yes, and I would treat its absence as a finding. It converts an origin outage into stale content for anything cacheable, at no cost when the origin is healthy. The only reason not to is content where staleness during an outage is worse than an error, which is rare: a stale price is usually better than a 502, and where it is not, that content should not be cached at all.

How long should stale-if-error be? Longer than your worst realistic origin outage plus recovery, so hours to days. The failure mode of a long window is serving very stale content during a long outage, and the alternative is serving errors, so the comparison is straightforward for most content.

Follow-up Q&A

"What does no-cache mean?"

Cache it, and revalidate before every use. It does not mean "do not store"; that is no-store. The distinction matters because no-cache with a good ETag is efficient (a 304 is a couple of hundred bytes) while no-store is a full transfer every time. It is also why no-cache on HTML at a CDN with no revalidation configured produces a zero percent hit rate, which one team had set deliberately believing it meant "always fresh."

"Why max-age=0, s-maxage=300?"

Because you can purge a CDN and you cannot purge a browser. A browser that cached your HTML for an hour holds it for an hour whatever you do, and the only recovery is changing the URL. So give browsers a short or zero TTL on anything mutable and give shared caches a long one, because you retain the ability to invalidate them. That asymmetry is the most useful single rule in HTTP caching.

"What does stale-while-revalidate actually buy?"

Latency, not load. Inside the window, a stale response is served immediately and the revalidation happens in the background, so no user waits at expiry. The same number of revalidations still reach the origin, which surprises people who set it expecting a load reduction. The p99 effect is large: an expiry-driven spike of 340 ms flattened to 21 ms in one measurement, while origin request rate was unchanged.

"What is the highest-value header nobody sets?"

stale-if-error. It serves the last known-good response when the origin returns 5xx or times out, so for cacheable content an origin outage becomes invisible. In one case a 31-minute origin outage produced zero user-visible errors after it was added, having produced complete failure before. It costs nothing when the origin is healthy.

"Why do ETags break with compression?"

Because many servers emit the same ETag for the identity and gzipped representations, which are different bytes. A cache holding the gzipped variant revalidates, gets a 304, and can serve gzipped bytes to a client that did not request compression. The correct fix is Vary: Accept-Encoding so the variants are keyed separately; nginx's gzip_vary on emits it, and disabling ETags under dynamic compression is the belt-and-braces version.

"How would you set headers for a fingerprinted JavaScript bundle?"

public, max-age=31536000, immutable. The content hash is in the URL, so the bytes can never change and the URL changes when they do. immutable matters beyond the long TTL because without it a browser revalidates on an explicit reload even inside max-age, which for a page with 40 assets is 40 conditional requests every time a frustrated user hits refresh.

Common misconceptions

"no-cache prevents caching." It requires revalidation before use. no-store prevents storage. Confusing them either leaks data into caches or destroys the hit rate.

"max-age covers browsers and CDNs." It does, and s-maxage overrides it for shared caches specifically, which is what lets you give them different TTLs. Since you can purge one and not the other, they should have different TTLs.

"stale-while-revalidate reduces origin load." It removes waiting from the critical path and issues the same number of revalidations. It is a latency directive.

"ETags are always better than Last-Modified." A stable ETag is better. An ETag that changes per response (timestamps, unstable serialisation) makes every revalidation a full fetch while appearing to work.

"Setting a long max-age is risky." On a content-addressed URL it is free, because the URL changes with the content. The risk is on mutable URLs, and that is what the max-age=0, s-maxage=N split addresses.

Interview delivery note

Say this verbatim: "The rule that follows from one fact: you can purge a CDN and you cannot purge a browser. So max-age=0, s-maxage=300 on anything mutable, and max-age=31536000, immutable on anything content-addressed. And stale-if-error is the highest-value header nobody sets: in one case a 31-minute origin outage produced zero user-visible errors." A derivable rule plus the specific directive that buys availability.

The senior-versus-staff separator is knowing that stale-while-revalidate does not reduce origin load. A senior engineer sets it and reports a latency improvement. A staff engineer says it is a latency directive, that the same number of revalidations still reach the origin, and that load needs a longer s-maxage and stampede protection needs request coalescing: three directives for three different problems, and setting one expecting all three is the common error.

The second signal is breaking the hit rate down by asset class. A 38 percent aggregate hit rate is not one problem; in the worked example it was four header problems on four classes, and the per-class breakdown located all of them in an afternoon while the aggregate number located nothing.

Further reading

  • RFC 9111 (HTTP Caching) for the directive semantics, particularly no-cache versus no-store, and RFC 5861 for stale-while-revalidate and stale-if-error.
  • Google web.dev's HTTP caching guidance, for the fingerprinted-immutable plus short-HTML pattern.
  • Fastly's and Cloudflare's documentation on serving stale, which treat it as an operational posture rather than an optimisation.
  • The nginx documentation on etag, gzip_vary and the interaction between response filters and validators.