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 of max-age. The value applies only to shared caches. Browsers silently ignore s-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.

Two-tier freshness model: max-age governs browser TTL, s-maxage governs CDN TTL Diagram showing a request flowing from Browser through CDN to Origin. The Browser-to-CDN leg is governed by max-age. The CDN-to-Origin leg is governed by s-maxage. Both directives appear in the same Cache-Control response header from Origin. Browser (private cache) CDN / Proxy (shared cache) Origin (application server) max-age governs s-maxage governs Cache-Control: public, s-maxage=86400, max-age=3600 Origin response header — same header, two audiences
The same 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:

  1. no-store wins unconditionally — if present, nothing is cached regardless of max-age or s-maxage.
  2. private prevents shared caches from storing the response even when s-maxage is present. The combination private, s-maxage=86400 is contradictory: the private directive takes precedence and shared caches must not store the response.
  3. For shared caches: s-maxage overrides max-age. If s-maxage is absent, shared caches fall back to max-age.
  4. For private caches (browsers): s-maxage is silently ignored. Only max-age (or Expires as 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;
  },
};

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

  1. private silently neutralizes s-maxage. If a CDN receives Cache-Control: private, s-maxage=86400, RFC 9111 requires the shared cache to not store the response. The s-maxage value is completely ignored. Always audit response headers on endpoints that moved from private to shared caching — a stale private directive will prevent CDN caching regardless of the s-maxage value.

  2. CDN header rewriting strips s-maxage. Some CDN configurations rewrite the Cache-Control header before forwarding it to browsers, removing s-maxage (since browsers ignore it anyway) and replacing it with an equivalent max-age. This means the browser receives a max-age equal to the original s-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.

  3. Missing Age header breaks freshness calculation. Some origin servers do not emit Age. Without Age, 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 emit Age: 0 on fresh responses and ensure the CDN propagates an updated Age header to downstream clients.

  4. s-maxage with Vary: * is uncacheable. Vary: * means no two requests are considered equivalent, making the response uncacheable in any shared cache. s-maxage has no effect when Vary: * is set.

  5. Clock skew between origin and CDN. Freshness is calculated relative to the Date header 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.

  6. stale-while-revalidate browser support is not universal. Firefox and Safari have inconsistent support for stale-while-revalidate. Do not rely on it for browser-layer freshness extension — use it only for CDN-layer behavior where CDN support is confirmed.

  7. s-maxage=0 forces CDN revalidation, not bypass. Setting s-maxage=0 does not prevent caching — it means the CDN must revalidate on every request before serving. To prevent shared caching entirely, use no-store or private.

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.


Back to Cache-Control Directives & Header Combinations