How to Combine Cache-Control Directives Safely

Problem Statement

Stacking multiple Cache-Control directives in a single header without understanding their precedence relationships produces undefined, implementation-dependent caching behavior. A header like Cache-Control: public, max-age=86400, no-cache looks intentional but is self-contradictory: no-cache forces revalidation before every use, making the max-age=86400 freshness window unreachable. This guide shows exactly which combinations are safe, which violate RFC 9111, and how to verify correct behavior end-to-end.

Prerequisite Concepts

Before working through the steps below, make sure you understand these foundational topics:

Directive Interaction Diagram

The diagram below shows how RFC 9111 resolves the most common directive groupings across browser and shared-cache tiers.

Cache-Control Directive Interactions A two-column diagram. The left column shows directives that affect browser caches: max-age, no-cache, no-store, private. The right column shows directives that affect shared caches (CDNs): s-maxage, public, no-store, stale-while-revalidate. Arrows indicate which directive wins when two conflict. Browser Cache Shared Cache (CDN) max-age=N Fresh for N seconds no-cache Revalidate before every use no-store Prohibits all storage private Browser only; blocks shared caches s-maxage=N Overrides max-age for shared caches public Authorises shared cache storage no-store Applies to all caches including CDNs stale-while-revalidate=N Serve stale; fetch fresh in background overrides wins for CDN both tiers directive overrides / conflicts with another individual directive

Step-by-Step Resolution

Step 1 — Identify the resource type and caching intent

Before writing a single directive, answer three questions:

  1. Who may store this response? Private user data must stay in the browser only (private). Assets safe for CDN storage need public or s-maxage.
  2. How long before the response goes stale? Content-hashed static assets can be immutable for a year. API responses that update every minute need a short s-maxage.
  3. What should happen when a cached copy is stale? A hard no-cache requires a server round-trip; stale-while-revalidate allows background refresh while serving the stale copy.

Step 2 — Select a safe directive combination

The table below maps resource types to tested, RFC-compliant directive sets.

Resource type Safe Cache-Control value Reasoning
Content-hashed static asset (JS, CSS, images) public, max-age=31536000, immutable Filename changes on deploy; one-year TTL is safe for all tiers
Dynamic HTML (shared, updates frequently) public, s-maxage=60, stale-while-revalidate=300 CDN caches for 60 s; background refresh within the 300 s stale window
API JSON (authenticated, shared by role) public, s-maxage=30, no-cache CDN can cache 30 s; browsers must revalidate every request
User-specific data (session, profile) private, no-cache Browser stores for reuse but validates on every request; CDN bypasses
Sensitive or regulated data no-store No storage at any layer; never paired with max-age or s-maxage

Hard rules from RFC 9111:

  • Never pair public with private — they are mutually exclusive scope directives.
  • Never pair no-store with max-age or s-maxageno-store prohibits the storage that TTL directives depend on.
  • Never pair no-cache with a non-zero max-age expecting the max-age window to apply — no-cache wins and the max-age freshness interval is never exercised. If you need a short browser TTL alongside a longer CDN TTL, use max-age for the browser and s-maxage for the CDN.
  • stale-while-revalidate combined with no-cache is contradictory — no-cache prevents serving any stored response without revalidation, so the stale-serving permission is never used.

Step 3 — Configure the origin to emit the correct header

Nginx — per-location directive:

location ~* \.(js|css|png|svg|woff2)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

location /api/ {
    add_header Cache-Control "public, s-maxage=30, no-cache";
}

Apache — using mod_headers:


    Header set Cache-Control "public, max-age=31536000, immutable"



    Header set Cache-Control "public, s-maxage=30, no-cache"

Cloudflare Cache Rule (transform rule on response headers, Dashboard → Rules → Transform Rules → Modify Response Header):

Field:  Cache-Control
Action: Set to
Value:  public, s-maxage=60, stale-while-revalidate=300

Apply this rule with a matching expression like (http.request.uri.path matches "^/api/") for API routes, or leave the origin header intact for static assets and let Cloudflare inherit it.

Step 4 — Verify end-to-end with curl

Run two successive requests and inspect the Age, X-Cache, and Cache-Control values:

# First request — should miss the CDN cache
curl -sI https://example.com/api/data \
  | grep -iE "^(cache-control|age|x-cache|cf-cache-status):"

# Second request — Age should be rising if the CDN is caching
curl -sI https://example.com/api/data \
  | grep -iE "^(cache-control|age|x-cache|cf-cache-status):"

Expected output for public, s-maxage=60, stale-while-revalidate=300:

Cache-Control: public, s-maxage=60, stale-while-revalidate=300
Age: 3
CF-Cache-Status: HIT

A rising Age value on the second call confirms the shared cache is storing and serving the response. An Age stuck at 0 with a MISS or BYPASS status means the directive is preventing CDN storage — check for conflicting headers such as Set-Cookie (which implicitly makes responses uncacheable by shared caches unless public overrides it) or a CDN policy rule overriding the origin value.

To force cache bypass and test the true origin header without CDN interference:

