Back to Performance
Performance
easy
mid

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.

10 min read·~5 min to think through

HTTP caching is the single biggest perf lever after CDN choice. Match the strategy to the resource lifecycle.

The headers

HeaderPurpose
Cache-Control: max-age=NFresh for N seconds; no revalidation needed
Cache-Control: s-maxage=NSame, but only for shared caches (CDNs)
Cache-Control: immutablePromise resource won't change before max-age; skip revalidate on reload
Cache-Control: publicCaches everywhere
Cache-Control: privateBrowser only, not shared caches
Cache-Control: no-cacheCache, but always revalidate before use
Cache-Control: no-storeDon't cache at all
Cache-Control: stale-while-revalidate=NServe stale up to N seconds while fetching fresh in background
Cache-Control: stale-if-error=NServe stale up to N seconds if origin errors
ETag: "abc"Opaque version; client sends If-None-Match, server returns 304 if match
Last-ModifiedTimestamp; client sends If-Modified-Since
Vary: Accept-EncodingCache one entry per encoding (gzip vs brotli)

Strategies by resource

Hashed static assets (main.abc123.js, logo.def456.png)

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

Cached forever. The hash in the URL guarantees changes get new URLs. Invalidation is free.

HTML

Two patterns:

A. Edge-cached HTML (public content):

ts
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300

Browser 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):

ts
Cache-Control: private, no-store

Never cached. Always fresh from origin.

API responses

Public, slow-changing:

ts
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:

ts
Cache-Control: private, no-store

Browser may still cache in app-level (React Query); HTTP layer doesn't.

Auth / mutations:

ts
Cache-Control: no-store

Never cache.

Conditional GET (ETag)

ts
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.

ts
Cache-Control: max-age=60, stale-while-revalidate=600

For 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."

ts
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; PURGE invalidates 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-cache confused with no-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: public with per-user content — data leak.
  • Forgetting stale-if-error for origin outages.

Real-world matrix

ResourceCache-Control
Hashed JS / CSS / imagespublic, max-age=31536000, immutable
Public HTMLpublic, max-age=0, s-maxage=60, stale-while-revalidate=300
Per-user HTMLprivate, no-store
Public API (slow-changing)public, max-age=60, stale-while-revalidate=3600
Per-user APIprivate, no-store (rely on app cache)
Auth endpointsno-store
Static fonts (hashed)public, max-age=31536000, immutable
robots.txt, sitemap.xmlpublic, 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.

Senior engineer discussion

Seniors design cache strategy alongside the deploy story. They distinguish layers (browser vs CDN), match strategy to resource lifecycle, and treat cache key hygiene as a security concern. They know stale-while-revalidate is the cheat code for hiding origin latency on non-personal content.

Related questions