Cache-Control Headers Guide: Browser and CDN Config by Resource

Blog / Edge & CDN

Cache-Control Headers Guide: Browser and CDN Config by Resource

Use Cache-Control: public, max-age=31536000, immutable for hashed static files. Use no-cache for HTML so the browser revalidates with the origin every time. Use private or no-store for per-user data. If you want the CDN to cache something but not the browser, use s-maxage to separate the two layers. Cache-Control is hard to get right because one response header has to govern both the browser cache and the CDN cache. Below, we first explain the directives, then give a config table by resource type, and finally verify the result with curl -I.

Which two cache layers does Cache-Control control?

A request leaving the browser may pass through two layers of cache:

  • Private cache: the browser's own disk and memory cache, which serves only the current user.
  • Shared cache: CDN edge nodes and reverse proxies (Nginx proxy_cache, Varnish, and so on), where the same copy is served to many users.

Most misconfigurations come from conflating the two. For example, if you add public, max-age=600 to a logged-in page, the browser cache is fine, but the CDN may serve user A's page to user B. Conversely, if you add no-store to your HTML, the CDN can never cache it, and all traffic goes back to your origin.

So before you write a cache header, answer two questions: can this response be shown to other people? And after the content changes, how stale can the data be at most?

What is the difference between no-cache, no-store, and max-age?

These directives are the ones most often mixed up. Here is what each means:

