How HTTP Works

Request and response header

< Cache-Control: public, max-age=3600

Cache-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.

Reviewed 6 min readintermediate6 sourcesTry itMarkdown
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-Control sets how long a response stays fresh and who may store it. Use public, max-age=31536000, immutable for hashed assets, no-cache for HTML you want revalidated, private for per-user data, and no-store only 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

DirectiveEffect
max-age=NFresh for N seconds from generation time; Age counts against it
s-maxage=NLike max-age, but only for shared caches (CDNs, proxies); overrides max-age there
no-cacheMay be stored, but must be revalidated with the origin before every reuse
no-storeCaches must not store any part of the request or response
privateOnly a private cache (a single user’s browser) may store it
publicExplicitly lets shared caches store it, including when the request carried Authorization; otherwise rarely needed
must-revalidateOnce stale, must not be served without successful revalidation; if the origin is unreachable the cache answers 504
proxy-revalidateSame as must-revalidate, but applies only to shared caches
must-understandA 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-transformIntermediaries must not alter the body or Content-Encoding/Content-Type; stops proxies that recompress images or minify
immutableBody will not change while fresh, so skip revalidation on reload (RFC 8246)
stale-while-revalidate=NServe stale up to N seconds past expiry while refreshing in the background (RFC 5861)
stale-if-error=NServe 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 ignores Vary and Set-Cookie, so one user receives another’s page.
  • A long max-age on a URL that is not versioned. Browsers will not ask again until it expires and you cannot recall the copy.
  • Mixing Expires and max-age with different values. When max-age is present Expires is ignored, so keep only one.
  • Omitting Vary: Accept-Encoding or Vary: Origin when the body differs, which poisons shared caches. See Vary.
  • Using no-cache and expecting no stored copy. The response is stored; it is just revalidated.

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

  1. MDN Web Docs: Cache-Controldeveloper.mozilla.org
  2. RFC 9111 section 5.2: Cache-Controlrfc-editor.org
  3. RFC 5861: stale-while-revalidate and stale-if-errorrfc-editor.org
  4. RFC 8246: The immutable Cache-Control Extensionrfc-editor.org
  5. RFC 9213: Targeted HTTP Cache Control (CDN-Cache-Control)rfc-editor.org
  6. web.dev: Prevent unnecessary network requests with the HTTP Cacheweb.dev

Keep going

Browse /search