HTTP caching and CDNs — designing Cache-Control
Cache-Control accepts many keywords and lets you combine them freely, which makes it a header people write casually. But the way to choose has a pattern. Who can share this resource, can its content change, and is retention itself a problem? These three settle the values you should write.
What you'll learn in this chapter
- The difference between private and shared caches, and the directives that separate them
- The differences between
no-cacheandno-store, and betweenmax-ageands-maxage stale-while-revalidate, which decides behavior after expiry, andimmutable, which skips validation- Why
Varyis needed when the same URL returns different content - The procedure for deriving caching design from the nature of the resource
This chapter builds on the CDN discussion in Rendering patterns.
Who holds the cache
Between the request and the origin server, several places can hold a cache.
| Kind | Held by | Character |
|---|---|---|
| Private cache | Browser | For that one user. Invisible to anyone else |
| Shared cache | CDN, reverse proxy | Shared across many users |
This distinction is the starting point of any design. If you put a per-user response on a shared cache, you serve other people's data. That's the most serious incident a caching design can produce.
They also have different purposes. Private caches speed up return visits for that user; shared caches cut the number of requests reaching the origin. If you want to reduce origin load, the shared cache is the one that matters.
There is also a cache inside the origin server, used to reduce database queries. In this diagram, that lives between Origin server and Database. It's a different layer with different mechanics; that one is covered by Scaling directions.
Fresh and stale
A cached response has an expiry for "how long to treat as fresh".
- fresh — within its lifetime. Serve it without asking the origin
- stale — past its lifetime. As a rule, ask the origin before using it
Once stale, the cache isn't thrown away immediately. Through revalidation, it can ask the origin "has it changed?"
# Cache-side inquiry
GET /api/recipes/42 HTTP/1.1
If-None-Match: "abc123"
# Body omitted if unchanged
HTTP/1.1 304 Not Modified
The exchange using ETag or Last-Modified doesn't transfer the body, so it's cheap. But the round trip to origin still happens; it's not zero.
Grouping directives by role
The values of Cache-Control are easier to think about when grouped into four roles.
1. Who can store it
| Directive | Meaning |
|---|---|
public | Shared caches may store it |
private | Only private caches may store it. CDNs must not |
no-store | No cache may store it at all |
2. How long it counts as fresh
| Directive | Meaning |
|---|---|
max-age=N | Fresh for N seconds after generation. Applies to all caches |
s-maxage=N | Fresh window on shared caches. Private caches ignore it. Overrides max-age on shared caches |
max-age measures elapsed time not "since the response was received" but "since it was generated at the origin". If a response served with max-age=60 had been sitting in the CDN for 50 seconds, the browser sees only 10 seconds left. It expresses a duration, not a wall-clock time. That distinction is covered by Datetimes and time zones.
3. What to do after expiry
| Directive | Meaning |
|---|---|
must-revalidate | After expiry, revalidate every time. If the origin is unreachable, return 504 |
stale-while-revalidate=N | For N seconds after expiry, serve stale while revalidating in the background. Applies to every cache |
stale-while-revalidate is a mechanism for erasing wait time. Even requests hitting the moment of expiry are served the stale content immediately, and the update runs in the background. Subsequent requests get the new content. It's the same idea as the ISR pattern from Rendering patterns: hit stale once, so nobody waits.
4. Whether validation is required
| Directive | Meaning |
|---|---|
no-cache | May be stored, but must be revalidated on every reuse |
immutable | Promises the content won't change while fresh. Validation can be skipped |
Two commonly misread pairs
no-cache and no-store
The names don't line up with reality.
| Storage | Reuse | |
|---|---|---|
no-cache | Yes | Only after revalidation |
no-store | No | Not possible |
no-cache does not mean "do not cache". Storage is allowed; the instruction is to check with the origin every time before using it. If nothing changed, a 304 comes back and the body isn't transferred. It fits the requirement "don't give up speed, but never serve stale content".
no-store truly retains nothing. Use it when the presence of data on the device or an intermediary is itself a problem — personal information, payment data, etc.
max-age and s-maxage
The division of labor is clear.
Cache-Control: public, s-maxage=60, max-age=0
This says "CDN may serve as-is for 60 seconds. The browser must ask the CDN every time". Requests to the origin drop to at most one per 60 seconds, and each user sees content at most 60 seconds behind.
Reversed to max-age=60, s-maxage=0, the CDN checks origin every time and only the browser caches for 60 seconds. Origin load barely drops. If you want the shared cache to work, use s-maxage.
Vary — when the same URL returns different content
Cache-Control decides whether a response may be stored and how long it stays fresh. It does not decide which requests may reuse that stored response. That is what Vary is for.
A shared cache keys responses by URL. If the same URL returns different content depending on a request header, name that header in Vary. The cache then keeps a separate entry per value of the headers you listed.
Vary: Accept-Encoding, Accept-Language
Drop it and a response built for one set of conditions gets handed to a request with different ones: a compressed body reaching a client that can't decompress it, or a Japanese page returned for an English request. The reason Origin and CORS needs Vary: Origin when the allow header is chosen from Origin is the same one — forget it and the CDN hands out another origin's permission.
Responses whose content differs per user belong outside the shared cache via private, not Vary: Cookie. Vary: Cookie creates an entry per cookie value, so the shared cache almost never hits.
Design from the nature of the resource
These three questions tell you what to write.
Three representative types:
| Type | Example | Design | Reason |
|---|---|---|---|
| Static file with a content hash | app-a3f8c2.js | public, max-age=31536000, immutable | If content changes, the URL changes. The same URL always has the same content, so even validation is unnecessary |
| Shareable API with a freshness requirement | Product info, article body | public, s-maxage=<seconds>, stale-while-revalidate=<seconds>, max-age=0 | Absorb on the CDN to protect origin, and catch up on the specified interval. stale-while-revalidate applies to the browser too, so for that many seconds the local stale copy is what appears |
| Per-user data | My-page, favorites | private, no-cache | Must not sit on shared cache. Must always be current. What counts as per-user is most often decided by cookies |
Why one year for hashed files?
31536000 seconds is 365 days. If your build changes the filename hash on every build (cache busting), the content at a given URL will never change again. So the expiry can be maxed out.
Adding immutable on top skips validation even when the user reloads. Without it, using something short like max-age=14400 (4 hours) undoes the point of the hash. Unnecessary validation runs every 4 hours, and the cache expires regardless of your deploy cadence.
Why add max-age=0?
In public, s-maxage=60, max-age=0, the max-age=0 is there to keep the browser from holding its own freshness lifetime.
The reason isn't wait time. max-age measures elapsed time since generation, so the time a response sat on the CDN is subtracted from the browser's remaining time (see RFC 9111 §4.2: response_is_fresh = (freshness_lifetime > current_age)). Allowing the browser 60 seconds too doesn't turn the lag into 120 seconds.
The problem is that you can't take it back. You can purge the CDN's cache yourself, but once a copy is on a user's device, it can't be recalled. If you accidentally serve wrong content, fixing the CDN still leaves you unable to reach anyone for those 60 seconds. With max-age=0 alone, users always see the CDN's current state. The response from the CDN is fast, so the felt speed doesn't drop. Add stale-while-revalidate, though, and the browser keeps a window of that many seconds in which it may serve stale content.
Common misconceptions
"no-cache forbids caching." — It doesn't. Storage is allowed; revalidation is required on every reuse. What forbids storage is no-store.
"Writing s-maxage also affects the browser." — It doesn't. Private caches ignore s-maxage. What controls the browser is max-age.
"Even with private, CDNs can cache it." — They can't. private forbids storing on shared caches. Writing private when you want the CDN to accept it is a contradiction.
"Even hashed files should have a short max-age." — Not needed. The URL identifies the content; there's no reason to expire. The idiom is to add immutable and skip validation too.
"With stale-while-revalidate, stale content isn't returned." — Reversed — it's the mechanism that deliberately returns stale content. It serves it while updating behind. The limit on tolerable staleness is set as a seconds value.
Exercises
Q1. Three incident reports arrive. Where is the cause in each?
Report 1: "I logged out but another user's my-page was shown to me."
Report 2: "I deployed a fix to the CSS, but some people still see the old look."
Report 3: "I edited an article and purged the CDN, but some people still see the old version an hour later."
Answer and explanation
Answers:
| Report | Where the cause is |
|---|---|
| 1 | A per-user response has been placed on a shared cache |
| 2 | Content changed but the URL didn't, and a long expiry was set |
| 3 | The browser is holding its own expiry |
Report 1 has a different weight of incident. Because other people's data is being served, a caching misconfiguration is directly a data leak. Whether something can be shared has to be decided before the freshness discussion.
The cause of Report 2 isn't only in Cache-Control. The root is swapping content while keeping the same URL. Shortening the expiry works around it, but adding a content hash to the filename at build time makes the URL identify the content and the problem disappears.
Report 3 shows the fact that purging the CDN doesn't reach browser caches. Once a response with an expiry has been served, you can't take it back. That's why freshness is controlled on the shared cache and not given to the browser.
How to fix comes down to the table in "Design from the nature of the resource".
Q2. You put public, no-cache on a JS bundle with a content hash. What happens?
Answer and explanation
Answer: Rendering stays correct, but a validation round-trip fires on every revisit.
no-cache allows storage but requires asking the origin before using it. Nothing breaks.
That said, "the body isn't transferred" only holds when the response carries an ETag or Last-Modified. Without a validator, every check returns a full 200 with the whole body, which is barely different from not caching.
The problem is the round trip itself. A hashed filename guarantees that content-change means URL-change. That is, the content at this URL will never change, so there's nothing to validate. Setting public, max-age=31536000, immutable erases the round trip entirely.
no-cache is needed when "the same URL may change content". If the URL identifies the content, validation is unnecessary.
Q3. You set only public, max-age=600. How long do the CDN and browser each cache?
Answer and explanation
Answer: Both up to "600 seconds since generation". They don't add up to 1,200 seconds.
max-age applies to all caches. Since s-maxage wasn't written, the shared cache follows this value too.
Here's where the property measured from generation bites. Caches are required to attach Age when they respond, and the browser adds it in to compute the remaining time. To a browser that receives a response that had sat 599 seconds in the CDN, only 1 second remains. Expiries don't stack.
- Source: RFC 9111 §4 — "A cache MUST generate an Age header field when it responds without validation"
- Source: RFC 9111 §4.2 —
response_is_fresh = (freshness_lifetime > current_age)
The cost isn't wait time — it's being unable to take it back. Copies served to the browser can't be purged, so if you serve wrong content, up to 600 seconds is out of reach.
To control freshness, split where it applies.
Cache-Control: public, s-maxage=600, max-age=0
Absorb 600 seconds on the CDN to protect origin, and don't let the browser hold any. Users always see the CDN's current state.
Summary
- Caches split into private (browser) and shared (CDN, proxy);
private/publicdecides which can hold them - What reduces origin load is the shared cache. The browser cache only speeds up return visits
no-cachemeans "store but revalidate every time";no-storemeans "never store". The names diverge from the meaningmax-ageapplies to all caches;s-maxageapplies only to shared cachesstale-while-revalidateserves stale while updating in the background, erasing wait time- Files with a content hash use
max-age=31536000, immutableas the idiom. There's no reason to give them a short expiry - If the same URL varies by request, name those headers in
Vary. Drop it and the shared cache hands out the wrong response - Design is decided by three questions: shareable → changeable → OK to retain
- Laravel API Development Guide — Cache — Application-internal cache design (this chapter is about the HTTP layer)
- MDN — Cache-Control
- RFC 9111 — HTTP Caching
- RFC 5861 — stale-while-revalidate
What to read next
- Complexity — On to Part 4. Entering computer science basics
- HTTP — The structure of the protocol that carries
Cache-Control