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:
| Directive | Meaning | Common misconception |
|---|---|---|
max-age=N | The response is fresh for N seconds and can be used without asking the origin | Assuming users see a changed file immediately |
no-cache | May be stored, but must be revalidated with the origin before each use (conditional requests) | Assuming it means "don't cache" |
no-store | No cache may store it | Assuming it is the same as no-cache |
private | Only the browser may cache it; shared caches must not | Assuming it keeps sensitive data off disk (it doesn't; the browser still stores it) |
public | Explicitly allows shared caches to store it, including responses to requests with Authorization | Assuming that without it a CDN cannot cache the response |
s-maxage=N | Applies only to shared caches and overrides max-age | Assuming browsers read it too |
must-revalidate | Once stale, must be revalidated with the origin; the stale copy cannot be used directly | Assuming it also forces revalidation before expiry |
immutable | Content will not change during the freshness lifetime, so even a page refresh need not revalidate | Assuming it can be used on its own |
stale-while-revalidate=N | For N seconds after expiry, the stale copy may be served while it is updated in the background | Assuming every browser and CDN supports it |
stale-if-error=N | If the origin errors, a copy up to N seconds past expiry may still be served | Same 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 sendsIf-None-Match: "abc123". - If the previous response had
Last-Modified, it sendsIf-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 type | Recommended Cache-Control | Notes |
|---|---|---|
Hashed JS/CSS/fonts (app.3f9a1c.js) | public, max-age=31536000, immutable | The filename changes with the content, so an old file is never rewritten |
| HTML entry page | no-cache | Revalidates with ETag every time; after a release users immediately get the new hashed references |
| HTML, when you want the CDN to absorb traffic too | public, max-age=0, s-maxage=60, stale-while-revalidate=30 | Browser revalidates every time, CDN caches for 60 seconds; purge the CDN cache on release |
Images without a hash (/logo.png) | public, max-age=86400 | When you change an image, renaming the file is more reliable than waiting for expiry |
| User-uploaded images (unique URLs) | public, max-age=2592000 | Fine 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=60 | CDN absorbs bursts; browsers always get fairly fresh data |
| Authenticated API, personal pages | private, no-cache | Browser may cache but must revalidate; CDN must not store |
| Payments, tokens, sensitive personal data | no-store | No copy kept at any layer |
robots.txt, sitemap.xml | public, max-age=3600 | Rarely changes; one hour is enough |
Two points are easy to miss:
- Do not use
immutableon files without a hash. The browser will not revalidate for a year, so changes to the file will never show up. - Be careful when a response has
Set-Cookie. Many CDNs do not cache responses withSet-Cookieby default, but not all of them. If you give such a responsepublic, s-maxage, you may cache a session cookie and hand it to other people. For public pages, remove unnecessarySet-Cookieheaders 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 header | How to read it |
|---|---|
Age | How 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 similar | A hit/miss marker commonly used by other CDNs; see that CDN's docs for the exact values |
Vary | The cache key will include these request headers. Vary: * or Vary: Cookie can sharply reduce hit rate |
A troubleshooting order:
- 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 confirmCache-Controlis written correctly. - Then request through the CDN two or three times and see whether
cf-cache-statusgoes fromMISStoHITand whetherAgeappears. - If it is always
MISS: check forSet-Cookie,private,no-store, or query parameters that differ on every request (for example, a timestamp). - If it is always
DYNAMIC: Cloudflare by default only caches static files identified by file extension. HTML and APIs need their own cache rules. - If users report they can't see updates: look at
Ageandmax-ageto 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 andnginx -tfails outright. - By default,
add_headeronly applies to status codes such as 200, 204, 206, 301, 302 and 304. Addalwaysfor 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
locationhas a singleadd_header, it inherits none of the parent'sadd_headerdirectives. Security headers defined in theserverblock often go missing in somelocationfor this reason. expires 30d;generates bothExpiresandCache-Control: max-age. Combined with a hand-writtenadd_header Cache-Control, you may end up with two headers, so pick one.- If the backend already returns
Cache-Control, adding another withadd_headerproduces two headers. Useproxy_hide_headerfirst 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-ageseparately, 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
privateorno-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
-
Varyincludes only necessary headers, usuallyAccept-Encoding - Two consecutive
curl -Irequests showAgeorHIT - 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.
Related reading
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.