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:

  1. Memory cache — fastest; cleared when the tab closes. Holds resources from the current session.
  2. Disk cache (HTTP cache) — persists across visits. Governed by Cache-Control, ETag, and Last-Modified.
  3. Service worker cache — optional layer you control in JavaScript; see the PWA guide for cache-first vs network-first strategies.
  4. CDN edge — PoPs worldwide store copies close to users. Respects s-maxage and shared-cache directives separately from browser max-age.
  5. 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:

DirectiveMeaning
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's ETag (opaque version token, often a hash of content).
  • If-Modified-Since: Wed, 21 Oct 2025 07:28:00 GMT — paired with Last-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 — when Access-Control-Allow-Origin differs 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:

  1. Origin sends Cache-Control: public, s-maxage=86400, max-age=3600.
  2. First visitor in Tokyo triggers an origin pull; edge stores the response for 24 hours.
  3. Subsequent Tokyo visitors hit the edge — milliseconds instead of trans-Pacific RTT.
  4. 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 configstyle.css?v=2 works 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

  1. Fingerprint JS/CSS/fonts at build time; set immutable + one-year max-age.
  2. HTML: no-cache or short CDN TTL with purge on deploy.
  3. Images: long cache if URLs are versioned; use responsive formats and dimensions for LCP.
  4. APIs: default no-store; add ETag only for safe, public GET endpoints.
  5. Document purge steps in your deploy pipeline alongside TLS and DNS cutovers.
  6. Verify with curl and DevTools after every infrastructure change.

Key takeaways

  • HTTP caching is layered — browser, CDN, and origin each read Cache-Control and 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 / 304 saves 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