Mastering max-age and s-maxage Directives
TL;DR: Use s-maxage to set CDN TTL independently from the browser TTL set by max-age. The two directives target different tiers of the cache hierarchy — combining them gives you precision control over freshness without sacrificing performance at either layer.
Cache-Control: public, s-maxage=86400, max-age=3600, stale-while-revalidate=300
Mechanism & RFC 9111 Alignment
max-age and s-maxage are freshness directives defined in RFC 9111 Section 5.2.2. Both specify a freshness lifetime in seconds measured from the response’s Date header. The difference is which caches they govern.
RFC 9111 Section 5.2.2.9 states that s-maxage “overrides the expiration time of a response in a shared cache.” Section 5.2.2.1 states that max-age specifies the “maximum age” that any cache — shared or private — may use the response unless s-maxage is also present.
The two directives create a deterministic two-tier freshness model:
max-age=N— any cache may treat the response as fresh for N seconds. Browsers (private caches) use this value.s-maxage=N— shared caches (CDNs, reverse proxies, enterprise gateways) use this value instead ofmax-age. The value applies only to shared caches. Browsers silently ignores-maxage.
RFC 9111 Section 5.2.2.9 further specifies that s-maxage implicitly marks the response as publicly cacheable. A shared cache may store and reuse the response even without an explicit public directive when s-maxage is present. Adding public explicitly is still recommended for clarity.
For foundational header parsing rules and the full directive taxonomy, see Cache-Control Directives & Header Combinations.
Cache-Control response header is read by both the browser and the CDN. s-maxage overrides max-age for shared caches only; browsers read only max-age.Scope & Precedence
Understanding which directive wins in which context prevents misconfigured caches from serving stale or incorrectly restricted content.
| Directive | Applies to | Ignored by | Overrides |
|---|---|---|---|
max-age |
All caches (shared + private) | Nothing | Expires header |
s-maxage |
Shared caches only (CDN, proxy) | Browsers | max-age (for shared caches), Expires |
no-store |
All caches | Nothing | Both max-age and s-maxage |
private |
Blocks shared caches | — | s-maxage (shared caches must not store) |
Critical precedence rules from RFC 9111:
no-storewins unconditionally — if present, nothing is cached regardless ofmax-ageors-maxage.privateprevents shared caches from storing the response even whens-maxageis present. The combinationprivate, s-maxage=86400is contradictory: theprivatedirective takes precedence and shared caches must not store the response.- For shared caches:
s-maxageoverridesmax-age. Ifs-maxageis absent, shared caches fall back tomax-age. - For private caches (browsers):
s-maxageis silently ignored. Onlymax-age(orExpiresas a fallback) applies.
After s-maxage expires, the proxy-revalidate directive forces shared caches to revalidate with origin before serving stale content. The analogous directive for browsers after max-age expires is must-revalidate. For the full scope story, see Public vs Private Cache Scope.
Implementation Patterns
1. Static assets with long CDN TTL and short browser TTL
Versioned static assets (JS, CSS, images with a content hash in the URL) can use a very long CDN TTL while keeping the browser TTL shorter to stay within reasonable local storage limits:
Cache-Control: public, s-maxage=604800, max-age=86400, immutable
The CDN serves the asset for seven days. The browser caches it for one day. immutable tells browsers not to send conditional validation requests during the max-age window.
2. CDN TTL with browser validation
For frequently updated shared content where you want aggressive CDN caching but prefer browsers to always validate:
Cache-Control: public, s-maxage=3600, max-age=0, must-revalidate
CDNs cache for one hour. Browsers store the response but must revalidate on every use. This keeps browser users on fresh content while CDNs absorb the bulk of repeated requests. See no-cache vs no-store: When to Use Each for comparison with no-cache.
3. Background refresh without blocking latency
stale-while-revalidate pairs naturally with s-maxage to eliminate blocking revalidation at the CDN layer:
Cache-Control: public, s-maxage=86400, max-age=3600, stale-while-revalidate=300
After s-maxage expires, the CDN serves the stale response for up to 300 seconds while asynchronously fetching a fresh copy from origin. The client never waits for origin during this window. max-age=3600 keeps the browser copy fresh for one hour independently.
4. API responses with tiered freshness
REST APIs that serve non-personalized data benefit from CDN caching even with short TTLs:
Cache-Control: public, s-maxage=60, max-age=0, stale-while-revalidate=120, proxy-revalidate
CDN serves cached JSON for 60 seconds. During the 120-second stale window, CDN background-revalidates. proxy-revalidate ensures that once the stale window also expires, the CDN must block and fetch a fresh copy before serving. Browsers always go to CDN (or origin if CDN misses). For REST API-specific header strategies, see Cache-Control Best Practices for REST APIs.
5. Authenticated shared content
Some shared content is user-scoped but not strictly private (e.g., per-organization dashboards where all users in the org see identical data). s-maxage with Vary: Authorization can serve this:
Cache-Control: public, s-maxage=300, max-age=0
Vary: Authorization
The CDN caches a separate entry per Authorization value. Without Vary: Authorization, the CDN would risk serving one user’s data to another. Note: not all CDNs support Vary on Authorization — verify with your provider.
Server & CDN Configuration
Nginx
# Static versioned assets — long CDN TTL, shorter browser TTL
location ~* \.(js|css|woff2)$ {
add_header Cache-Control "public, s-maxage=604800, max-age=86400, immutable";
}
# API responses — CDN caches, browsers always validate
location /api/ {
add_header Cache-Control "public, s-maxage=60, max-age=0, stale-while-revalidate=120, proxy-revalidate";
}
Apache
Header set Cache-Control "public, s-maxage=604800, max-age=86400, immutable"
Header set Cache-Control "public, s-maxage=60, max-age=0, stale-while-revalidate=120, proxy-revalidate"
Cloudflare (Cache Rules)
In the Cloudflare dashboard under Rules → Cache Rules, create a rule that sets the Edge TTL (maps to s-maxage) and Browser TTL (maps to max-age) independently:
Rule: Static Assets
Match: (http.request.uri.path matches "\.(js|css|woff2)$")
Action:
Edge TTL: 7 days
Browser TTL: 1 day
Cache status: Eligible for cache
Alternatively, emit the header from origin and configure Cloudflare to respect origin Cache-Control headers (the default). Cloudflare reads s-maxage for edge TTL and max-age for browser TTL automatically when both are present.
To override origin headers at the edge using Cloudflare Workers:
export default {
async fetch(request, env) {
const response = await fetch(request);
const newResponse = new Response(response.body, response);
newResponse.headers.set(
"Cache-Control",
"public, s-maxage=86400, max-age=3600, stale-while-revalidate=300"
);
return newResponse;
},
};
Interaction with Related Directives
max-age and s-maxage do not operate in isolation. Several adjacent directives modify or interact with their behavior:
must-revalidate — activates after max-age expires for browsers. Without it, some implementations may serve stale content under origin failure conditions. With it, the cache must return a 504 Gateway Timeout rather than serve a stale entry.
proxy-revalidate — the shared-cache equivalent of must-revalidate. Activates after s-maxage expires. Shared caches must not serve stale content under any condition once the s-maxage window and any stale-while-revalidate extension have both elapsed.
stale-while-revalidate=N — extends the effective TTL by N seconds for background revalidation after s-maxage (or max-age) expires. During this extension window the cache serves the stale copy while asynchronously refreshing. This eliminates the latency spike that would otherwise occur at TTL expiry. Covered in depth at What Happens When max-age Expires.
immutable — suppresses browser conditional requests within the max-age window. Browsers normally send If-None-Match or If-Modified-Since on reload. immutable tells the browser that the resource will not change during its freshness lifetime and it can skip validation entirely. Only makes sense on versioned URLs.
no-store and private — both override s-maxage for shared caches. If either is present, the s-maxage value is irrelevant to shared caches. For the full conflict-resolution decision tree, see How to Combine Cache-Control Directives Safely.
Verification Workflow
Use this step-by-step procedure to confirm max-age and s-maxage are working as intended in both the browser and CDN layers.
Step 1 — Confirm the origin emits the correct header.
curl -sI https://example.com/asset.js | grep -i cache-control
Expected output:
cache-control: public, s-maxage=86400, max-age=3600, stale-while-revalidate=300
Step 2 — Check the CDN is caching (look for Age and cache status headers).
curl -sI https://example.com/asset.js \
| grep -iE 'cache-control|age|cf-cache-status|x-cache'
A cached response shows an Age value greater than 0 and a hit status in CF-Cache-Status: HIT (Cloudflare), X-Cache: HIT (many other CDNs), or similar. Age counts seconds elapsed since the CDN cached the object — if Age exceeds s-maxage, the object is stale at the CDN.
Step 3 — Force a CDN cache bypass to fetch directly from origin.
curl -sI -H "Cache-Control: no-cache" https://example.com/asset.js
This bypasses CDN cache (Cloudflare and Fastly honour no-cache from clients to force origin fetch). Compare the Cache-Control header in this response with step 1 to confirm the CDN is not rewriting it.
Step 4 — Confirm browser TTL in DevTools.
Open Chrome DevTools → Network tab → request the resource. In the Size column, (disk cache) or (memory cache) indicates the browser served from its private cache within the max-age window. (from network) with 200 OK indicates the CDN or origin was hit. A 304 Not Modified indicates the browser sent a conditional request after max-age expired and the CDN or origin confirmed the resource is unchanged.
Step 5 — Verify post-expiry revalidation behavior.
Wait until Age approaches s-maxage. Issue another request. A correct CDN will either return a fresh Age: 0 response (re-fetched from origin) or a stale response with Age slightly exceeding s-maxage during the stale-while-revalidate window. If the CDN continues serving the stale response indefinitely beyond s-maxage + stale-while-revalidate, the CDN is not respecting the TTL.
Failure Modes & Gotchas
-
privatesilently neutralizess-maxage. If a CDN receivesCache-Control: private, s-maxage=86400, RFC 9111 requires the shared cache to not store the response. Thes-maxagevalue is completely ignored. Always audit response headers on endpoints that moved from private to shared caching — a staleprivatedirective will prevent CDN caching regardless of thes-maxagevalue. -
CDN header rewriting strips
s-maxage. Some CDN configurations rewrite theCache-Controlheader before forwarding it to browsers, removings-maxage(since browsers ignore it anyway) and replacing it with an equivalentmax-age. This means the browser receives amax-ageequal to the originals-maxage— often a much longer TTL than intended for the browser. Always test what the CDN sends downstream versus what origin sends to the CDN. -
Missing
Ageheader breaks freshness calculation. Some origin servers do not emitAge. WithoutAge, a CDN cannot accurately determine how long a stored response has been held — it may re-serve objects beyond their intended freshness window. Configure your origin to emitAge: 0on fresh responses and ensure the CDN propagates an updatedAgeheader to downstream clients. -
s-maxagewithVary: *is uncacheable.Vary: *means no two requests are considered equivalent, making the response uncacheable in any shared cache.s-maxagehas no effect whenVary: *is set. -
Clock skew between origin and CDN. Freshness is calculated relative to the
Dateheader in the response. If the CDN’s clock diverges from the origin’s clock by more than a few seconds, freshness calculations become inaccurate. Use NTP-synchronized clocks on all origin servers. -
stale-while-revalidatebrowser support is not universal. Firefox and Safari have inconsistent support forstale-while-revalidate. Do not rely on it for browser-layer freshness extension — use it only for CDN-layer behavior where CDN support is confirmed. -
s-maxage=0forces CDN revalidation, not bypass. Settings-maxage=0does not prevent caching — it means the CDN must revalidate on every request before serving. To prevent shared caching entirely, useno-storeorprivate.
FAQ
Does s-maxage override max-age for CDNs even when max-age is larger?
Yes. RFC 9111 Section 5.2.2.9 is unambiguous: when s-maxage is present, shared caches use it exclusively for freshness calculation, regardless of the max-age value. If you set max-age=86400, s-maxage=3600, the CDN TTL is 3600 seconds even though max-age is longer.
Can I use s-maxage without max-age?
Yes. If max-age is absent and s-maxage is present, browsers fall back to heuristic caching or the Expires header (if present). Heuristic caching in browsers computes a TTL based on the Last-Modified header — typically 10% of the resource’s apparent age. To prevent unexpected browser caching, pair s-maxage with an explicit max-age=0 if you want browsers to always revalidate.
Why does my browser not cache the response even though max-age is set?
Common causes: Cache-Control: no-store appears elsewhere in the header (check for duplicates); the request is sent with a cookie that makes the response private by your CDN’s default policy; the response was served over HTTP rather than HTTPS and the browser security policy blocks caching; or DevTools has “Disable cache” checked.
Does s-maxage affect Cloudflare’s Edge TTL?
Yes. Cloudflare reads s-maxage from the origin response and uses it as the edge TTL when the response is eligible for caching. If you have set a Cloudflare Cache Rule with an explicit edge TTL, that rule takes precedence over the origin s-maxage value — the rule wins. To respect origin TTLs, set the Cloudflare Cache Rule to “Use Cache-Control header TTL.”
What happens when s-maxage expires and there is no stale-while-revalidate?
The CDN sends a conditional request to origin using If-None-Match (if an ETag was stored) or If-Modified-Since (if Last-Modified was stored). If origin returns 304 Not Modified, the CDN resets the freshness timer and continues serving. If origin returns 200 OK with a new body, the CDN stores the new response. If origin is unreachable and must-revalidate or proxy-revalidate is set, the CDN must return a 504 error rather than serve stale content.
Related
- What Happens When max-age Expires covers the conditional GET revalidation sequence that fires after
max-ageors-maxageTTL elapses, includingETagandIf-None-Matchmechanics. - Header Stacking and Directive Precedence examines how multiple
Cache-Controldirectives on the same response interact when they appear to conflict. - How CDN Cache Keys Are Generated explains why
s-maxagealone does not guarantee a cache hit — the cache key must also match for a stored entry to be reused.