Browsers and CDNs decide what to cache, and for how long, mostly from the Cache-Control header. Getting it right makes repeat visits fast and offloads your server; getting it wrong serves stale pages or caches private data.
Before you start
- Access to your web server config, or
.htaccesson cPanel hosting. - Background: what is a CDN.
Key directives
| Directive | Meaning |
|---|---|
max-age=N | Fresh for N seconds in browsers and shared caches |
s-maxage=N | Overrides max-age for shared caches (CDNs, proxies) |
public / private | Whether shared caches may store it; private = browser only |
no-cache | May store, but must revalidate before each use |
no-store | Never store (for sensitive data) |
immutable | Will never change during max-age; skip revalidation |
stale-while-revalidate=N | Serve stale for N seconds while fetching a fresh copy |
A sensible strategy
- Fingerprinted assets (e.g.
app.3f9a1c.js):public, max-age=31536000, immutable. - Images and fonts without hashes: a week or a month, e.g.
public, max-age=604800. - HTML pages: short or revalidated, e.g.
public, max-age=0, s-maxage=300, stale-while-revalidate=60so the CDN caches briefly. - Logged-in pages, carts, account data:
private, no-store.
Validators: ETag and Last-Modified
When a cached copy expires, the client asks "has it changed?" with If-None-Match or If-Modified-Since. If not, the server returns 304 Not Modified with no body, saving bandwidth.
Nginx example
location ~* \.(css|js|woff2|svg|png|jpg|webp|avif)$ {
add_header Cache-Control "public, max-age=2592000";
}For hashed build files, use a separate location with one year and immutable. See Nginx performance tuning.
Apache / .htaccess example
<IfModule mod_headers.c>
<FilesMatch "\.(css|js|woff2|svg|png|jpg|webp)$">
Header set Cache-Control "public, max-age=2592000"
</FilesMatch>
</IfModule>On LiteSpeed servers with WordPress, the LiteSpeed Cache plugin sets many of these for you; see LiteSpeed Cache plugin.
Check headers
curl -sI https://example.com/assets/app.css | grep -i -E 'cache-control|age|cf-cache-status|x-cache'CDN headers such as Age or a cache status header show whether the response came from cache.
Troubleshooting
- Users see old CSS after a deploy: long max-age on unversioned files; use fingerprinted filenames or purge the CDN.
- Private data cached by CDN: missing
private/no-store, or the CDN is set to cache everything; fix headers and CDN rules. - Header appears twice: both the app and server set it; keep one source.
Frequently asked questions
no-cache or no-store?
no-cache allows storing with revalidation; no-store forbids storing at all.
Does Expires still matter?
Cache-Control max-age takes precedence; Expires is a legacy fallback.
Do CDN page rules override headers?
Many CDNs can override origin headers with their own rules; check both.