DirectiveMeaningCommon misconception
max-age=NThe response is fresh for N seconds and can be used without asking the originAssuming users see a changed file immediately
no-cacheMay be stored, but must be revalidated with the origin before each use (conditional requests)Assuming it means "don't cache"
no-storeNo cache may store itAssuming it is the same as no-cache
privateOnly the browser may cache it; shared caches must notAssuming it keeps sensitive data off disk (it doesn't; the browser still stores it)
publicExplicitly allows shared caches to store it, including responses to requests with AuthorizationAssuming that without it a CDN cannot cache the response
s-maxage=NApplies only to shared caches and overrides max-ageAssuming browsers read it too
must-revalidateOnce stale, must be revalidated with the origin; the stale copy cannot be used directlyAssuming it also forces revalidation before expiry
immutableContent will not change during the freshness lifetime, so even a page refresh need not revalidateAssuming it can be used on its own
stale-while-revalidate=NFor N seconds after expiry, the stale copy may be served while it is updated in the backgroundAssuming every browser and CDN supports it
stale-if-error=NIf the origin errors, a copy up to N seconds past expiry may still be servedSame as above; check the specific CDN's docs for support

A simple way to remember it: no-cache means "ask before using," while no-store means "do not store." For HTML, no-cache plus an ETag means most requests return just a 304 and cost almost no bandwidth, while users are still guaranteed the latest entry page.

When do ETag and Last-Modified come into play?

After no-cache applies or max-age runs out, the cache sends a conditional request to the origin:

  • If the previous response had ETag: "abc123", it sends If-None-Match: "abc123".
  • If the previous response had Last-Modified, it sends If-Modified-Since.

If the origin decides the content has not changed, it returns 304 Not Modified with no body. ETag is more precise than Last-Modified, which only has one-second resolution and can give wrong answers when file timestamps differ across multiple origin servers. If you have several machines behind a load balancer, make sure they generate identical ETags (Nginx by default derives them from modification time and file size, so preserving file times at deploy is enough). Otherwise revalidation will keep failing.

You can verify this by hand:

# Get the ETag on the first request
curl -sI https://example.com/ | grep -i etag
# Request again with the ETag; expect a 304
curl -sI -H 'If-None-Match: "value from the previous step"' https://example.com/ | head -1

How should each resource type be configured? A table you can copy

These are common starting points. Adjust the numbers to match your release frequency:

Resource typeRecommended Cache-ControlNotes
Hashed JS/CSS/fonts (app.3f9a1c.js)public, max-age=31536000, immutableThe filename changes with the content, so an old file is never rewritten
HTML entry pageno-cacheRevalidates with ETag every time; after a release users immediately get the new hashed references
HTML, when you want the CDN to absorb traffic toopublic, max-age=0, s-maxage=60, stale-while-revalidate=30Browser revalidates every time, CDN caches for 60 seconds; purge the CDN cache on release
Images without a hash (/logo.png)public, max-age=86400When you change an image, renaming the file is more reliable than waiting for expiry
User-uploaded images (unique URLs)public, max-age=2592000Fine to make this long as long as URLs are never reused
Public API (leaderboards, status data)public, max-age=0, s-maxage=30, stale-while-revalidate=60CDN absorbs bursts; browsers always get fairly fresh data
Authenticated API, personal pagesprivate, no-cacheBrowser may cache but must revalidate; CDN must not store
Payments, tokens, sensitive personal datano-storeNo copy kept at any layer
robots.txt, sitemap.xmlpublic, max-age=3600Rarely changes; one hour is enough

Two points are easy to miss:

  1. Do not use immutable on files without a hash. The browser will not revalidate for a year, so changes to the file will never show up.
  2. Be careful when a response has Set-Cookie. Many CDNs do not cache responses with Set-Cookie by default, but not all of them. If you give such a response public, s-maxage, you may cache a session cookie and hand it to other people. For public pages, remove unnecessary Set-Cookie headers at the origin.

How do s-maxage and CDN-Cache-Control separate the two layers?

With a single Cache-Control header, s-maxage does the separation: browsers read max-age, and shared caches prefer s-maxage.

Cache-Control: public, max-age=60, s-maxage=3600

This means the browser caches for 1 minute and the CDN caches for 1 hour. After you ship a new version, you purge the CDN cache once, and users wait at most 1 minute to see it.

The catch is that s-maxage applies to every shared cache between your origin and the user, including intermediary proxies you do not control. RFC 9213 defines a CDN-Cache-Control header that only CDNs read; browsers ignore it:

Cache-Control: public, max-age=60
CDN-Cache-Control: max-age=3600

Cloudflare also supports Cloudflare-CDN-Cache-Control, which only Cloudflare reads and which is not forwarded to downstream CDNs. Support for these headers varies by CDN, so check your CDN's official docs before going live, then test it using the method below.

How do you tell whether the CDN got a cache hit with curl?

The most direct method is to request the URL twice in a row and compare the response headers:

curl -sI https://example.com/static/app.3f9a1c.js | grep -iE 'cache-control|age|cf-cache-status|x-cache|etag|last-modified|vary'

Look at these headers:

Response headerHow to read it
AgeHow many seconds the copy has been sitting in the shared cache. If it shows up on the second request and keeps increasing, you got a cache hit
cf-cache-status (Cloudflare)HIT is a hit; MISS means not cached and fetched from origin; EXPIRED means it expired and went back to origin; REVALIDATED means it was revalidated and reused; UPDATING/STALE means a stale copy was served; BYPASS means a rule or header bypassed the cache; DYNAMIC means the resource is not eligible for caching
X-Cache or similarA hit/miss marker commonly used by other CDNs; see that CDN's docs for the exact values
VaryThe cache key will include these request headers. Vary: * or Vary: Cookie can sharply reduce hit rate

A troubleshooting order:

  1. First look at the headers the origin itself returns. Bypass the CDN and request the origin directly (curl -sI --resolve example.com:443:ORIGIN_IP https://example.com/path) to confirm Cache-Control is written correctly.
  2. Then request through the CDN two or three times and see whether cf-cache-status goes from MISS to HIT and whether Age appears.
  3. If it is always MISS: check for Set-Cookie, private, no-store, or query parameters that differ on every request (for example, a timestamp).
  4. If it is always DYNAMIC: Cloudflare by default only caches static files identified by file extension. HTML and APIs need their own cache rules.
  5. If users report they can't see updates: look at Age and max-age to tell whether the browser cache or the CDN cache has not expired.

Note that the same URL is served by different edge nodes in different regions, each with its own independent cache. You may see HIT in one place, while a user in another region can still get a MISS on their first visit. This also matters when measuring CDN speed. How cdnping's probes avoid cache effects on results is described on the methodology page, and a speed comparison of public frontend CDNs is in the CDN speed ranking.

How do you configure Nginx?

An example that separates config by resource type:

# Hashed static assets
location ~* "\.[0-9a-f]{8,}\.(js|css|woff2)$" {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

# Regular images
location ~* \.(png|jpg|jpeg|gif|webp|avif|svg)$ {
    add_header Cache-Control "public, max-age=86400" always;
}

# HTML entry
location / {
    try_files $uri $uri/ /index.html;
    add_header Cache-Control "no-cache" always;
}

# Authenticated API
location /api/me {
    proxy_pass http://backend;
    proxy_hide_header Cache-Control;
    add_header Cache-Control "private, no-cache" always;
}

A few Nginx gotchas:

  • When a regex contains { and }, wrap the whole regex in quotes. Otherwise Nginx treats the braces as the start of a block and nginx -t fails outright.
  • By default, add_header only applies to status codes such as 200, 204, 206, 301, 302 and 304. Add always for it to apply to 4xx/5xx too. If a CDN stores an error page with a long cache time, users will keep seeing the error even after you fix the origin.
  • As soon as a child location has a single add_header, it inherits none of the parent's add_header directives. Security headers defined in the server block often go missing in some location for this reason.
  • expires 30d; generates both Expires and Cache-Control: max-age. Combined with a hand-written add_header Cache-Control, you may end up with two headers, so pick one.
  • If the backend already returns Cache-Control, adding another with add_header produces two headers. Use proxy_hide_header first to remove the backend's.

What should you watch for on Cloudflare?

By default, Cloudflare respects the origin's Cache-Control (including s-maxage, private and no-store), but by default it only caches static resources identified by file extension. To cache HTML or APIs, set "Eligible for cache" for the matching paths in Cache Rules, then choose in the rule:

  • Edge TTL: with "Use cache-control header if present," it follows your s-maxage/max-age. You can also override it in the rule.
  • Browser TTL: you can override the browser-side max-age separately, but do not set it longer than the origin's value, or users will be stuck on the old version after a release.
  • Cache key: stripping meaningless query parameters (such as utm_*) can noticeably improve hit rate.

Do not put paths that carry an Authorization header or a session cookie into an "Eligible for cache" rule, unless you are sure the response is identical for everyone. For AI application scenarios (streaming responses, API rate limiting, origin protection), see Accelerating AI apps with Cloudflare for more detailed configuration.

Pre-launch checklist

  • All build artifacts carry a content hash, and only they use immutable
  • The HTML entry is no-cache, or the CDN-layer cache time is very short and your release process includes a cache purge step
  • All authenticated endpoints are private or no-store; make one unauthenticated curl request to confirm you cannot get someone else's data
  • Public pages do not return Set-Cookie
  • Error responses (404, 500) have an explicit cache time and are not cached long-term
  • Vary includes only necessary headers, usually Accept-Encoding
  • Two consecutive curl -I requests show Age or HIT
  • Hitting the origin directly, bypassing the CDN, returns the expected headers

FAQ

I changed the CSS, but users still see the old styles?

Most likely the filename has no hash and a long max-age is set. The lasting fix is to have your build tool output hashed filenames and switch HTML to no-cache. A stopgap is to purge the CDN cache, but copies already in browsers can only wait to expire.

Will Cache-Control: no-cache make my site slower?

For HTML, the extra cost is one conditional request. When the content is unchanged it returns a 304 with headers only and no body. What actually affects speed is usually connection setup itself. For how much DNS, TCP and TLS each contribute, see the breakdown in AI API latency breakdown, which applies equally to web requests.

Do these headers affect npm mirrors and package downloads too?

Yes. Mirrors and registries generally use different cache policies for package metadata and tarballs: metadata changes, so its cache time is short; tarballs are pinned to a version and can be cached for a long time. If installs are slow, compare against the troubleshooting approach in How to set up an npm registry mirror.

Is this the same as Claude's prompt caching?

No. HTTP caching stores the complete response and is controlled by response headers. Claude API prompt caching reuses the computed result for a prompt prefix on the server side and is controlled by request parameters. For details, see How to use Claude API prompt caching.

If your site serves users in mainland China, CDN choice and node coverage affect the experience just as much; start with How to choose a public CDN for China. For the overall order of network troubleshooting, see the developer network self-check list. If you need edge acceleration built on Cloudflare's global network, see the cdnping Edge page.

Related articles

Keep reading