curl -sI -H "Cache-Control: no-cache" https://example.com/api/data \
  | grep -iE "^(cache-control|age|x-cache|cf-cache-status):"

Step 5 — Confirm browser behavior with DevTools

  1. Open Chrome or Firefox, press F12, and switch to the Network tab.
  2. Tick Disable cache and perform a hard reload (Ctrl+Shift+R / Cmd+Shift+R). Inspect the Response Headers panel for the exact Cache-Control string emitted by the server. This is the ground truth — confirm it matches what you configured in Step 3.
  3. Untick Disable cache and reload normally. Check the Size column:
    • (disk cache) — the browser served the response from disk storage; max-age is working.
    • (memory cache) — the browser served from in-memory storage; max-age is working.
    • A numeric byte count with 200 OK — the browser made a network request; either the TTL expired, no-cache is forcing revalidation, or the response is not being stored.
  4. For resources with no-cache, check the Status column. You should see 304 Not Modified on subsequent requests, confirming the browser is sending conditional requests with If-None-Match or If-Modified-Since and the server is validating correctly. A 200 OK instead of 304 means the server is not sending ETags or Last-Modified headers, and the freshness vs validation model is falling back to full re-fetches.

Expected Output / Verification

A correctly configured response for each resource class looks like this:

Content-hashed static asset (one-year immutable):

HTTP/2 200
Cache-Control: public, max-age=31536000, immutable
ETag: "abc123def456"
Age: 0
CF-Cache-Status: MISS

On the second request, Age rises and CF-Cache-Status becomes HIT. In DevTools, the Size column shows (disk cache) for subsequent page loads.

Dynamic API response (short CDN TTL, stale-while-revalidate):

HTTP/2 200
Cache-Control: public, s-maxage=60, stale-while-revalidate=300
Age: 22
CF-Cache-Status: HIT

Within the 60 s freshness window, Age increments and CF-Cache-Status is HIT. Between 60 s and 360 s (s-maxage + stale-while-revalidate), Cloudflare serves the stale copy while triggering a background revalidation. After 360 s, the entry is evicted and the next request becomes a MISS.

User-specific response (private, no-cache):

HTTP/2 200
Cache-Control: private, no-cache
Vary: Cookie
ETag: "user42-v7"

No CDN should cache this. Confirm with:

curl -sI -b "session=abc" https://example.com/dashboard \
  | grep -iE "^(cache-control|age|cf-cache-status):"

Expected: CF-Cache-Status: BYPASS (Cloudflare) or X-Cache: MISS (Fastly/Varnish). An Age header appearing on a private response is a misconfiguration — either the CDN policy is overriding the origin header or the route is missing authentication enforcement.

Edge Cases

  • CDN policy overrides silently drop origin directives. CloudFront Cache Policies, Cloudflare Cache Rules, and Fastly VCL can all override origin Cache-Control values. Always verify the header the CDN emits to clients (curl from an external network), not just what the origin emits. These take precedence over origin headers when configured, and the discrepancy is the most common source of “why isn’t this caching?” bugs.

  • Set-Cookie responses are not stored by shared caches by default. RFC 9111 §7.3 states that a response with a Set-Cookie header must not be stored in a shared cache unless the response explicitly carries public. If your API sets cookies and you want CDN caching, add public intentionally and confirm the cookie does not contain user-specific session state that would cause cache poisoning.

  • HTTP/1.1 vs HTTP/2 proxies and legacy Pragma: no-cache. Corporate intermediaries running HTTP/1.0-era proxy software may ignore Cache-Control entirely and honour only Pragma: no-cache. Sending both Pragma: no-cache and Cache-Control: no-store covers both eras. Modern CDNs and browsers ignore Pragma entirely, so this is only relevant when supporting legacy enterprise networks.

  • Missing ETag or Last-Modified makes no-cache wasteful. no-cache requires the server to validate on every request, but if the origin does not return an ETag or Last-Modified header, the browser cannot send a conditional request — it receives a full 200 OK instead of a lightweight 304 Not Modified. Check the header stacking and directive precedence cluster for guidance on ensuring validators are always present when no-cache is in use.

FAQ

Can I use no-cache and max-age together?

You can, but no-cache renders max-age irrelevant for freshness decisions — the cache must revalidate before every use regardless of the max-age value. If you want a long CDN TTL with mandatory browser revalidation, use s-maxage for the CDN and no-cache for browsers instead of pairing no-cache with max-age.

Does public override private in the same header?

No. RFC 9111 treats public and private as mutually exclusive cache-scope directives. Sending both in the same header is a protocol violation; behavior is implementation-defined and unpredictable across CDNs and browsers. Always send exactly one or neither.

Does no-store with max-age cause any harm?

no-store prohibits any form of storage, making max-age meaningless — a cache cannot honour a TTL for a response it is not allowed to store. The combination wastes header space and can confuse CDN logic that parses directives independently.

Will stale-while-revalidate work alongside no-cache?

No. no-cache requires revalidation before every use, which directly contradicts stale-while-revalidate’s permission to serve stale content during a background fetch. no-cache takes precedence and stale-while-revalidate has no effect. Remove one.


Related

Back to Mastering max-age and s-maxage Directives