HTTP Cache Headers for JSON APIs: Cache-Control, Vary, Expires, and SWR

Last updated:

HTTP cache headers tell every cache in the chain — browser, corporate proxy, CDN, reverse proxy — what to store, for how long, and when to revalidate. For JSON APIs, getting these right is the difference between a snappy app that absorbs traffic spikes and an origin that melts under load (or worse, one that leaks user A's response to user B). The modern standard is RFC 9111 (HTTP Caching), with RFC 5861 adding stale-while-revalidate and stale-if-error. This reference walks through every directive that matters on a JSON endpoint — public vs private, max-age vs s-maxage, no-cache vs no-store, the Vary header, and the newer CDN-Cache-Controlfor splitting browser and edge behavior — plus invalidation patterns and how Cloudflare's cf-cache-status exposes what actually happened.

Building or debugging a JSON API response? Paste the body into Jsonic's JSON Validatorto confirm it parses cleanly before you go chasing cache-header bugs. Bad JSON returning 200 is a much more common cause of "the cache is broken" than the cache itself.

Validate JSON response

Cache-Control directive cheat sheet: public, private, no-store, max-age, s-maxage

Cache-Control is the one header that controls everything else. It carries a comma-separated list of directives that every modern cache understands. The full list in RFC 9111 has around a dozen response directives; for JSON APIs you really only need to know seven, and most responses use two or three of them together.

DirectiveAudienceWhat it does
publicAll cachesResponse is shareable across users
privateBrowser onlyShared caches must not store; browser may
no-storeAll cachesDo not write to disk at all — every request hits origin
no-cacheAll cachesMay store, but must revalidate before serving
max-age=NAll cachesFresh for N seconds, then stale
s-maxage=NShared caches onlyOverrides max-age for CDNs/proxies
must-revalidateAll cachesOnce stale, must revalidate — cannot serve stale on error
immutableBrowserBody will never change for this URL — skip revalidation entirely
stale-while-revalidate=NAll cachesServe stale for N seconds while refetching in background
stale-if-error=NAll cachesServe stale for N seconds if origin returns 5xx

Five patterns cover most JSON APIs:

# 1. Public, short-lived (catalog, public feed)
Cache-Control: public, max-age=60, s-maxage=300

# 2. Public with SWR (dashboard tiles, trending lists)
Cache-Control: public, max-age=60, stale-while-revalidate=600

# 3. User-specific, browser-only caching
Cache-Control: private, max-age=300

# 4. Never cache (auth, payment, one-time tokens)
Cache-Control: no-store

# 5. Cache but always check (when ETag is cheap)
Cache-Control: no-cache

Directives combine — public, max-age=60, s-maxage=86400, stale-while-revalidate=300 is a perfectly normal line, and each cache obeys the parts that apply to it.

Public vs Private caching for JSON APIs

public and private answer one question: can a shared cache (a CDN, a corporate proxy, an ISP cache) store this response and serve it to a different user? public says yes, private says no.

For a JSON API, the choice usually maps to whether the response is user-specific. A product catalog endpoint returns the same JSON for every request — mark it public so a CDN can absorb the load. A user profile endpoint returns a different body per user — mark it privateso only the user's own browser holds the copy, and the CDN refuses to cache it.

# Public catalog endpoint
GET /api/products
Cache-Control: public, max-age=300, s-maxage=3600
Content-Type: application/json

# User-specific profile endpoint
GET /api/me
Cache-Control: private, max-age=60
Content-Type: application/json

The trap is authenticated endpoints that return the same body for every authenticated user. Marking them public is technically correct (the body is shareable) but if the auth wall is itself the point of the endpoint, you must also send Vary: Authorization — otherwise an unauthenticated request could be served the authenticated copy. When in doubt on auth endpoints, use private and accept the small origin-hit cost. The cost of over-caching user data is a leaked response; the cost of under-caching is one extra request.

privatedoes not mean "private as in secret" — it does not encrypt or obscure anything. It is purely a routing instruction: do not put this in a shared cache. For genuinely sensitive responses (auth tokens, partial PII, payment data), use no-store instead, which forbids even the browser from writing it to disk.

stale-while-revalidate (RFC 5861) — the SWR pattern

stale-while-revalidate (SWR) is one of the most useful directives for read-heavy JSON APIs. Defined in RFC 5861, it splits caching into two phases: a fresh window where the cache serves the body without any origin contact, then a stale window where the cache still serves the cached body immediately but kicks off a background revalidation. The user always gets a fast response; the origin sees roughly one request per (fresh + stale) window.

Cache-Control: public, max-age=60, stale-while-revalidate=600

# Timeline for one cached entry:
# t=0..60s   fresh        → serve from cache, no origin hit
# t=60..660s stale window → serve stale, revalidate in background
# t>660s     expired      → next request waits for origin

SWR is the right call when low latency matters more than perfect freshness — dashboards, home feeds, product listings, trending lists, anything where a 1-2 minute lag is invisible. Avoid it for inventory checks at checkout, payment confirmations, real-time chat, or anything where stale data is a correctness bug rather than a cosmetic one.

The sibling directive stale-if-error serves stale content when the origin returns 5xx. It is a resilience pattern: during a partial origin outage, users continue to see slightly-old responses instead of error pages.

Cache-Control: public, max-age=60, stale-while-revalidate=600, stale-if-error=86400

# If origin returns 5xx for the next 24h, caches keep serving the last good body.

Most modern CDNs honor SWR natively (Cloudflare, Fastly, Vercel, CloudFront with configuration). Browsers added support more recently — Chrome and Firefox honor SWR; older Safari versions ignore it and treat the response as expired at max-age. The fallback behavior is safe: it just means the user occasionally waits for a fresh fetch, exactly as if you had not set SWR.

Vary header: caching by Accept, Authorization, User-Agent

The Vary response header lists request headers that affect the response body. Caches use that list to build the cache key — the URL plus the listed request headers — so they store separate copies for each unique combination. Without Vary, the cache uses just the URL, and any header-driven variation will cause one user to be served another user's response.

# Content negotiation — JSON or XML based on Accept
GET /api/users/42
Accept: application/json
→ Vary: Accept

# Authenticated and anonymous responses differ
GET /api/feed
Authorization: Bearer abc123
→ Vary: Authorization

# Both
GET /api/users/42
→ Vary: Accept, Authorization

The three most common Vary values for JSON APIs:

  • Vary: Accept — when the same URL returns different content types (JSON vs XML vs CSV) based on the Accept header. The cache stores one entry per content type.
  • Vary: Authorization — when authenticated and unauthenticated requests get different bodies. The cache treats logged-in and logged-out requests as separate entries.
  • Vary: Accept-Encoding — added automatically by most servers when they gzip/brotli responses. Ensures compressed and uncompressed bodies are not mixed up.

Avoid Vary: User-Agent unless you have to. Every browser version is a distinct User-Agent string — Chrome 130.0.6723.69 alone is one cache entry, Chrome 130.0.6723.70 is another. Cache fragmentation kills hit rate. If you must vary on device class, normalize server-side and emit a derived header: Vary: X-Device-Class with values like mobile, desktop, tablet.

For more on cache-aware API design, see our REST API design guide, which covers when content negotiation is worth the cache cost.

Expires header — legacy and why Cache-Control wins

Expires predates Cache-Control. It carries an absolute HTTP date — Expires: Sun, 01 Jun 2026 14:00:00 GMT — telling caches the response is fresh until that moment. RFC 9111 keeps it for backward compatibility but says explicitly that Cache-Control: max-age overrides Expires whenever both are present.

# Legacy header — works on every cache ever shipped
Expires: Sun, 01 Jun 2026 14:00:00 GMT

# Modern equivalent — relative TTL, not absolute timestamp
Cache-Control: public, max-age=86400

Three reasons Cache-Control won:

  • Relative is safer than absolute. max-age=86400means "one day from when the cache stored this" — immune to clock skew between origin and cache. Expires is an absolute moment; if origin and cache disagree on what time it is, the entry expires at the wrong time.
  • It expresses more. Cache-Control carries public, private, s-maxage, stale-while-revalidate, no-store — none of which have an Expires equivalent.
  • It is per-cache-tier. Use max-age for browsers, s-maxage for CDNs, optionally CDN-Cache-Control for CDN only. Expires applies uniformly to every cache.

When should you still send Expires? Almost never on a new API. The one case is supporting HTTP/1.0 clients or ancient proxies that ignore Cache-Control entirely — vanishingly rare today. Some teams send both for belt-and-suspenders compatibility; if you do, make sure the two values agree (typically computed from the same TTL).

CDN-Cache-Control and Surrogate-Control for edge tier

CDN-Cache-Control targets only CDNs and edge caches, leaving Cache-Control to control browser behavior. It is the cleanest way to split browser TTL from CDN TTL — short browser cache for responsiveness to updates, long CDN cache for origin protection.

# Browsers cache for 60s; CDN caches for 24h
Cache-Control: max-age=60
CDN-Cache-Control: max-age=86400, stale-while-revalidate=3600

# Effect:
# - Browser revalidates every minute → users see new content fast
# - CDN holds for a day → origin sees ~1 request/day per cached path
# - CDN can do SWR independently of browser SWR

Before CDN-Cache-Control, the standard trick was max-age=60, s-maxage=86400. That still works, but s-maxage applies to every shared cache — a corporate proxy would also hold for 24 hours, which you may not want. CDN-Cache-Control is more specific.

Each CDN has its own preferred header name (precedence order matters when multiple are sent):

CDNPreferred headerFalls back to
CloudflareCDN-Cache-ControlCache-Control (with s-maxage)
FastlySurrogate-ControlCDN-Cache-Control, then Cache-Control
AkamaiEdge-Control / CDN-Cache-ControlCache-Control
VercelCDN-Cache-ControlCache-Control (with s-maxage)
CloudFrontBehavior config + Cache-ControlOrigin Cache-Control

Cloudflare's cf-cache-status response header is the best way to confirm what actually happened at the edge:

HIT          # served from Cloudflare cache, fresh
MISS         # not in cache; fetched from origin
EXPIRED      # was cached, TTL passed, refetched from origin
REVALIDATED  # was stale, origin returned 304, served cached body
STALE        # served stale during background revalidation (SWR)
UPDATING     # like STALE but during a different update path
BYPASS       # cache was bypassed (cookie, query, header rule)
DYNAMIC      # response was not cacheable (e.g., no Cache-Control or Set-Cookie)
NONE/UNKNOWN # request did not pass through Cloudflare cache

When the rate of DYNAMIC is higher than expected, the usual cause is a Set-Cookie in the response — Cloudflare treats cookie-bearing responses as uncacheable by default. Strip the cookie or use Cache Rules to override.

must-revalidate, proxy-revalidate, and no-cache (the subtle ones)

Three directives are routinely confused with each other and with no-store. They all involve revalidation, but each does something slightly different.

  • no-cache — the cache may store the response but must not serve it without first revalidating with the origin. Every reuse triggers a conditional request (If-None-Match with the stored ETag). If the origin returns 304, the cache serves the stored body; if it returns 200, the cache updates and serves the new body.
  • must-revalidate — once an entry goes stale (past its max-age), the cache must revalidate before serving it; the cache is not allowed to serve stale even if the origin is unreachable. This is the strict mode — turns off stale-while-revalidate and stale-if-error behaviors.
  • proxy-revalidate — same as must-revalidate but applies only to shared caches. The browser may still serve stale; the CDN may not. Rarely used in practice.
# Force revalidation on every reuse — ETag does the work
Cache-Control: no-cache

# Cache for 5 minutes, but never serve stale once expired
Cache-Control: max-age=300, must-revalidate

# Combine — cache, revalidate before use, never serve stale on error
Cache-Control: no-cache, must-revalidate

The mental model: max-age controls when the entry becomes stale. no-cache means stale-on-arrival (revalidate every time). must-revalidate controls what the cache does once it is stale (mandatory revalidation, no stale fallback). no-store opts out of caching entirely.

Pair no-cache with a strong ETag and you get the best of both worlds: zero stale risk, minimal bandwidth (304s carry no body), and a single source of truth on the origin. See our ETag + If-None-Match guide for the conditional revalidation pattern.

Cache invalidation strategies: TTL, ETag conditional, surrogate keys

Once a response is cached, you have three ways to get an update out to clients, listed from least to most powerful.

1. TTL expiry (passive). Set a short max-age or s-maxage and accept that updates propagate when the TTL runs out. The laziest pattern, and often the right one for content that updates on a predictable schedule (hourly aggregates, daily reports). No invalidation code to maintain, but updates lag by up to the TTL.

2. ETag conditional revalidation (semi-active). Pair Cache-Control: no-cache with a strong ETag. The cache still stores the response, but every reuse triggers a conditional request. When the body hasn't changed, the origin returns 304 with no body — cheap, fast, keeps the cache warm. When it has changed, the origin returns 200 with the new body and a new ETag. This pattern gives you near-zero staleness with minimal bandwidth, but every request still hits the origin (just with a tiny payload).

3. Explicit purge (active).On every write to the origin data, call the CDN's purge API to evict the relevant cache entries. Three flavors:

  • Purge by URL — list the exact URLs to evict. Works everywhere. Annoying when one write affects many cached URLs.
  • Purge by tag (surrogate keys) — tag responses with Surrogate-Key values, then purge all entries with a given tag. Available on Fastly, Cloudflare Enterprise, and Vercel. Best fit for relational data where one write touches many cached resources.
  • Purge everything — nukes the cache for the entire zone. Use during incidents; never as a regular pattern.
# Origin response with surrogate keys
HTTP/1.1 200 OK
Cache-Control: public, max-age=300, s-maxage=86400
Surrogate-Key: product-123 catalog-v2 region-us
Content-Type: application/json

{"id": 123, "name": "Widget", "price": 19.99}

# On write — purge by tag
POST https://api.fastly.com/service/SERVICE_ID/purge/product-123
Fastly-Key: <api-token>

Combine all three for production: TTL as the safety net (caches eventually self-heal), ETag for cheap revalidation, surrogate keys for instant purge on writes. See our JSON caching guide for the broader picture and the Caching strategies article for the trade-offs between TTL lengths, write-through, and read-through patterns. If your API is cross-origin, the cache headers interact with CORS preflight responses — see the CORS interactions guide for the gotchas.

Express middleware setting cache headers

A typical Node/Express pattern — set headers in a middleware so handlers stay focused on business logic. This middleware picks the right Cache-Control based on a route tag:

// cache-headers.ts
import type { Request, Response, NextFunction } from 'express'

type CachePolicy =
  | { kind: 'public'; maxAge: number; sMaxAge?: number; swr?: number }
  | { kind: 'private'; maxAge: number }
  | { kind: 'no-store' }
  | { kind: 'revalidate' } // no-cache + must-revalidate

export function cache(policy: CachePolicy) {
  return (_req: Request, res: Response, next: NextFunction) => {
    let value: string
    switch (policy.kind) {
      case 'public': {
        const parts = ['public', `max-age=${policy.maxAge}`]
        if (policy.sMaxAge !== undefined) parts.push(`s-maxage=${policy.sMaxAge}`)
        if (policy.swr !== undefined) parts.push(`stale-while-revalidate=${policy.swr}`)
        value = parts.join(', ')
        break
      }
      case 'private':
        value = `private, max-age=${policy.maxAge}`
        break
      case 'no-store':
        value = 'no-store'
        break
      case 'revalidate':
        value = 'no-cache, must-revalidate'
        break
    }
    res.setHeader('Cache-Control', value)
    next()
  }
}

// Usage
app.get('/api/products', cache({ kind: 'public', maxAge: 60, sMaxAge: 3600, swr: 600 }), getProducts)
app.get('/api/me',       cache({ kind: 'private', maxAge: 60 }), getMe)
app.post('/api/checkout', cache({ kind: 'no-store' }), runCheckout)

Next.js Route Handler setting cache headers

Next.js App Router route handlers set response headers via the Response constructor or NextResponse. The cache headers control browser and CDN behavior; the Next.js cache (the framework's own data cache) is configured separately via the fetch API and is layered below the HTTP cache.

// app/api/products/route.ts
import { NextResponse } from 'next/server'

export async function GET() {
  const products = await loadProductsFromDb()
  return NextResponse.json(products, {
    headers: {
      'Cache-Control': 'public, max-age=60, s-maxage=3600, stale-while-revalidate=600',
      'CDN-Cache-Control': 'max-age=3600, stale-while-revalidate=86400',
      'Vary': 'Accept',
    },
  })
}

// app/api/me/route.ts — user-specific, browser-only
export async function GET(req: Request) {
  const user = await getUserFromAuth(req)
  return NextResponse.json(user, {
    headers: {
      'Cache-Control': 'private, max-age=60, must-revalidate',
      'Vary': 'Authorization',
    },
  })
}

// app/api/checkout/route.ts — never cache
export async function POST(req: Request) {
  const result = await runCheckout(await req.json())
  return NextResponse.json(result, {
    headers: { 'Cache-Control': 'no-store' },
  })
}

Key terms

Cache-Control
The primary HTTP cache header (RFC 9111). A comma-separated list of directives controlling whether, where, and for how long a response may be cached. Wins over the legacy Expires header when both are present.
shared cache
Any cache that serves more than one user — CDNs, ISP proxies, corporate proxies. private tells shared caches to skip the response; public permits storage.
private cache
A cache that serves a single user — the browser. private, max-age=N means only the user's own browser caches the response; no CDN or proxy keeps a copy.
stale-while-revalidate (SWR)
RFC 5861 directive that lets a cache serve stale content immediately while asynchronously revalidating with the origin. Optimizes for latency over freshness.
Vary
Response header listing request headers that affect the response body. Caches add the listed headers to the cache key so each variant is stored separately. Without it, header-driven response variation causes cache mix-ups.
s-maxage
Like max-age but applies only to shared caches (CDNs, proxies). Lets you set a long CDN TTL alongside a short browser TTL.
CDN-Cache-Control
A newer header targeting only CDNs and edge caches, leaving Cache-Control to control browser behavior. The cleanest split between browser and CDN TTL.
cf-cache-status
Cloudflare response header reporting what happened at the edge: HIT, MISS, EXPIRED, REVALIDATED, STALE, BYPASS, DYNAMIC. The fastest way to verify cache behavior in production.

Frequently asked questions

What's the difference between no-cache and no-store?

no-cache and no-store sound similar but do very different things. no-store forbids any cache from keeping a copy of the response at all — not the browser, not a shared proxy, not a CDN. Every request must go to the origin. Use it for responses that contain sensitive data (banking balances, one-time tokens, partial PII) where leaving a copy anywhere is a risk. no-cache, despite the name, does not forbid storage — it forbids serving the cached copy without first revalidating with the origin. The cache keeps the response, sends a conditional request (with If-None-Match or If-Modified-Since) on every reuse, and only serves the cached body if the origin returns 304 Not Modified. Practical rule: pick no-store for anything that should never be written to disk; pick no-cache when you want caching plus mandatory revalidation; pick max-age=0, must-revalidate as a slightly different flavor of the same intent.

How do I prevent a JSON API response from being cached?

Send Cache-Control: no-store on the response. That single directive tells every cache in the chain — browser, ISP proxy, CDN, custom reverse proxy — to discard the body and refetch on every request. For defense in depth across older HTTP/1.0 caches that may ignore Cache-Control, also send Pragma: no-cache and Expires: 0. In modern stacks (Cloudflare, Vercel, Fastly, CloudFront) Cache-Control: no-store alone is sufficient — the legacy headers are belt and suspenders. Common cases for opting out of caching: authentication endpoints, payment confirmations, anything that returns a one-time nonce, real-time data where staleness is unacceptable. Note that some CDNs will still cache no-store responses unless you also configure the CDN tier explicitly — see the CDN-Cache-Control section. If you want some caching but not browser caching, use private, max-age=0 to allow shared caches to skip but keep the browser from storing.

What does Cache-Control: public mean for an authenticated endpoint?

public marks a response as shareable across users — any cache (browser, proxy, CDN) is allowed to store one copy and serve it to anyone. For an authenticated endpoint, that is almost always wrong. If the response body varies by user (a profile, an account balance, a personalized feed), public means user A could request the endpoint, populate the CDN with their data, and user B would receive user A&apos;s response. Use private instead — only the user&apos;s own browser caches the body, shared caches skip it. The exception is an authenticated endpoint that returns the same body for every authenticated user (e.g., a public catalog gated behind a login wall): you can use public there, but you must also send Vary: Authorization to ensure unauthenticated requests are not served the authenticated copy. When in doubt on authenticated routes, use private — the cost of over-caching is a leaked response; the cost of under-caching is one extra origin hit.

When should I use stale-while-revalidate?

Use stale-while-revalidate (SWR) when low latency matters more than perfect freshness. With max-age=60, stale-while-revalidate=600, the cache serves fresh content for 60 seconds, then for the next 600 seconds it serves the stale body immediately to the user while asynchronously revalidating with the origin in the background. The user gets a fast response every time; the origin sees fewer requests; the cache stays warm. SWR (RFC 5861) is ideal for: home feeds and dashboards where 1-2 minute staleness is invisible, product catalog endpoints with infrequent updates, leaderboards and trending lists, content APIs where the data updates on a slow tick. Avoid SWR for: anything that must be up-to-the-second (payments, inventory checks at checkout, real-time chat), endpoints where stale data is a correctness bug, and anything you would have marked no-store for security reasons. The sibling directive stale-if-error serves stale content when the origin returns 5xx — useful for resilience during partial outages.

How does s-maxage differ from max-age?

max-age applies to every cache in the chain — the browser, any proxy, the CDN. s-maxage (the s is for shared) applies only to shared caches and overrides max-age for them. This split lets you tell the CDN to keep content for hours while the browser only keeps it for seconds. Example: Cache-Control: public, max-age=60, s-maxage=86400 means browsers cache for 60 seconds and revalidate often (you can purge and users see fresh content within a minute), but the CDN keeps the response for 24 hours and absorbs the bulk of traffic. This is the canonical pattern for high-traffic public JSON APIs: short browser TTL for responsiveness to updates, long CDN TTL for origin protection. s-maxage also implicitly makes the response public — if you set s-maxage, you are telling shared caches they can store and reuse the body. If the response is user-specific, do not use s-maxage; use max-age plus private instead.

Why do I need a Vary header on a JSON endpoint?

The Vary header tells caches which request headers affect the response, so they store separate copies keyed on those headers. Without Vary, a cache uses just the URL as the key — and if your endpoint returns different bodies based on request headers (most do), users will be served each other&apos;s responses. The two most common Vary values for JSON APIs are Vary: Accept (when the same URL returns JSON or XML depending on the Accept header — the cache stores one copy per content type) and Vary: Authorization (when authenticated and unauthenticated requests get different bodies — the cache treats them as separate entries). Vary: Accept-Encoding is added automatically by most servers when they compress responses. Be careful with high-cardinality Vary values like Vary: User-Agent — every browser version becomes a separate cache entry, fragmenting the cache and tanking the hit rate. If you must vary on a high-cardinality input, normalize it server-side and send a derived header (e.g., Vary: X-Device-Class with values like mobile, desktop, tablet).

How do I invalidate a CDN cache for JSON responses?

CDN cache invalidation comes in three flavors, increasingly powerful. TTL expiry is the laziest — set a short max-age or s-maxage and accept that updates propagate when the TTL runs out. Explicit purge is the most common — call the CDN&apos;s purge API (Cloudflare /zones/:id/purge_cache, Fastly /service/:id/purge, CloudFront create-invalidation) with the URL or path you want to evict. Surrogate-key invalidation is the most precise — tag responses with a Surrogate-Key header listing logical groups (e.g., Surrogate-Key: product-123 user-456 catalog-v2), then purge by tag (Fastly, Cloudflare Enterprise, Vercel) without listing every URL. Pattern: on every write, build a list of affected surrogate keys and call purge-by-tag in your write path. Use ETag-based conditional revalidation as a complement — even if the cache serves stale, the next revalidation hits the origin with If-None-Match and gets a 304 most of the time, keeping bandwidth low. See the ETag + If-None-Match guide for the conditional pattern.

What is CDN-Cache-Control and how is it different from Cache-Control?

CDN-Cache-Control is a newer header that targets only CDNs and edge caches, leaving Cache-Control to control browser behavior. Send Cache-Control: max-age=60, CDN-Cache-Control: max-age=86400 and the browser caches for 60 seconds (so users see fresh data quickly) while the CDN caches for a full day (so the origin is shielded from most traffic). Before CDN-Cache-Control, the cleanest way to do this split was max-age plus s-maxage, but s-maxage applies to every shared cache, not just the CDN — a corporate proxy would also keep the response for 24 hours, which may not be what you want. CDN-Cache-Control is more specific. Major CDNs that recognize it: Cloudflare, Fastly, Akamai, Vercel, AWS CloudFront (via behavior config). Some CDNs use their own variant names (Cloudflare also reads Cloudflare-CDN-Cache-Control, Fastly reads Surrogate-Control). Check your CDN docs for the exact header name precedence. Cache-Control remains the universal fallback every cache understands.

Further reading and primary sources