HTTP Caching Headers: Cache-Control, ETags, and Getting It Right
Most caching bugs are header bugs. How Cache-Control directives actually behave (max-age, s-maxage, no-cache vs no-store, private, immutable, stale-while-revalidate), how ETags and 304s work, Vary, and a practical header policy for static assets, HTML, APIs, and personalised pages.
When users see an old version after a deploy, or a CDN serves one user's dashboard to another, or your API hammers the database with requests for data that never changes, the root cause is usually the same: HTTP caching headers that don't say what you meant. (What Is Caching? covers the concepts; this is the mechanics.)
Who caches
- Browser cache — private to one user.
- Shared caches — CDNs and reverse proxies in front of your app, serving many users from one stored response. (What Is a CDN?.)
The headers tell each kind what it may do. Getting "private vs shared" wrong is the dangerous mistake.
Cache-Control directives that matter
| Directive | Meaning |
|---|---|
max-age=N |
Fresh for N seconds; reuse without asking the server |
s-maxage=N |
Like max-age, but only for shared caches (overrides max-age there) |
public |
Shared caches may store it, even if it would otherwise be considered private |
private |
Only the browser may store it; CDNs must not |
no-cache |
May be stored, but must revalidate with the server before each use |
no-store |
Must not be stored anywhere |
must-revalidate |
Once stale, must not be used without revalidating |
immutable |
Won't change while fresh; don't even revalidate on reload |
stale-while-revalidate=N |
May serve stale for up to N seconds while fetching a fresh copy in the background |
stale-if-error=N |
May serve stale for up to N seconds if the origin errors |
Two frequent confusions:
no-cachedoes not mean "don't cache." It means "always check first." Useno-storefor "never keep this."- No header is not "no caching." Without explicit freshness, browsers and caches may apply heuristic caching — for example, reusing a response for a fraction of the time since its
Last-Modifieddate. Always be explicit.
Revalidation: ETags and 304
When a cached response is stale (or marked no-cache), the client can ask "has it changed?" instead of re-downloading:
# First response
HTTP/1.1 200 OK
ETag: "a7f3c9"
Cache-Control: no-cache
# Later request
GET /api/catalog
If-None-Match: "a7f3c9"
# If unchanged
HTTP/1.1 304 Not Modified
A 304 has no body — cheap on bandwidth. But note it still costs a round trip and, unless your server can compute the ETag cheaply, a database hit. For truly hot endpoints, freshness (max-age) beats revalidation.
Last-Modified / If-Modified-Since is the older, timestamp-based equivalent with one-second granularity.
Vary: the cache key
A shared cache keys responses by URL. If your response differs by request header, say so:
Vary: Accept-Encoding
Common cases: Accept-Encoding (compression — usually handled by your server/CDN), Accept-Language if you negotiate language. Avoid Vary: Cookie or Vary: Authorization on shared caches as a way to cache personalised content: it fragments the cache into one entry per user, and many CDNs treat it as uncacheable anyway. Personalised responses should simply be private.
A practical header policy
Fingerprinted static assets (app.3f9a2c.js, hashed images, fonts)
Cache-Control: public, max-age=31536000, immutable
The filename changes when content changes, so the URL is effectively a version. Cache forever. Frameworks (Next.js /_next/static, Vite's assets/) emit hashed names for exactly this reason.
HTML documents
Cache-Control: no-cache
Or a short shared lifetime for public pages:
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300
HTML references the hashed assets, so it must stay fresh — otherwise users load old HTML that points at old (possibly deleted) bundles after a deploy. This is the root of most "users see the old version" bugs. (Zero-Downtime Deploys covers version skew.)
Public, shared API data (catalogue, blog index)
Cache-Control: public, max-age=60, stale-while-revalidate=600
A minute of staleness is usually fine, and stale-while-revalidate means users never wait on the refresh.
Personalised pages and user-specific API responses
Cache-Control: private, no-cache
Browser may keep it for back/forward navigation but must revalidate; no shared cache may store it.
Sensitive data (account details, tokens, anything with PII you don't want on disk)
Cache-Control: no-store
The dangerous mistake
A response that includes user-specific data and is cacheable by a shared cache:
# ❌ /api/me served with:
Cache-Control: public, max-age=300
The first user's response is stored at the CDN and served to everyone else for five minutes. Also watch for:
- Frameworks or CDN rules that cache "all
GETrequests" by default. Set-Cookieon cacheable responses (many CDNs won't cache these, but don't rely on it).- Edge rules that ignore origin headers and force caching by path.
Audit every authenticated endpoint's Cache-Control. A test that requests /api/me as two users through the CDN is cheap insurance.
Debugging
curl -sI https://example.com/app.js | grep -i -E 'cache-control|etag|age|vary|x-cache|cf-cache-status'
Age— seconds the response has been in a shared cache.- CDN status headers (
X-Cache,CF-Cache-Status, and similar) — HIT, MISS, EXPIRED, BYPASS. - DevTools Network tab: "(disk cache)" / "(memory cache)" in the Size column; tick Disable cache to rule caching out.
Checklist
- Hashed assets:
public, max-age=31536000, immutable - HTML:
no-cacheor shorts-maxagewithstale-while-revalidate - Every authenticated response is
privateorno-store - Explicit
Cache-Controlon every response — no heuristic caching - ETags on revalidated resources are cheap to compute
- CDN rules respect origin headers; tested with two users
EasySpawn serves your app through a reverse proxy that respects the Cache-Control headers your app sets, on your own domain with automatic SSL — so the header policy you write is the one that runs. See how it works or join the waitlist.
Related: What Is Caching? · Self-Hosting Next.js Without Vercel · Preventing Cache Stampedes
Keep reading
Reverse Proxies Explained: Nginx, Caddy, and Traefik in Front of Your App
A reverse proxy sits between the internet and your app, handling TLS, routing, compression, and more. What reverse proxies do, how Nginx, Caddy, and Traefik differ, forwarded headers and trusting the real client IP, WebSockets and streaming, timeouts and body limits, and common 502/504 causes.
Secrets Management Beyond .env Files
.env files are fine on a laptop and fragile everywhere else. Where secrets should live in production and CI, secret managers vs platform env vars, OIDC to remove long-lived CI credentials, rotation, least privilege, keeping secrets out of logs and AI agent context, and a practical maturity path.