Cache-Control tells browsers and CDNs whether they may keep a copy of a response and for how long. Get it right and repeat visits load instantly. Get it wrong and users see yesterday's JavaScript, or worse, someone else's account page from a shared cache.
| Directive | Meaning |
|---|---|
max-age=600 | Fresh for 600 seconds. Until then the browser uses its copy without asking. |
s-maxage=3600 | Like max-age, but only for shared caches (CDNs, proxies). Overrides max-age there. |
no-cache | May be stored, but must be checked with the server before every use. Not "don't cache". |
no-store | Never store a copy anywhere. This is the real "don't cache". |
private | Only the user's own browser may store it; CDNs and proxies must not. |
public | Any cache may store it, even responses that normally aren't cached, like ones to requests with an Authorization header. |
must-revalidate | Once stale, never use it without checking the server first, even if the server is down. |
immutable | This will never change while fresh, so don't even revalidate when the user reloads. |
stale-while-revalidate=60 | After it goes stale, keep serving the old copy for up to 60 seconds while fetching a new one in the background. |
The most common mistake: reading no-cache as "don't cache". It means "cache, but always ask first". A server can then answer 304 Not Modified with no body, which is fast. If the response must never be written to disk, use no-store.
| Response | Cache-Control | Why |
|---|---|---|
| HTML pages | no-cache | Users get new deploys immediately; unchanged pages still come back as a quick 304. |
JS/CSS/images with a hash in the name (app.3f9a2c.js) | public, max-age=31536000, immutable | A new version gets a new file name, so the old one can be kept for a year. |
JS/CSS without a hash (app.js) | no-cache | Otherwise users keep the old file until max-age runs out. |
| Logged-in pages, account data | private, no-store | Keeps it out of shared caches and off the disk. |
| Public API responses | public, max-age=60, stale-while-revalidate=300 | A minute of caching absorbs traffic spikes; users never wait on a refresh. |
| Private API responses | private, no-cache or no-store | Depends on how sensitive the data is. |
Setting it in nginx and Express:
# nginx
location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; }
location / { add_header Cache-Control "no-cache"; }
// Express
app.use("/assets", express.static("dist/assets", { immutable: true, maxAge: "1y" }));
app.get("/api/me", (req, res) => { res.set("Cache-Control", "private, no-store"); /* ... */ });
Open DevTools (F12), go to Network, click the request and look under Response Headers. The Size column says (disk cache) or (memory cache) when the browser didn't contact the server at all. From the command line:
curl -sI https://example.com/app.js | grep -i -E "cache-control|age|etag|last-modified"
An Age header means a CDN served its cached copy, and how many seconds old it is.
Age or a CDN hit header. Purge the path in your CDN's dashboard.Expires header is ignored when max-age is present, but a proxy adding its own Cache-Control can override yours.DevTools' Disable cache checkbox works only while DevTools is open. To keep getting fresh answers from one site while you work, send Cache-Control: no-cache and Pragma: no-cache on your requests to it.
Add request headers like Cache-Control: no-cache to the sites you choose, or override a response's Cache-Control to see how your page behaves with different caching, and switch it off with one click. It shows whether each rule fired. No account, no tracking.
Related: hard refresh in Chrome · HTTP headers list · add a request header in Chrome.
More on this topic: iframe "refused to connect": what causes it and how to fix it · “Refused to load … violates the following Content Security Policy directive”: fixes · Mixed content error: “loaded over HTTPS, but requested an insecure resource” · How to change your user agent in Chrome (and what it really changes)