HTTP Caching: Cache-Control, ETag, and CDN Strategies for 2026
HTTP caching is the highest-leverage performance optimization most teams ignore. One line — Cache-Control: public, max-age=3600, stale-while-revalidate=86400 — can drop origin traffic 95%+ and median latency below 50ms globally. The mechanics are documented across RFC 9110 and RFC 5861 but scattered. This guide consolidates them.
1. The cache hierarchy
A request from a browser typically traverses up to four caches:
- Browser cache — the user's local cache, scoped to the user
- CDN edge — Cloudflare/Fastly/CloudFront edge node closest to the user
- CDN regional — a regional shield (some CDNs only)
- Reverse proxy — Varnish/Nginx in front of your origin
- Origin server — your application
A cache miss at one layer becomes a request to the next. A well-designed hierarchy serves 99% of requests at the edge, so origin sees only a fraction of total traffic.
2. Cache-Control directives
The most important header. Multiple directives separated by commas:
Cache-Control: public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800
| Directive | Meaning |
|---|---|
| public | May be cached by any cache (browser + CDN + proxy) |
| private | Browser only — CDN/proxy must not cache |
| no-cache | May store, must revalidate before each use |
| no-store | Must not store at all (sensitive data) |
| max-age=N | Fresh for N seconds in any cache |
| s-maxage=N | Override max-age for shared caches only |
| stale-while-revalidate=N | Serve stale up to N seconds while refreshing in background (RFC 5861) |
| stale-if-error=N | Serve stale up to N seconds if origin returns 5xx |
| must-revalidate | After expiry, MUST contact origin (no stale) |
| immutable | Will not change during lifetime — browser skips revalidation entirely (RFC 8246) |
3. ETag and conditional requests
ETag is a server-generated identifier for a specific version of a resource — typically a content hash or version number.
# First request — full response
GET /api/users/42
HTTP/1.1 200 OK
ETag: "v3-a8f5e6c2"
Cache-Control: public, max-age=60
{ "id": 42, "name": "Ada Lovelace" }
# After max-age expires, browser revalidates
GET /api/users/42
If-None-Match: "v3-a8f5e6c2"
# Server compares, sends just headers if unchanged
HTTP/1.1 304 Not Modified
ETag: "v3-a8f5e6c2"
Cache-Control: public, max-age=60The 304 Not Modified response has an empty body — bandwidth savings of 80–95% on read-heavy endpoints with long-lived data.
Strong vs weak validators: ETag: "abc" means byte-identical (strong). ETag: W/"abc" means semantically equivalent but maybe not byte-identical (weak — useful when whitespace or compression differs but content is the same).
4. Static assets — the immutable pattern
For build-versioned assets (JS bundles, hashed CSS, images with hash in filename), use the immutable pattern:
# /static/app.a8f5e6c2.js Cache-Control: public, max-age=31536000, immutable
One year cache + immutable means the browser never revalidates. When the file changes, the build produces a new filename (app.b9c8d4e1.js), so old cached versions stay valid for users still on the old build.
5. HTML pages — the SWR pattern
For HTML or API responses where freshness matters but staleness is acceptable:
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=86400
What this does:
- Browser revalidates every request (
max-age=0) - CDN caches for 60 seconds (
s-maxage=60) - If a request comes after 60s, CDN serves stale immediately, fetches fresh in background (
stale-while-revalidate=86400)
Result: 99%+ of requests served from CDN edge, perceived latency <50ms globally, worst-case staleness of one request.
6. Vary header — cache key separation
When the response varies based on request headers (Accept-Encoding, Accept-Language, custom auth headers), use Vary to tell caches to keep separate copies:
Vary: Accept-Encoding, Accept-Language, Authorization
Trap: Vary: User-Agent creates thousands of cache keys (every browser version is unique) — effectively disables caching. If you need device-specific responses, normalize at the CDN (e.g., Cloudflare's "Mobile Redirect" or "Polish") rather than vary on User-Agent.
7. Common bugs and how to spot them
7.1 Cookies break caching
Most CDNs treat any response with Set-Cookie as private by default. A request that sets a session cookie disables CDN caching for that response. Fix: set cookies on a separate auth endpoint, never on cacheable content.
7.2 Different cache keys for same URL
A query parameter, request header, or cookie can split your cache. Check your CDN's cache key configuration — Cloudflare's default ignores most query params, Fastly does not.
7.3 Cache stampede
When a popular cached resource expires, all concurrent requests hit the origin simultaneously. Mitigations: stale-while-revalidate (serve stale while one request refreshes), origin shielding (CDN regional cache absorbs the burst), or request coalescing at the CDN.
7.4 Stale 5xx responses
A 500 from origin can get cached if you forget to set Cache-Control: no-store on errors. Result: every user sees the same error for hours. Modern CDNs default to not caching 5xx, but configure explicitly.
8. Per-content-type cache strategy
| Content type | Strategy |
|---|---|
| Hashed JS/CSS bundle | public, max-age=31536000, immutable |
| Hashed images | public, max-age=31536000, immutable |
| Unhashed images (logos) | public, max-age=86400 + ETag |
| HTML pages | public, max-age=0, s-maxage=60, stale-while-revalidate=86400 |
| JSON API (read) | public, max-age=60 + ETag |
| User-specific data | private, max-age=0, must-revalidate |
| Sensitive data (auth, payments) | no-store |
| 5xx errors | no-store |
FAQ
Q. Should I use ETag or Last-Modified?
A. Use ETag when you can — it's stronger. Last-Modified has 1-second resolution and breaks for files modified multiple times per second. ETag can be a content hash (strong validator) or a version number (weak validator, prefixed with W/). When both are present, RFC 9110 says clients should prefer ETag.
Q. What's the difference between max-age and s-maxage?
A. max-age applies to all caches (browser + CDN + proxy). s-maxage overrides max-age for shared caches only (CDN/proxy), letting you set different TTLs for browser and CDN. Common pattern: max-age=0 (browser revalidates every time) + s-maxage=86400 (CDN caches for a day) — best of both worlds.
Q. Why doesn't my browser cache work?
A. Three usual causes: (1) the response includes Set-Cookie which makes the browser cache it as private, blocking shared cache; (2) the request method isn't GET/HEAD; (3) the response has Cache-Control: no-store or Pragma: no-cache. Open DevTools → Network → click the request → 'Response Headers' to see what's actually returned.
Q. What is stale-while-revalidate and when should I use it?
A. stale-while-revalidate=N (RFC 5861) lets the cache serve a stale response immediately while fetching a fresh one in the background. The next request gets the fresh version. Result: zero perceived latency at the cost of one stale response. Perfect for content that changes infrequently but isn't time-critical (product listings, blog posts, dashboards).
Q. Can I cache POST requests?
A. Technically yes per RFC 9110, but in practice no: virtually all CDNs and browsers ignore Cache-Control on POST. If you need cached writes, use a GET endpoint with idempotent semantics or implement application-level caching. GraphQL APIs that send everything as POST often use Automatic Persisted Queries (APQ) to convert to GET for caching.
References
📖 Related Guides
UUID v4 vs ULID — Which to Choose?
Compare UUID v4 and ULID for distributed systems. Performance, sortability, and use cases.
JWT Anatomy — Header, Payload, Signature
Understand JWT structure, claims, and signing algorithms. Security best practices.
URL Encoding — When to Use What
encodeURI vs encodeURIComponent vs escape. Query strings, paths, and reserved characters.
About the DevToolNow Editorial Team
DevToolNow's editorial team is made up of working software developers who use these tools every day. Every guide is reviewed against primary sources — IETF RFCs, W3C/WHATWG specifications, MDN Web Docs, and project repositories on GitHub — before publication. We update articles when standards change so the guidance stays current.
Sources we cite: IETF RFCs · MDN Web Docs · WHATWG · ECMAScript spec · Official project READMEs on GitHub