Request and response header
< Cache-Control: public, max-age=3600Cache-Control Header: Directives, Examples and CDN Behavior
Cache-Control directives explained: max-age, no-cache vs no-store, s-maxage, stale-while-revalidate, immutable, with nginx, Cloudflare and Next.js examples.
- Direction
- Request and response
- Category
- Caching
- Spec
- RFC 9111 §5.2
- JS can set it
- Yes
- CORS-safelisted
- No: may trigger a preflight
On this page
TL;DR:
Cache-Controlsets how long a response stays fresh and who may store it. Usepublic, max-age=31536000, immutablefor hashed assets,no-cachefor HTML you want revalidated,privatefor per-user data, andno-storeonly for responses that must not be kept.
How a cache decides
For every stored response a cache, whether browser or CDN, asks three questions: is it still fresh (max-age, s-maxage, Expires), may it be reused for this request (Vary, private), and if stale, can it be revalidated (ETag, Last-Modified). Cache-Control drives all three. Directives are comma-separated, case-insensitive, and unknown ones must be ignored (RFC 9111 section 5.2).
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
ETag: "v42"
Response directives
| Directive | Effect |
|---|---|
max-age=N | Fresh for N seconds from generation time; Age counts against it |
s-maxage=N | Like max-age, but only for shared caches (CDNs, proxies); overrides max-age there |
no-cache | May be stored, but must be revalidated with the origin before every reuse |
no-store | Caches must not store any part of the request or response |
private | Only a private cache (a single user’s browser) may store it |
public | Explicitly lets shared caches store it, including when the request carried Authorization; otherwise rarely needed |
must-revalidate | Once stale, must not be served without successful revalidation; if the origin is unreachable the cache answers 504 |
proxy-revalidate | Same as must-revalidate, but applies only to shared caches |
must-understand | A cache may store a response with an unrecognised status code only if it understands that code; otherwise it behaves as no-store (RFC 9111 section 5.2.2.3) |
no-transform | Intermediaries must not alter the body or Content-Encoding/Content-Type; stops proxies that recompress images or minify |
immutable | Body will not change while fresh, so skip revalidation on reload (RFC 8246) |
stale-while-revalidate=N | Serve stale up to N seconds past expiry while refreshing in the background (RFC 5861) |
stale-if-error=N | Serve stale up to N seconds when the origin errors or is unreachable (RFC 5861) |
Request directives are no-cache, no-store, max-age, max-stale, min-fresh, only-if-cached and no-transform. In the browser, fetch(url, { cache: 'no-store' }) maps onto them.
no-cache vs no-store
The names are the most misleading in HTTP. no-cache means “keep it, but ask first”:
GET /app HTTP/1.1
If-None-Match: "v42"
HTTP/1.1 304 Not Modified
no-store means “do not keep this”. RFC 9111 section 5.2.2.5 names it as the directive for sensitive content. More in no-cache vs no-store.
A widespread snippet is no-store, no-cache, must-revalidate with Pragma: no-cache and Expires: 0. Only no-store does the work. Pragma: no-cache is defined only as a request header (RFC 9111 section 5.4) and survives for HTTP/1.0 proxies. no-store is an instruction to compliant caches, not a vault: it does not stop a user screenshotting a page, and a page may be restored from the browser’s back/forward cache, so test logout flows explicitly.
Freshness, heuristics and Age
With max-age, s-maxage or Expires, freshness is explicit. Without any of them a cache may compute a heuristic lifetime (RFC 9111 section 4.2.2), commonly 10% of the time since Last-Modified. A response with only Last-Modified can therefore be reused for hours or days without contacting the origin. Always send an explicit policy.
Age is how many seconds the response has already spent in caches. A CDN reporting Age: 120 on a max-age=300 response has 180 seconds of freshness left.
stale-while-revalidate and stale-if-error
Cache-Control: public, max-age=60, stale-while-revalidate=300, stale-if-error=86400
From 0 to 60 seconds the response is served fresh. From 60 to 360 seconds it is served stale instantly while a background request refreshes it. After that the client waits for the origin. If the origin returns a 5xx within a day, the stale copy is served instead of the error. Browsers honour stale-while-revalidate in Chrome 75+, Firefox 68+ and Safari 14+; stale-if-error matters mostly to CDNs and other shared caches. CloudFront supports both directives (it caps them at the cache policy’s maximum TTL), and Cloudflare supports both with two caveats: its documentation says s-maxage disables stale-while-revalidate, and stale-if-error is ignored when Always Online is enabled.
Shared-cache and CDN-only control
s-maxage gives CDNs a longer TTL than browsers:
Cache-Control: public, max-age=60, s-maxage=3600
RFC 9213 defines targeted fields so one response can carry separate policies per cache layer. CDN-Cache-Control addresses CDNs generally, and vendors add their own, such as Cloudflare-CDN-Cache-Control and Surrogate-Control. A targeted field overrides Cache-Control for the cache that reads it.
Cache-Control: no-cache
CDN-Cache-Control: max-age=3600
Browsers revalidate every time while the CDN holds the page for an hour. Which header your CDN reads varies, so confirm in its documentation.
Recipes
Hashed static assets, where the filename changes with the content:
Cache-Control: public, max-age=31536000, immutable
HTML entry points referencing those assets:
Cache-Control: no-cache
ETag: "9f2c"
Per-user API responses:
Cache-Control: private, max-age=60
Vary: Authorization
Account pages, tokens, anything that must not be kept:
Cache-Control: no-store
Public data, fast with background refresh:
Cache-Control: public, max-age=60, stale-while-revalidate=300, stale-if-error=3600
By stack
nginx. expires 1y already emits Expires and Cache-Control: max-age=31536000. Adding a second add_header Cache-Control produces two Cache-Control headers, which caches combine; keep one source of truth. Remember add_header in a location discards all add_header lines inherited from the enclosing server block.
location ~* \.(?:js|css|woff2|png|jpg|svg)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
add_header Cache-Control "no-cache";
}
Next.js. Files under /_next/static already get public, max-age=31536000, immutable. Pages and route handlers need explicit values, either in the response or in config:
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/api/articles',
headers: [
{ key: 'Cache-Control', value: 'public, s-maxage=60, stale-while-revalidate=300' }
]
}
]
}
}
Cloudflare. By default it caches by file extension (static assets), not HTML or JSON, and its Browser Cache TTL setting can override the browser-facing max-age. To cache HTML, create a Cache Rule (“Eligible for cache”) and choose “Use cache-control header if present” or set an Edge TTL. Read CF-Cache-Status: HIT, MISS, DYNAMIC (not eligible), BYPASS, REVALIDATED, EXPIRED.
CloudFront. A cache policy sets minimum, default and maximum TTL. The origin’s max-age is clamped between minimum and maximum, so a non-zero minimum TTL can cache responses the origin marked no-cache. Look for X-Cache: Hit from cloudfront and the Age header.
Check what you are getting
curl -sI https://example.com/app.js | grep -iE '^(cache-control|age|etag|expires|vary|cf-cache-status|x-cache)'
cache-control: public, max-age=31536000, immutable
age: 4821
cf-cache-status: HIT
Test the revalidation round trip:
curl -sI https://example.com/ -H 'If-None-Match: "9f2c"' | head -1
HTTP/2 304 means validation works. In Chrome DevTools, “Disable cache” sends Cache-Control: no-cache on requests, which can hide caching bugs while DevTools is open.
Common mistakes
- Caching personalised responses with
public, or behind a CDN rule that ignoresVaryandSet-Cookie, so one user receives another’s page. - A long
max-ageon a URL that is not versioned. Browsers will not ask again until it expires and you cannot recall the copy. - Mixing
Expiresandmax-agewith different values. Whenmax-ageis presentExpiresis ignored, so keep only one. - Omitting
Vary: Accept-EncodingorVary: Originwhen the body differs, which poisons shared caches. See Vary. - Using
no-cacheand expecting no stored copy. The response is stored; it is just revalidated.
Related
- ETag, Last-Modified, Expires, Age, Vary
- If-None-Match and 304 Not Modified
- HTTP caching guide and the Cache builder
In practice
Express.js
// Hashed static assets: cache for 1 year
app.use('/static', express.static('public', {
maxAge: '1y',
immutable: true
}))
// Per-user API response: browser only, 60s
app.get('/api/profile', (req, res) => {
res.set('Cache-Control', 'private, max-age=60')
res.json(req.user)
})
// Must not be stored
app.get('/api/account/balance', (req, res) => {
res.set('Cache-Control', 'no-store')
res.json({ balance: 1234.56 })
})
// Public content with background refresh
app.get('/api/articles', (req, res) => {
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
res.json(articles)
})Next.js App Router
// app/api/articles/route.ts
export async function GET() {
const articles = await db.articles.findAll()
return Response.json(articles, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300'
}
})
}nginx
# Versioned assets. Do not combine this with `expires`, which would add a
# second Cache-Control header.
location ~* \.(?:js|css|png|jpg|svg|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# HTML: always revalidate
location / {
add_header Cache-Control "no-cache";
}Frequently asked questions
What is the difference between no-cache and no-store?
no-cache lets a cache store the response but forces it to revalidate with the origin before every reuse, usually producing a 304. no-store tells caches not to store the response at all. Use no-store for responses containing secrets and no-cache for HTML that must always be current.
How do I prevent a response from being cached?
Send Cache-Control: no-store. It is the only directive RFC 9111 defines for this, and the extra no-cache, must-revalidate, Pragma and Expires values in older snippets add nothing for modern caches. It is an instruction to compliant caches, not a security boundary: a browser may still hold the page in memory for the current session.
What does max-age=0 do?
It makes the response stale immediately, so a cache may store it but has to revalidate before reuse unless it is permitted to serve stale content. Add must-revalidate, or use no-cache, when you need a firm guarantee that a stale copy is never served.
Why is my Cache-Control header ignored by Cloudflare or CloudFront?
CDNs apply their own rules first. Cloudflare does not cache HTML or JSON by default whatever the header says, and CloudFront clamps the origin max-age between the minimum and maximum TTL in its cache policy. Check CF-Cache-Status or X-Cache on the response to see what the edge actually did.
What do stale-while-revalidate and stale-if-error do?
stale-while-revalidate=N lets a cache serve a stale response for up to N seconds past expiry while it refetches in the background. stale-if-error=N lets it serve stale for up to N seconds when the origin returns a 5xx error or is unreachable. Both come from RFC 5861 and are referenced in RFC 9111 section 4.2.4.
Does immutable mean the browser never revalidates?
It means a browser should not revalidate a still-fresh response when the user reloads the page. It does not extend freshness: once max-age has elapsed the response is stale like any other. Firefox and Safari honour it; Chromium already avoids revalidating subresources on a normal reload.
Sources
- MDN Web Docs: Cache-Controldeveloper.mozilla.org
- RFC 9111 section 5.2: Cache-Controlrfc-editor.org
- RFC 5861: stale-while-revalidate and stale-if-errorrfc-editor.org
- RFC 8246: The immutable Cache-Control Extensionrfc-editor.org
- RFC 9213: Targeted HTTP Cache Control (CDN-Cache-Control)rfc-editor.org
- web.dev: Prevent unnecessary network requests with the HTTP Cacheweb.dev
Related
Cache-Control: no-cache vs no-store
Compare the no-cache and no-store Cache-Control response directives and choose the correct policy for sensitive or frequently changing responses.
CDN (Content Delivery Network)
A CDN is a network of edge servers that cache and serve responses near users. See how cache hits show up in headers and what CDNs do not cache by default.
CDN Not Caching: cf-cache-status DYNAMIC, BYPASS, MISS
CDN not caching? Read cf-cache-status DYNAMIC, BYPASS, MISS or X-Cache: Miss from cloudfront, then fix Set-Cookie, Cache-Control, Vary and cache keys.
TTFB (Time to First Byte)
TTFB is the time from starting a request until the first byte of the response arrives. Learn what it includes, how to measure it and what usually inflates it.
Access-Control-Max-Age Header
Learn how Access-Control-Max-Age specifies how long browsers can cache CORS preflight results. Reduce preflight requests and improve cross-origin performance.