Guide
HTTP caching explained
Every repeat visit to your site should be faster than the first — but only if you tell browsers and CDNs what they may store, for how long, and when to check back with the origin. HTTP caching is not one switch; it is a stack of independent caches (browser memory, disk, service worker, CDN edge, reverse proxy) that all read the same response headers. Misconfigured headers either waste bandwidth on identical downloads or serve stale HTML long after you deployed a fix. This guide explains Cache-Control, ETags, validation requests, and the policies that pair with Core Web Vitals and rendering strategies so your pages load quickly without surprising users.
Where HTTP caches live
When a browser requests https://example.com/style.css, the response may
be satisfied without touching your server:
- Memory cache — fastest; cleared when the tab closes. Holds resources from the current session.
- Disk cache (HTTP cache) — persists across visits. Governed by
Cache-Control,ETag, andLast-Modified. - Service worker cache — optional layer you control in JavaScript; see the PWA guide for cache-first vs network-first strategies.
- CDN edge — PoPs worldwide store copies close to users. Respects
s-maxageand shared-cache directives separately from browsermax-age. - Origin / reverse proxy — nginx, Varnish, or Cloudflare Workers can cache before your app runs.
Each layer makes its own decision from the same headers. Set headers at the origin; CDNs and browsers propagate them. A missing or contradictory policy means the safest default: do not cache, which hurts performance on static sites that could serve fingerprinted assets for a year.
Cache-Control: the main vocabulary
The Cache-Control response header is a comma-separated list of
directives. The ones you use daily:
| Directive | Meaning |
|---|---|
max-age=31536000 |
Fresh for N seconds in the browser cache. After expiry, the cache must revalidate or refetch. |
s-maxage=86400 |
Same as max-age but for shared caches (CDN, proxy). Overrides max-age at the edge only. |
public |
Any cache may store the response (default for most static assets). |
private |
Only the end-user browser may cache — not CDNs. Use for personalized HTML or responses with session cookies. |
no-store |
Do not store the response anywhere. Required for sensitive data (banking, health). Heavier than most pages need. |
no-cache |
May store, but must revalidate with the origin before each use. Common for HTML that changes often. |
immutable |
Hint that the URL will never change during max-age — safe for
content-hashed filenames like app.a3f9c2.js. |
stale-while-revalidate=60 |
After freshness expires, serve stale content for up to 60s while fetching a new copy in the background. Smooths traffic spikes. |
stale-if-error=86400 |
If the origin is down, serve stale content for up to a day. Resilience pattern for read-heavy sites. |
Fresh vs stale: While a response is fresh, caches serve it without contacting the origin — zero latency, zero server load. Once stale, behavior depends on other directives: revalidate (conditional GET), refetch, or serve stale under SWR rules.
Validation: ETag and Last-Modified
When max-age expires or no-cache forces a check, the cache
sends a conditional request instead of downloading the full body
again:
If-None-Match: "abc123"— paired with the origin'sETag(opaque version token, often a hash of content).If-Modified-Since: Wed, 21 Oct 2025 07:28:00 GMT— paired withLast-Modified.
If nothing changed, the origin returns 304 Not Modified with an empty
body. The cache reuses the stored copy. This is cheap bandwidth but still costs a
round trip — for hot paths, long max-age on immutable assets beats
constant 304s.
Weak ETags (W/"...") allow semantically equivalent
responses to match; strong ETags require byte-identical bodies. Pick strong ETags for
static files; weak ETags can work for dynamically compressed JSON if your framework
supports them consistently.
The Vary header and cache poisoning
Caches key responses by URL — but sometimes the same URL returns different bytes
depending on request headers. Vary: Accept-Encoding is standard: gzip and
Brotli versions are separate cache entries. Also common:
Vary: Accept— JSON vs HTML content negotiation.Vary: Origin— whenAccess-Control-Allow-Origindiffers per caller (see CORS).Vary: Cookie— dangerous if overused; can fragment the cache into one entry per user. Prefer separate URLs for personalized vs public content.
Omit Vary when the response is identical for all clients. Wrong
Vary or caching authenticated responses without private
causes cache poisoning — user A sees user B's data. Never cache
responses that include Set-Cookie at a shared CDN unless you fully
understand the keying model.
What to cache: HTML, APIs, and static assets
HTML documents
HTML is the hardest layer. Users expect fresh content after deploy; CDNs must not serve yesterday's article list. Typical policy:
Cache-Control: no-cache
or short freshness with revalidation:
Cache-Control: public, max-age=0, must-revalidate
For static sites rebuilt on publish, you can cache HTML at the CDN with
s-maxage=300, stale-while-revalidate=60 and purge on deploy. Incremental
Static Site Generation and ISR (covered in the
rendering guide)
rely on exactly this split: long-lived assets, short-lived HTML shells.
API and JSON responses
Default: Cache-Control: private, no-store for authenticated endpoints.
Public read-only data (exchange rates, feature flags) can use short
max-age plus ETag — but coordinate with
rate limiting
so caches absorb read traffic instead of hammering the database.
Fingerprinted static assets
JavaScript, CSS, fonts, and images with content hashes in the filename are the easy win:
Cache-Control: public, max-age=31536000, immutable
When you ship a new build, the URL changes (main.b7e2.js), so there is
no stale problem. Never apply one-year caching to /style.css without a
hash — users will load old CSS until the cache expires.
CDN caching in practice
A CDN
sits between users and origin. It honors s-maxage over max-age
for shared caching. Common workflow:
- Origin sends
Cache-Control: public, s-maxage=86400, max-age=3600. - First visitor in Tokyo triggers an origin pull; edge stores the response for 24 hours.
- Subsequent Tokyo visitors hit the edge — milliseconds instead of trans-Pacific RTT.
- On deploy, run a cache purge for
/guides/*or rely on new hashed asset URLs.
stale-while-revalidate at the CDN keeps pages snappy during traffic
spikes: the edge serves the previous version while one request refreshes the copy.
Pair with monitoring so you notice if revalidation fails silently.
Debugging cache behavior
Browser DevTools Network tab shows Size column values like
(disk cache) or (memory cache) when no network transfer
occurred. Response headers reveal the policy:
age: 43200— seconds this response has lived in a shared cache.x-cache: HIT/CF-Cache-Status: HIT— CDN-specific hit/miss indicators.
curl -I https://example.com/asset.js prints headers without a body —
useful for CI checks that fail deploys if HTML accidentally ships with
max-age=31536000. Test with and without Cache-Control: no-cache
on the request to force revalidation paths.
Common mistakes
- Caching HTML with long max-age — users see old content; purges become mandatory for every typo fix.
- No cache headers on static assets — every visit re-downloads megabytes of JS; LCP and INP suffer.
- Query-string cache busting without CDN config —
style.css?v=2works only if the CDN keys on the full URL; some providers ignore query strings by default. - Mixing Set-Cookie with public caching — personalized responses leak across users.
- Ignoring Vary — gzip vs identity responses get mixed up, or JSON is served to HTML requests.
- Assuming service workers replace HTTP cache — they complement each other; wrong SW strategies can serve stale shells forever.
Publisher checklist
- Fingerprint JS/CSS/fonts at build time; set
immutable+ one-year max-age. - HTML:
no-cacheor short CDN TTL with purge on deploy. - Images: long cache if URLs are versioned; use responsive formats and dimensions for LCP.
- APIs: default no-store; add ETag only for safe, public GET endpoints.
- Document purge steps in your deploy pipeline alongside TLS and DNS cutovers.
- Verify with curl and DevTools after every infrastructure change.
Key takeaways
- HTTP caching is layered — browser, CDN, and origin each read
Cache-Controland validators. - Fresh responses need no network; stale behavior is policy-driven (revalidate, SWR, or refetch).
- Use long immutable caching for hashed static files; keep HTML revalidatable.
ETag/304saves bandwidth but not RTT — prefer long max-age when URLs are content-addressed.- Pair cache headers with CDN purge discipline and performance measurement so speed gains show up in real user data.
Related reading
- CDN explained — edge PoPs, origin pull, and purge strategies
- Core Web Vitals — LCP, INP, and CLS thresholds that caching improves
- Service workers and PWA — programmatic caches above HTTP headers
- SSR vs SSG vs ISR — how rendering choice affects what you cache