HTTP caching headers — the complete deep dive.
Most developers know Cache-Control: max-age=3600 and stop there. But Cache-Control has 15+ directives, each with specific semantics. ETag and Last-Modified do different things. The Vary header is a footgun. This is the complete reference: every directive, what it does, and the configurations that hold up in production with real CDNs in the path.
01Where caches live
An HTTP response can be cached in multiple places between origin and user:
- Browser cache — per-user, private
- Service worker cache — per-user, programmable
- Corporate proxy — shared across employees
- CDN edge cache — shared across all users in a region
- Origin reverse proxy — shared at your infrastructure
Cache-Control directives target specific layers. private means browsers only. public permits shared caches. s-maxage applies only to shared caches. Getting the layer right is half the battle.
02Cache-Control — the directives that matter
max-age=N — seconds the response can be cached. Used by all caches.
s-maxage=N — like max-age but for shared caches only (CDN). Lets you cache aggressively at the edge while keeping the browser fresh.
public — explicitly says "shared caches can store this." Required for some responses (POST replies) to be CDN-cacheable.
private — only the user's browser may cache. Personalized responses, user data.
no-cache — counterintuitive. Means "cache it, but revalidate every time." Use ETag with this to get 304 Not Modified responses.
no-store — actually means "don't cache." Sensitive data, financial transactions.
immutable — promise the response will never change for this URL. Browser won't even revalidate. Only safe for content-hashed URLs.
must-revalidate — once max-age expires, MUST check origin before serving stale.
stale-while-revalidate=N — for N seconds after expiry, serve stale immediately while revalidating in background. Killer feature for perceived performance.
stale-if-error=N — if origin is down, serve stale for N seconds. Free degraded mode.
03The four patterns that cover 95% of cases
# File: /assets/app-a3f2c1.js
Cache-Control: public, max-age=31536000, immutable
Cache for one year, never revalidate. Safe because the URL changes when content changes.
# File: /assets/logo.png
Cache-Control: public, max-age=3600, stale-while-revalidate=86400
ETag: "abc123"
Cache for 1 hour, then stale-while-revalidate for 24 more hours. Users always see fast responses; freshness updates in the background.
Cache-Control: public, max-age=0, s-maxage=60, must-revalidate
ETag: "v1.2.3"
Browser revalidates every page load (cheap with ETag). CDN caches for 60 seconds — huge load reduction on the origin during traffic spikes.
Cache-Control: private, no-cache
ETag: "user-123-v45"
Browser caches locally, but revalidates every request. Personalized content never accidentally shared.
04ETag vs Last-Modified
Both enable conditional requests — the client can ask "has this changed since I last saw it?" The server responds with 304 Not Modified (no body) if unchanged, or 200 with the new body.
Last-Modified uses a timestamp:
# Initial response
Last-Modified: Wed, 25 May 2026 12:00:00 GMT
# Client revalidation
If-Modified-Since: Wed, 25 May 2026 12:00:00 GMT
# Server response if unchanged
HTTP/1.1 304 Not Modified
ETag uses an opaque identifier — typically a hash or version number:
# Initial response
ETag: "abc123"
# Client revalidation
If-None-Match: "abc123"
# Server response if unchanged
HTTP/1.1 304 Not Modified
ETag wins when:
- The resource changes within a 1-second window (Last-Modified has 1-second resolution)
- The resource changes back to a previous state (Last-Modified would say "newer," ETag would match)
- Multiple representations exist (content negotiation)
Last-Modified wins when:
- You already have the timestamp cheap (file mtime on disk)
- Cross-CDN coherence matters (weak ETag generation can differ between origins)
Most applications should default to ETag.
05Weak vs strong ETags
Strong ETags promise byte-identical responses. Weak ETags (prefixed W/) promise semantically-equivalent but possibly byte-different responses.
Strong is required for byte-range requests. Weak is sufficient for caching. If your response goes through gzip compression, transforming proxies, or any layer that might re-encode, use weak ETags.
06The Vary header — the footgun
Vary tells caches which request headers should affect the cache key. If your response differs based on Accept-Encoding, you need:
Vary: Accept-Encoding
Without this, the CDN might serve a gzipped response to a client that didn't request encoding. With it, the CDN stores separate cache entries per encoding.
Common Vary values:
- Accept-Encoding — for compression (gzip, br)
- Accept-Language — if you serve different content per language
- Origin — if you echo back CORS Origin in responses
The footgun: Vary: User-Agent. Every unique User-Agent string is a separate cache entry. With millions of UA strings in the wild, your cache hit rate craters. Never vary on User-Agent.
07How CDNs actually behave
Real-world CDN gotchas:
- Different defaults. Cloudflare caches by URL only by default. Fastly is stricter. CloudFront has its own behavior. Read your CDN's docs.
- Query string handling. By default,
/page?a=1and/page?a=2may or may not be different cache entries. Configure explicitly. - Cookie handling. If responses include Set-Cookie, many CDNs refuse to cache. Strip them from cacheable responses.
- Default cache time on missing headers. Some CDNs cache for hours by default if you forget to set Cache-Control. Always set explicit headers.
08Cache invalidation strategies
Three approaches in order of complexity:
- Wait for TTL. If your TTL is short, you can wait. Useful for non-critical content.
- Versioned URLs. Include a version or content hash in the URL. New version = new URL = no invalidation needed. The cleanest approach.
- Explicit purge. Call the CDN API to invalidate specific URLs or tags. Necessary for HTML pages that can't change URLs.
Modern CDNs support "surrogate keys" (Fastly's term; other CDNs have similar features). Tag responses with arbitrary keys; purge by key later. Example: every page about product 123 gets Surrogate-Key: product-123. When the product changes, purge that key — every cached page about it disappears at once.
09Age and Date — debugging tools
Date — when the response was generated at origin.
Age — how many seconds the response has been in cache.
These are sent automatically by caches and let you debug cache behavior. If Age is 0, you got a fresh response from origin. If Age: 240, the CDN served from cache 4 minutes after origin generated it.
10Testing cache behavior
Cache bugs are insidious. Test explicitly:
# See full response headers including Age and CF-Cache-Status
curl -I https://example.com/page
# Two requests in a row — second should hit cache
curl -I https://example.com/page
curl -I https://example.com/page
# Force revalidation
curl -I -H "Cache-Control: no-cache" https://example.com/page
# Conditional request
curl -I -H 'If-None-Match: "abc123"' https://example.com/page
∞The discipline
HTTP caching is one of those topics where 5% of the spec gets 95% of the use, but you really do need to know the 95% the day you have a cache bug. The headers are the API to every CDN, every browser, every proxy. Getting them right reduces origin traffic by orders of magnitude. Getting them wrong serves stale data to angry customers.
Set explicit headers on every response. Use ETags for conditional requests. Use Vary carefully. Test your cache behavior. The savings compound and the bugs disappear.