Good caching makes repeat visits nearly instant, and cuts server load and bandwidth. Bad caching either wastes that opportunity, or serves stale content after a deploy. The behaviour is controlled by a few HTTP response headers. Here’s what each does, and sensible settings for typical sites.
The main headers
| Header | Purpose |
|---|---|
| Cache-Control | The modern, primary caching instruction |
| Expires | Older absolute expiry date (ignored when Cache-Control: max-age exists) |
| ETag | A fingerprint of the response, for revalidation |
| Last-Modified | Modification date, for revalidation |
| Vary | Which request headers change the response (e.g. Accept-Encoding, Accept) |
Cache-Control directives
| Directive | Meaning |
|---|---|
max-age=N |
Fresh for N seconds (browsers and shared caches) |
s-maxage=N |
Freshness for shared caches (CDNs, proxies) only |
public |
May be stored by shared caches |
private |
Only the user’s browser may store it (personalised pages) |
no-cache |
May be stored, but must be revalidated before each use |
no-store |
Never store (sensitive data) |
immutable |
Will never change during its lifetime: don’t even revalidate |
stale-while-revalidate=N |
Serve stale for N seconds while refreshing in the background |
no-cache doesn’t mean “don’t cache”. It means “check with the server first”. Use no-store for truly sensitive responses.
Revalidation: ETag and Last-Modified
When a cached copy is stale (or no-cache), the browser asks: “has this changed?” (If-None-Match: <etag>, or If-Modified-Since). If not, the server replies 304 Not Modified, with no body. That’s fast and cheap. Nginx sends ETag and Last-Modified for static files automatically.
A practical strategy
| Content | Cache-Control |
|---|---|
Hashed assets (app.4f2a1c.js, style.9b8e.css) |
public, max-age=31536000, immutable |
| Unhashed static files (images, fonts) | public, max-age=2592000 (30 days) |
| HTML pages | no-cache (always revalidate), or a short max-age with stale-while-revalidate |
| Logged-in / personalised pages | private, no-cache |
| Sensitive responses (account data, tokens) | no-store |
| APIs | Usually no-store or short max-age, depending on the data |
The key trick: hashed file names (cache busting). Most build tools (Vite, webpack, Astro, Django ManifestStaticFilesStorage, Laravel Mix/Vite) add a content hash, so you can cache assets “forever”, and a deploy simply references new names. See Django static files.
Nginx configuration
# hashed build assets
location /assets/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# images and fonts
location ~* \.(png|jpe?g|gif|webp|avif|svg|ico|woff2?)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# HTML: always revalidate
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
Note: add_header inside a location replaces headers inherited from the server block. Repeat security headers there, or use an include file. See Nginx static file caching and Nginx security headers.
CDNs
CDNs follow Cache-Control (and s-maxage), though many have their own rules, and don’t cache HTML by default. Purge the CDN after deploys if HTML is edge-cached. See CDN for websites and WordPress CDN setup.
Test your headers
curl -sI https://example.com/assets/app.4f2a1c.js | grep -iE 'cache-control|etag|last-modified|expires|age'
curl -sI https://example.com/ -H 'If-None-Match: "<etag>"' | head -1 # expect 304
In the browser’s DevTools Network tab, the “Size” column shows “(disk cache)” or “(memory cache)” for cached responses. Lighthouse flags “Serve static assets with an efficient cache policy”. See web performance testing.
Common mistakes
- Long
max-ageon unhashed CSS/JS: users keep old files after a deploy. - Caching personalised HTML as
public: a CDN may show one user’s page to another. no-storeeverywhere: no caching benefit at all.- Forgetting
Varywhen responses differ by header (compression, image format).
Quick reference
| Goal | Header |
|---|---|
| Cache forever (hashed) | public, max-age=31536000, immutable |
| Always check freshness | no-cache |
| Never store | no-store |
| Browser only | private |
| CDN-specific lifetime | s-maxage=N |
| Revalidation | ETag / Last-Modified → 304 |
Related: website speed optimization and minify CSS and JS.