All posts
5 min read

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.

infrastructurearchitecturedeploymentintermediate

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-cache does not mean "don't cache." It means "always check first." Use no-store for "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-Modified date. 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 GET requests" by default.
  • Set-Cookie on 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-cache or short s-maxage with stale-while-revalidate
  • Every authenticated response is private or no-store
  • Explicit Cache-Control on 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