devkult_
tools29converters34
home/blog/http-caching-explained
httpcachingperformance

HTTP Caching Explained: Cache-Control, ETag and 304 Not Modified

Caching is the cheapest performance win on the web, and the most common source of "why am I still seeing the old version?" It runs on two separate mechanisms that are easy to blur together. Once you can tell them apart, every caching header makes sense.

Mechanism 1: freshness — no request at all

Cache-Control: max-age=3600 tells a browser or CDN that this response stays fresh for an hour. During that hour the cache answers by itself; nothing goes over the network. In DevTools you'll see these as (memory cache) or (disk cache).

Freshness is what makes repeat visits fast. It's also why a bad deploy can stay stuck in browsers: until the response goes stale, the browser doesn't even ask.

Mechanism 2: validation — ask, and maybe get a 304

When a cached response is stale (or marked no-cache), the cache asks the server whether its copy is still good, by sending what it knows:

GET /app.css HTTP/1.1
If-None-Match: "5d8c72a-1f3"

If nothing changed, the server answers 304 Not Modified with no body, and the cache reuses its copy. A few hundred bytes instead of the whole file.

The validators come from the original response:

  • ETag: "5d8c72a-1f3" — an identifier for this exact version, sent back as If-None-Match.
  • Last-Modified: Tue, 14 Oct 2026 09:30:00 GMT — sent back as If-Modified-Since.

The Cache-Control directives that matter

Directive Meaning
max-age=N Fresh for N seconds in any cache
s-maxage=N Fresh for N seconds in shared caches (CDNs); overrides max-age there
public Any cache may store it, even for authenticated requests
private Only the user's browser may store it — not CDNs
no-cache May be stored, but must be revalidated before every use
no-store Must not be stored anywhere
immutable Will never change; don't revalidate even on reload
stale-while-revalidate=N Serve a stale copy for up to N seconds while refreshing in the background

The two most confused are no-cache and no-store. no-cache does not mean "don't cache" — it means "always check first", which with ETags is cheap. no-store is the one that keeps a response out of caches entirely.

A setup that works for most sites

Static assets with a hash in the file name (app.3f2a1c.js): cache forever. A new deploy produces a new file name, so staleness is impossible.

Cache-Control: public, max-age=31536000, immutable

HTML pages: always revalidate, so a deploy shows up on the next load, but let unchanged pages come back as cheap 304s.

Cache-Control: no-cache
ETag: "abc123"

Public API responses behind a CDN: short shared freshness, plus background refresh.

Cache-Control: public, s-maxage=60, stale-while-revalidate=300

Personal data — account pages, API responses with user data:

Cache-Control: private, no-store

When there's no Cache-Control at all

Caches don't just give up. For responses like 200 OK that are cacheable by default, browsers apply heuristic freshness, commonly 10% of the time since Last-Modified. A file last changed 30 days ago may be treated as fresh for three days. That's the hidden cause behind many "users see old code" reports. Always send an explicit Cache-Control.

The Vary header

Vary tells caches which request headers change the response, so they store separate copies:

  • Vary: Accept-Encoding — separate gzip and Brotli versions (servers add this automatically).
  • Vary: Origin — needed when your CORS headers depend on the caller.
  • Vary: Cookie — effectively disables shared caching, since every user's cookies differ.

Debugging caching

  • DevTools → Network: the Size column shows (disk cache) or (memory cache) for fresh hits, and the Status column shows 304 for revalidations. Tick Disable cache to bypass everything.
  • curl shows the real headers without any browser cache in the way: curl -sI https://example.com/app.css.
  • CDN headers report hits and misses: cf-cache-status on Cloudflare, x-vercel-cache on Vercel (HIT, MISS, STALE, PRERENDER), x-cache on CloudFront.

To compare headers across many URLs at once, paste them into the HTTP Status Checker. Every header in this guide has its own reference page: Cache-Control, ETag, Last-Modified, If-None-Match and Vary.

More from the blog