What HTTP caching strategies do you use to keep a frontend fast?
Match strategy to resource: 'public, max-age=31536000, immutable' for hashed assets (forever cache); short max-age + stale-while-revalidate for HTML/API; 'no-store' for personalized/sensitive. Use ETag/If-None-Match for cheap revalidation. Set Vary on responses that legitimately differ. CDN s-maxage for shared caches, private for browser-only. Cache key hygiene matters — wrong key leaks data between users.
HTTP caching is the single biggest perf lever after CDN choice. Match the strategy to the resource lifecycle.
The headers
| Header | Purpose |
|---|---|
| Cache-Control: max-age=N | Fresh for N seconds; no revalidation needed |
| Cache-Control: s-maxage=N | Same, but only for shared caches (CDNs) |
| Cache-Control: immutable | Promise resource won't change before max-age; skip revalidate on reload |
| Cache-Control: public | Caches everywhere |
| Cache-Control: private | Browser only, not shared caches |
| Cache-Control: no-cache | Cache, but always revalidate before use |
| Cache-Control: no-store | Don't cache at all |
| Cache-Control: stale-while-revalidate=N | Serve stale up to N seconds while fetching fresh in background |
| Cache-Control: stale-if-error=N | Serve stale up to N seconds if origin errors |
| ETag: "abc" | Opaque version; client sends If-None-Match, server returns 304 if match |
| Last-Modified | Timestamp; client sends If-Modified-Since |
| Vary: Accept-Encoding | Cache one entry per encoding (gzip vs brotli) |
Strategies by resource
Hashed static assets (main.abc123.js, logo.def456.png)
Cache-Control: public, max-age=31536000, immutableCached forever. The hash in the URL guarantees changes get new URLs. Invalidation is free.
HTML
Two patterns:
A. Edge-cached HTML (public content):
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300Browser revalidates on every nav (max-age=0); CDN serves cached up to 60s, then stale while refreshing for up to 5 more minutes.
B. Per-user HTML (logged in):
Cache-Control: private, no-storeNever cached. Always fresh from origin.
API responses
Public, slow-changing:
Cache-Control: public, max-age=60, stale-while-revalidate=600
ETag: "abc123"Hit cache for 60s; serve stale for 10 more min while refreshing; revalidate with ETag when full refresh needed.
Per-user:
Cache-Control: private, no-storeBrowser may still cache in app-level (React Query); HTTP layer doesn't.
Auth / mutations:
Cache-Control: no-storeNever cache.
Conditional GET (ETag)
1. Server → Browser:
200 OK
ETag: "abc123"
Cache-Control: max-age=300
[body]
2. (after max-age expires)
Browser → Server:
GET /resource
If-None-Match: "abc123"
3. Server → Browser:
304 Not Modified
(no body)Saves bandwidth on revalidation: 304 ships only headers.
Last-Modified works similarly with If-Modified-Since. Prefer ETag — opaque tokens don't depend on clock accuracy.
stale-while-revalidate
Hot pattern. Serve cached response instantly even if it's stale, and refresh in the background.
Cache-Control: max-age=60, stale-while-revalidate=600For 60s: fresh from cache. For next 600s: stale from cache (user sees fast response), background refresh runs. After 660s: full refresh required (block until new).
Perfect for content that's fine being slightly old (product listings, social feeds, news). Hides origin latency.
Vary
Tells the cache "this URL has different responses depending on these request headers."
Vary: Accept-Encoding ← required for gzip/brotli responses
Vary: Accept-Language ← if response varies by language
Vary: Cookie ← (usually too broad — explosion of cache entries)CDNs typically handle Accept-Encoding automatically. Be careful with Cookie and User-Agent — high cardinality kills cache hit rate.
Cache keys (the failure mode)
Wrong cache key = the most embarrassing perf/security bug:
- Too narrow (URL only): users on different languages or auth states see same cached response. Data leak if it's per-user content.
- Too wide (URL + every header): low hit rate, cache useless.
Right approach: declare exactly what varies via Vary, or configure CDN to include specific headers in the key.
CDN cache vs browser cache
Cache-Control: public, s-maxage=60, max-age=10→ CDN holds 60s, browser holds 10s.Cache-Control: private, max-age=300→ browser only.Cache-Control: public, max-age=31536000, immutable→ both, forever.
Invalidation
The hard problem. Strategies:
- Hashed URLs (immutable resources): no invalidation needed. New content = new URL.
- Tag-based purge: CDN (Cloudflare, Fastly) supports cache tags;
PURGEinvalidates all responses tagged "product-42". - Soft purge / revalidate: CDN keeps the stale entry but marks it for revalidation on next request.
- TTL: live with N seconds of staleness; pick TTL by change frequency.
Pitfalls
no-cacheconfused withno-store— no-cache does cache, just revalidates.- Long max-age on HTML referencing assets that may be rebuilt — fatal mismatch on deploy.
- Cookies in cache key by accident — kills hit rate.
- Missing
Vary: Accept-Encoding— clients without brotli get gzip-encoded brotli (broken). Cache-Control: publicwith per-user content — data leak.- Forgetting
stale-if-errorfor origin outages.
Real-world matrix
| Resource | Cache-Control |
|---|---|
| Hashed JS / CSS / images | public, max-age=31536000, immutable |
| Public HTML | public, max-age=0, s-maxage=60, stale-while-revalidate=300 |
| Per-user HTML | private, no-store |
| Public API (slow-changing) | public, max-age=60, stale-while-revalidate=3600 |
| Per-user API | private, no-store (rely on app cache) |
| Auth endpoints | no-store |
| Static fonts (hashed) | public, max-age=31536000, immutable |
| robots.txt, sitemap.xml | public, max-age=3600 |
Mental model
Cache aggressively at the layer closest to the user; use hashed URLs to make invalidation a non-problem; distinguish per-user from shared content explicitly. Get cache keys right or risk data leaks.
Follow-up questions
- •What's the difference between no-cache and no-store?
- •How does stale-while-revalidate work?
- •When do you need to set Vary?
- •What's the right strategy for HTML with personalization?
Common mistakes
- •Long-cached HTML + frequently rebuilt assets without graceful version handling.
- •public Cache-Control on per-user responses — data leak.
- •Cookies / Authorization in cache key — destroys hit rate.
- •Forgetting Vary on language-varying responses.
- •no-cache when meaning no-store.
- •Setting both Expires and Cache-Control — Cache-Control wins, removes ambiguity.
Performance considerations
- •Cache hits: <50ms TTFB. Cache misses: 200-500ms. A 95% hit rate cuts origin load 20x and dominates the perf delta. Immutable assets + stale-while-revalidate HTML is the highest-ROI pattern for content sites.
Edge cases
- •Bfcache (browser back/forward cache) bypasses HTTP cache — different lifecycle.
- •Service workers can intercept and override HTTP cache behavior.
- •Range requests (video) interact with cache differently.
- •POST responses can be cached if Cache-Control allows (rare).
- •Some CDNs let you cache POST responses for read-only mutations (idempotency tricks).
Real-world examples
- •GitHub API uses ETag aggressively; cached requests don't count against rate limit.
- •Cloudflare, Fastly, AWS CloudFront all support tag-based purge for HTML.
- •Next.js + Vercel uses ISR with cache tags for per-page invalidation.