HTTP Cache-Control Headers What no-cache, no-store, and max-age Actually Mean
A practical breakdown of Cache-Control directives — what browsers and CDNs actually do with no-cache, no-store, max-age, s-maxage, and ETags. Including the mistakes that bite most developers in production.
You’ve probably written Cache-Control: no-cache and assumed the browser would skip the cache entirely. It won’t. no-cache means “revalidate before serving from cache.” If you actually want the response to never be stored, that’s no-store. This confusion causes stale data bugs in production that are annoying to diagnose because everything looks fine in the Network tab on first load.
Let’s go through each directive clearly — what browsers do with it, what CDNs do with it, and the mistakes that come from mixing them up.
The Full Cache-Control Directive Reference
| 指令 | Browser behavior | CDN / proxy behavior | Typical use |
|---|---|---|---|
max-age=N | Cache for N seconds | Cache for N seconds (unless s-maxage overrides) | Static assets, API responses |
s-maxage=N | Ignored | Cache for N seconds | CDN TTL separate from browser |
no-cache | Cache but revalidate every request | Cache but revalidate every request | Frequently-changing content with ETags |
no-store | Do not store anywhere | Do not store anywhere | Auth responses, sensitive user data |
must-revalidate | No stale serving — revalidate or fail | No stale serving — revalidate or fail | API responses where stale = broken |
proxy-revalidate | Ignored | No stale serving on shared caches | CDN-specific must-revalidate |
private | Browser may cache | Must not cache | User-specific pages |
public | Any cache may store | May cache | Shared static resources |
immutable | Never revalidate within max-age | Varies by CDN | Hashed / versioned assets |
stale-while-revalidate=N | Serve stale for N seconds while fetching fresh | Varies by CDN | Speed without hard staleness |
max-age and s-maxage: Browser vs CDN TTL
max-age=N tells both the browser and CDNs how many seconds the response is fresh. After N seconds, the cached response is stale and must be revalidated before use.
s-maxage=N is CDN-only. Browsers ignore it entirely. If you want a CDN to cache for an hour but the browser for only 5 minutes:
Cache-Control: max-age=300, s-maxage=3600
The browser caches for 5 minutes. CloudFront, Fastly, Nginx — they’ll use 3600 seconds. A common trap: setting max-age=0 thinking it disables caching. It doesn’t. max-age=0 means the response is immediately stale — the browser still caches it and revalidates, likely getting a 304. If you never want it cached, use no-store.
no-cache: Not What You Think
Cache-Control: no-cache does not mean “don’t use the cache.” It means “don’t serve from cache without revalidating with the server first.”
The sequence when a browser has a cached response with no-cache:
- New request arrives for the cached resource
- Browser sends the cached response’s ETag (via
If-None-Match) or Last-Modified timestamp to the server - If content unchanged → server returns
304 Not Modifiedwith no body - If content changed → server returns
200 OKwith the new response
The benefit over no-store: you still get the bandwidth savings of 304 responses. If your content changes occasionally but must be fresh when it does, no-cache combined with an ETag is the right combination.
no-store: The Real “Don’t Cache This”
Cache-Control: no-store means the response must not be stored anywhere — not in the browser cache, not in a CDN, not in an intermediate proxy. No copy, full stop.
Use this for:
- Authentication responses (login tokens, session data)
- Sensitive personal data
- One-time content (payment confirmations, OTP pages)
One subtlety: no-store doesn’t prevent a page from appearing in the browser’s back-forward cache (bfcache). Browsers keep an in-memory snapshot for navigation performance that’s separate from the HTTP cache. If you need to handle post-logout back-button issues, hook into the pageshow event and check event.persisted.
must-revalidate: No Stale Grace
HTTP caching specs allow caches to serve stale responses when the origin is unreachable — a resilience feature most developers don’t know about. must-revalidate removes that leeway: once a cached response is stale, the cache must revalidate or return a 504. No stale serving under any circumstances.
# Without must-revalidate: CDN may serve stale if origin is slow or down
Cache-Control: max-age=3600
# With must-revalidate: stale = error, not a fallback
Cache-Control: max-age=3600, must-revalidate
Use this for API responses where serving stale data breaks functionality — inventory counts, pricing, auth state — rather than just looks slightly wrong.
private vs public: The CDN Bug That Leaks User Data
private means the response is intended for a specific user. Browsers can cache it; shared caches (CDNs, reverse proxies) must not.
public explicitly allows any cache — including shared — to store the response. Some caches only cache authenticated-request responses if you explicitly mark them public.
The real-world bug this causes: a developer copy-pastes Cache-Control: public, max-age=3600 from a static asset onto a page that includes user-specific data. The CDN caches the response. User B makes the same request and gets User A’s page from cache. This isn’t theoretical — GitHub had a variant of this in 2018. Mark authenticated or user-specific responses private explicitly, even if you think your CDN “knows” not to cache them.
ETags and Conditional Requests
ETags are how the server says “here’s a fingerprint of this response.” The browser stores the ETag alongside the cached response and sends it back on the next request via If-None-Match. If content hasn’t changed, the server returns 304 Not Modified with no body — same freshness enforcement as no-cache, much less bandwidth.
这 no-cache + ETag flow:
→ GET /api/config HTTP/1.1
← HTTP/1.1 200 OK
Cache-Control: no-cache
ETag: "abc123"
[full response body]
→ GET /api/config HTTP/1.1
If-None-Match: "abc123"
← HTTP/1.1 304 Not Modified
[no body — browser uses its cached copy]
Two ETag types:
- Strong ETags (
"abc123") — byte-for-byte identical. Required for CDN range request support. - Weak ETags (
W/"abc123") — semantically equivalent but not necessarily byte-identical. Fine for browser revalidation, not for range requests.
Nginx generates ETags automatically from file mtime and size. Express doesn’t add them by default — use app.set('etag', 'strong') 或 etag middleware explicitly.
Last-Modified and If-Modified-Since
Same concept as ETags but coarser — timestamp-based rather than content-hash-based. The server includes Last-Modified; the browser sends If-Modified-Since on subsequent requests.
The problem: if you redeploy and file modification times update even though content didn’t change, caches invalidate unnecessarily. A content-hash-based ETag won’t have this problem. Use ETags where possible and treat Last-Modified as a fallback for servers that don’t support ETags.
Vary: The Header That Silently Multiplies Your Cache
这 Vary header tells caches that the response may differ based on other request headers. Each unique combination of those headers gets its own cache entry.
Vary: Accept-Encoding
This tells caches to store separate responses for gzip, brotli, and identity encoding. Correct and common. The dangerous one: Vary: Cookie. Every user has a unique cookie set, so every user gets their own cache entry — effectively disabling shared caching. Many frameworks add Vary: Cookie silently. If your CDN cache hit rate is inexplicably low despite generous max-age values, check your response headers for Vary: Cookie sneaking in from session middleware.
Vary: * means “don’t cache this at all” in practice — every request is treated as unique. It’s equivalent to no-store for CDNs.
Cache-Busting with Query Parameters
When you need to force fresh downloads of versioned assets, appending a query param is the standard approach — the query string is part of the URL, so it’s treated as a new resource by both browsers and CDNs:
/app.js?v=2.1.4
/styles.css?hash=a1b2c3d4e5f6
If you’re constructing cache-busting params dynamically from content hashes or version strings that might contain special characters, make sure to percent-encode them before appending. The URL编码器/解码器 handles that quickly if you’re testing or building URLs manually.
The Three Mistakes That Bite Most Developers
1. Using no-cache when you mean no-store. If you’re handling auth responses, logout endpoints, or anything with PII, you want no-store. no-cache leaves data in the browser cache (just flagged as stale); no-store removes the footprint entirely. The difference matters when users share devices.
2. Not setting s-maxage for CDN control. Without s-maxage, your CDN uses max-age. If max-age is short for browser freshness (say, 60 seconds), your CDN caches for 60 seconds too — which probably isn’t what you want. Separate the two TTLs explicitly.
3. public on endpoints that return user data. This one is the security incident, not just a performance bug. Any response that’s personalized or authenticated should be private. Default to private and opt into public only for genuinely shared resources.
整合应用
The mental model: no-cache is about freshness enforcement — the response lives in the cache, it just needs a server stamp of approval before use. no-store is about leaving no trace. max-age is your browser TTL. s-maxage is your CDN’s separate TTL. ETags are what make revalidation cheap.
Get the private/public distinction right on any endpoint that touches user data. That single mistake — copying a cache header from a static asset onto an authenticated endpoint — is the one that turns into a cross-user data leak when your CDN starts caching personalized responses.
