---
title: "Cache-Control Headers Guide: Browser and CDN Config by Resource"
description: "Learn how no-cache, no-store, max-age, s-maxage and immutable differ, get a browser and CDN config table by resource type, and verify cache hits with curl."
url: https://cdnping.org/en/blog/cache-control-headers-guide-cdn
language: en
published: 2026-10-21
category: "Edge & CDN"
tags: ["Cache-Control","HTTP caching","CDN","Nginx","Cloudflare"]
source: cdnping.org
license: Free to quote and summarize with a link to the url above.
---
# 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 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:

```bash
# 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:

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:

```bash
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:

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](/en/methodology) page, and a speed comparison of public frontend CDNs is in the [CDN speed ranking](/en/benchmark/cdn).

## How do you configure Nginx?

An example that separates config by resource type:

```nginx
# 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](/en/blog/cloudflare-edge-for-ai-apps) 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](/en/blog/api-latency-connect-rtt-ttft), 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](/en/blog/npm-registry-mirror-china-setup).

### 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](/en/blog/claude-prompt-caching-save-cost).

## 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](/en/learn/choose-public-cdn-china). For the overall order of network troubleshooting, see the [developer network self-check list](/en/blog/developer-network-self-check-2026). If you need edge acceleration built on Cloudflare's global network, see the [cdnping Edge](/en/edge) page.
