Skip to main content

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-cache and no-store, and between max-age and s-maxage
  • stale-while-revalidate, which decides behavior after expiry, and immutable, which skips validation
  • Why Vary is needed when the same URL returns different content
  • The procedure for deriving caching design from the nature of the resource
Prerequisites

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.

KindHeld byCharacter
Private cacheBrowserFor that one user. Invisible to anyone else
Shared cacheCDN, reverse proxyShared 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​

DirectiveMeaning
publicShared caches may store it
privateOnly private caches may store it. CDNs must not
no-storeNo cache may store it at all

2. How long it counts as fresh​

DirectiveMeaning
max-age=NFresh for N seconds after generation. Applies to all caches
s-maxage=NFresh 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​

DirectiveMeaning
must-revalidateAfter expiry, revalidate every time. If the origin is unreachable, return 504
stale-while-revalidate=NFor 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​

DirectiveMeaning
no-cacheMay be stored, but must be revalidated on every reuse
immutablePromises 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.

StorageReuse
no-cacheYesOnly after revalidation
no-storeNoNot 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:

TypeExampleDesignReason
Static file with a content hashapp-a3f8c2.jspublic, max-age=31536000, immutableIf content changes, the URL changes. The same URL always has the same content, so even validation is unnecessary
Shareable API with a freshness requirementProduct info, article bodypublic, s-maxage=<seconds>, stale-while-revalidate=<seconds>, max-age=0Absorb 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 dataMy-page, favoritesprivate, no-cacheMust 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:

ReportWhere the cause is
1A per-user response has been placed on a shared cache
2Content changed but the URL didn't, and a long expiry was set
3The 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 / public decides which can hold them
  • What reduces origin load is the shared cache. The browser cache only speeds up return visits
  • no-cache means "store but revalidate every time"; no-store means "never store". The names diverge from the meaning
  • max-age applies to all caches; s-maxage applies only to shared caches
  • stale-while-revalidate serves stale while updating in the background, erasing wait time
  • Files with a content hash use max-age=31536000, immutable as 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
Related references
  • Complexity — On to Part 4. Entering computer science basics
  • HTTP — The structure of the protocol that carries Cache-Control