Caching is one of the simplest ways to improve delivery. A cache saves a copy of a response so it can be served without a round trip to the origin. The core control surface is the Cache-Control header and the time-to-live (TTL). Freshness and validation determine when a cache can reuse a stored response and when it must contact the origin.
Freshness vs validation
A cache serves a response when it is fresh. Freshness comes from an explicit lifetime such as max-age or from an older Expires date. After freshness ends, a cache either revalidates or fetches again.
Validation uses validators to ask the origin if a stored response is still good. Two common validators are ETag and Last-Modified. A validating request includes If-None-Match or If-Modified-Since. If the representation has not changed, the origin replies with 304 Not Modified (see HTTP status codes) and the cache can serve its copy without transferring the body. This saves bandwidth and keeps latency low even when TTLs are short.
The main Cache-Control directives
Freshness: max-age, s-maxage, and Expires
max-age=Nsets the freshness lifetime in seconds for all caches.s-maxage=Noverridesmax-agefor shared caches such as CDNs, leaving browsers to obeymax-age.Expires: <http-date>is the older version of freshness. If both are present,Cache-Controlwins.
max-age=0 does not mean “do not cache.” It allows storage but gives the response no freshness lifetime. Any reuse is subject to the rules for stale responses. Add no-cache to require successful validation before reuse, or use no-store to prohibit storage.
Storage scope: public, private, no-store
publicexplicitly marks a response as cacheable, subject to other HTTP caching constraints.privateprevents shared caches from storing the response. A private cache may store it.no-storeinstructs private and shared caches not to store the response. It is not a substitute for access control or transport security.
A shared cache cannot normally reuse a response to a request with an Authorization header. The response must include public, s-maxage, or must-revalidate to permit shared caching under the directive’s constraints.
Revalidation controls: no-cache and must-revalidate
no-cacheallows storage but prohibits reuse without successful validation by the origin.must-revalidateprohibits reuse after the response becomes stale until validation succeeds. A disconnected cache must return an error instead of reusing the stale response.proxy-revalidateapplies the same stale-response rule only to shared caches. It does not make a response cacheable by itself.
Resilience: stale-while-revalidate and stale-if-error
These optional directives let caches deliver better continuity:
stale-while-revalidate=Nallows a cache to serve a response that is stale by no more thanNseconds while it attempts asynchronous validation.stale-if-error=Nallows a cache to serve a response that is stale by no more thanNseconds when it encounters a specified error. RFC 5861 defines these errors as status codes 500, 502, 503, and 504.
Set each window to the maximum staleness that the content can tolerate. Confirm the CDN’s supported errors and validation behavior.
Other useful directives
immutabletells browsers the resource will not change during its lifetime. Use on versioned static assets to avoid revalidation.no-transformasks intermediaries not to transcode or resize responses.only-if-cachedis a request directive that tells the cache to serve only if it already has a copy (no origin fetch).
Vary and the cache key
The Vary header tells caches which request headers change the representation and therefore the cache key. Every distinct combination yields a separate object.
- Include
Accept-EncodinginVarywhen that request field selects the response representation and the CDN does not normalize encoding variants itself. - Avoid
Vary: Cookie. Cookies change often and can reduce the cache hit ratio. For cookie-aware responses, consider client-side personalization or separate cookie names that do not apply to static routes. - Be careful with
Vary: User-Agent. It creates a huge number of variants; prefer feature detection or server-side negotiation with a smaller set of hints. Vary: *prevents a cache from matching the stored response to a later request without forwarding that request to the origin.
Many CDNs also support “custom cache keys” outside HTTP Vary. Use them sparingly to normalize query strings, ignore marketing parameters, or pin exact headers.
Choosing TTLs by content type
Versioned static assets
For hashed filenames (for example app.9f1c2.js), use a very long TTL and mark immutable:
Cache-Control: public, max-age=31536000, immutableDeploy each new version with a new filename so caches fetch the new asset.
HTML documents and primary JSON pages
HTML often needs quick updates. A common pattern is a short freshness window combined with background refresh for continuity:
Cache-Control: public, s-maxage=60, max-age=0, stale-while-revalidate=300, stale-if-error=600Browsers treat the page as stale (they will revalidate), while the CDN can serve fresh copies for a minute and continue serving stale during revalidation or short outages.
APIs
Public, read-heavy endpoints:
Cache-Control: public, s-maxage=120, max-age=30, stale-while-revalidate=60
ETag: "abc123"Personalized or sensitive endpoints:
Cache-Control: private, no-storeFor shared caching of authorized requests, return an explicit enabling directive:
Cache-Control: public, s-maxage=60, must-revalidateFile downloads and media
For large, infrequently changing files, prefer long TTLs and support byte ranges. Verify how the CDN stores and combines partial responses. HTTP permits combination only when the ranges share the same strong validator.
ETag and Last-Modified in practice
Validators are the backbone of validation caching.
ETagidentifies a selected representation. A strong entity tag changes whenever the representation data changes. A weak tag indicates semantic equivalence, but it cannot support range combination orIf-Rangevalidation.Last-Modifiedis easy to implement but less precise. Clocks and build pipelines can make timestamps noisy.
An unchanged representation can produce 304 Not Modified, which avoids transferring its content. Send available validators on 200 responses to GET and HEAD, especially for content that is revalidated frequently.
Expires vs Cache-Control
Expires dates were the original freshness mechanism. Cache-Control is newer and more expressive. For shared caches, s-maxage takes precedence over max-age, and either directive takes precedence over Expires. Prefer Cache-Control. Keep Expires only when the platform requires it for legacy clients.
Edge vs browser caching
Browsers and CDNs do not always behave the same way.
s-maxagetargets shared caches and overridesmax-agethere. This permits a longer CDN freshness lifetime than the browser freshness lifetime.- Some CDNs support a separate
Surrogate-Controlheader for edge-specific policies. For example, one response can specify a long surrogate TTL and a short browser TTL:
Surrogate-Control: max-age=600
Cache-Control: max-age=60- Many CDNs also support default TTLs when the origin omits headers. Use defaults as a backstop, not as the main policy.
Common pitfalls and how to avoid them
- Thinking
max-age=0disables caching. It does not. Useno-cacheto force revalidation orno-storeto forbid storage. - Over-broad Vary.
Vary: CookieorVary: User-Agentcan kill caching. Minimize the vary set. - Missing validators. Without
ETagorLast-Modified, revalidation falls back to full fetches. - Conflicting freshness values. If
Expiresis present, keep it consistent withmax-ageands-maxage. The applicable age directive takes precedence. - Caching private content. Mark personalized responses as
privateorno-store. Do not rely on path secrecy. - Ignoring Authorization. A shared cache cannot normally reuse responses to requests with
Authorization. Explicit directives such aspublic,s-maxage, ormust-revalidatecan permit reuse under their constraints. - Relying on defaults. Be explicit on HTML and API responses. Defaults differ between browsers and CDNs.
Heuristic caching when headers are absent
When freshness information is absent, a shared cache may estimate a lifetime from values such as Last-Modified. The result depends on the cache. Prefer an explicit max-age or s-maxage, even if short.
Normalizing the cache key
Normalize ignorable query parameters (for example marketing tags) so they do not fragment the cache. Many CDNs can ignore specific parameters or order them consistently. The goal is that semantically equal URLs map to the same cached object.
Testing and rollout
- Inspect headers. Use
curl -I https://example.com/and a browser network panel. Confirm the presence and values ofCache-Control,ETag,Last-Modified, andVary. - Watch cache outcome headers. Many CDNs add
X-Cacheor similar to showHIT,MISS,EXPIRED, orSTALE. Use them during rollout. - Measure hit ratio and TTFB. Track changes in cache hit ratio and TTFB after deploying new rules.
- Exercise revalidation. Force short TTLs in a test environment and confirm that
304 Not Modifiedresponses appear for unchanged content. - Plan for purge. Even with long TTLs, time-sensitive content needs targeted invalidation. Tie purges to deployments for HTML and API payloads that must change quickly.
Practical starter policies
Use these as a baseline and adjust after measuring.
- Versioned static assets
Cache-Control: public, max-age=31536000, immutable- HTML shell
Cache-Control: public, s-maxage=60, max-age=0, stale-while-revalidate=300, stale-if-error=600
ETag: "rev-<build>"- Public API (read-heavy)
Cache-Control: public, s-maxage=120, max-age=30, stale-while-revalidate=60
ETag: "sha256-<body>"- Personalized or sensitive
Cache-Control: private, no-storeSee also Purging CDN Content for strategies to remove cached objects when immediate updates are required.