Cache-Control is one of those HTTP headers where the name lies to you. no-cache does not mean "don't cache." max-age=0 and no-store are not the same thing. And if you think you're setting a caching policy for the browser when your server sends a single Cache-Control header, you might be inadvertently setting policy for every CDN edge node between you and the client as well.
Here's what the directives actually do.
max-age: the most common, the most misunderstood
max-age=N tells the cache that a stored response is fresh for N seconds from the time the request was made. During that window, the cache serves the stored copy without contacting the origin server at all — no request, no 304, nothing. After N seconds the response is stale, and the cache must revalidate.
The thing most guides skip: max-age in the response applies to every cache in the chain — the browser cache, any shared proxy, your CDN. If you want to set a different TTL for the CDN versus the browser, you need s-maxage:
Cache-Control: max-age=60, s-maxage=3600
This tells the browser to treat the response as fresh for 60 seconds, while CDN nodes and shared proxies can serve it for up to an hour. Without s-maxage, your CDN obeys max-age alongside every other cache.
no-cache: "always check, but use what you have if it's still valid"
no-cache does not disable caching. It instructs the cache to revalidate on every request — it must make a conditional request (If-None-Match or If-Modified-Since) to the origin before serving the cached copy. The origin replies with either a full 200 (the cache was stale, here's the new content) or a lightweight 304 (the cache is still valid, use it).
This is the right directive for resources where freshness matters but bandwidth efficiency does too. A 304 Not Modified response carries no body — just headers — so a revalidating cache still saves the payload transfer while guaranteeing the response is current.
no-store: the one that actually disables caching
no-store is what most people think no-cache means. It tells every cache — browser, CDN, proxy — never to store the response at all. No caching, no revalidation, no conditional requests. Every request goes to the origin and gets a full response.
Use it for sensitive content (banking dashboards, medical records, anything personalized that must not land on a shared cache). Be deliberate: it carries a real cost in traffic and latency, because no amount of infrastructure caching can help you.
private vs public
private restricts caching to the end-user's browser only — CDNs and shared proxies must not store it. The canonical use case is any response personalized to a specific user (their account page, their shopping cart); caching that on a shared CDN node would serve one user's data to another.
public does the opposite: any cache may store it, including shared proxies. Resources like static assets are already cacheable without public, but explicitly marking them public lets some CDNs apply additional optimizations.
The directives together
The most common real-world patterns:
| Goal | Directive |
|---|---|
| Cache forever (immutable versioned asset) | max-age=31536000, immutable |
| CDN caches, browser revalidates every time | no-cache, s-maxage=3600 |
| Nobody caches this | no-store |
| Browser caches, CDN does not | private, max-age=300 |
| Revalidate, but share the cached copy | public, no-cache |
immutable (supported in all modern browsers and most CDNs) is worth knowing: it tells the cache the resource will never change, so skip the revalidation entirely even after max-age expires. Pair it with content-hashed URLs and you get perfect long-term caching with no stale-content risk.
Compute your TTL
Before you write a max-age value, convert it from whatever human unit you're thinking in to seconds — max-age only takes seconds, not "24h" or "30d". Our Cache TTL Calculator converts hours, days, and weeks to the exact second count for any max-age or s-maxage you need, and works backward from a seconds value if you're reading someone else's headers.
