The fastest request is the one that never happens. HTTP caching is the mechanism making that possible — and also the classic source of the "I already deployed and I don't see it" bug. This article breaks down how browser, CDN and server decide what to store, when to revalidate and what to serve while updating.
The two cache types that matter
Every HTTP response can be stored in two places with different rules:
- Private cache (the browser): just for you. May store personalized content.
- Shared cache (CDN, proxies): one copy for thousands of users. Storing per-user content here is the industry's most classic data leak.
The directive separating both worlds:
Cache-Control: max-age=3600 ← anyone caches for 1 hour
Cache-Control: private, max-age=600 ← browser only
Cache-Control: no-store ← nobody stores anything (banking, health)
no-store is not no-cache: the latter means "store but always revalidate before use" — useful and constantly misunderstood.
max-age vs s-maxage: two different clocks
When CDN and browser coexist, each deserves its own policy:
Cache-Control: public, max-age=60, s-maxage=86400, stale-while-revalidate=300
Reading it: the browser revalidates every minute (changes visible fast), the CDN retains 24 hours (origin protected), and during the first 300 seconds after expiry it serves slightly-stale while refreshing in background. This combo is the sweet spot of nearly any dynamic HTML page behind a CDN.
For fingerprinted assets (app.a83f2.js), the opposite policy is correct:
Cache-Control: public, max-age=31536000, immutable
One year + immutable (browser doesn't even ask). The trick: if content changes, change the filename — the hash in the name IS your invalidation. Exactly what Next/Vite/Webpack do at build time.
Conditional validation: ETag and Last-Modified
When a cached copy expires, there's no need to blindly re-download everything. Validation compares versions:
First response:
ETag: "33a64df5"
Last-Modified: Wed, 25 Aug 2026 10:00:00 GMT
Subsequent requests, the cache asks:
If-None-Match: "33a64df5"
If-Modified-Since: Wed, 25 Aug 2026 10:00:00 GMT
Unchanged? Server answers 304 Not Modified: no body, a few hundred bytes. Client keeps using its local copy. Changed? 200 with fresh content.
Practical difference between them: ETag is an arbitrary identifier (content hash, version...) detecting any change; Last-Modified only has second-level resolution. For content generated within the same second or where mtime lies, ETag wins. Extra caution: default ETags computed from file attributes differ across cluster servers — generate ETags from content, not metadata.
stale-while-revalidate and its siblings
Modern directives resolve the freshness-latency dilemma:
stale-while-revalidate=N: after expiry, serve the old copy immediately AND refresh in background. Users never wait.stale-if-error=N: if origin is down, keep serving the stale copy up to N seconds. Free resilience against micro-outages.
Honest cost: during that window users see slightly outdated content. Perfect for social feeds or dashboards; adjust N to zero for prices or stock, or use active invalidation.
Active invalidation: purge
Sometimes you must delete now: published typo, wrong price. CDNs expose purge APIs (Cloudflare, Fastly, CloudFront) by URL, tag or everything. Mature strategy: long TTLs + targeted purge by content tags (purge-by-tag: article-42) beats short TTLs punishing every visitor.
Warning: purge does NOT reach the browser's private cache. A hash-less asset published with a long max-age stays poisoned for anyone who already downloaded it until expiry — an industry-wide lesson learned in tears: HTML never gets long cache; long cache is only for hashed files.
Vary: the forgotten parameter
Vary declares which request headers produce distinct variants of the same URL:
Vary: Accept-Encoding
Without it, a shared cache might hand the brotli version to a gzip-only client. Another real case: serving AVIF/WebP/JPG images negotiated via Accept requires Vary: Accept, or users will receive formats they can't display. Each extra value multiplies cache entries — use precisely, never as wildcard.
Quick audit
DevTools → Network → Size column tells all without external tools: (disk cache), (memory cache) = served locally; full size = real trip. The Headers tab shows each resource's actual directives. To see it as an external visitor would, our headers checker displays the complete headers your server truly emits — essential because intermediaries (hosting, CDN) modify your configuration. Full context on every header lives in the headers guide.
FAQ
How long should HTML be cached? Dynamic: no-cache (always revalidates, saves bytes via 304) or max-age=60,s-maxage=300,SWR. Blog-type static: minutes in browser, hours in CDN with purge available.
Does Ctrl+F5 bypass the cache? Yes, requests with Cache-Control: no-cache ignoring local copies — first diagnostic step when "I can't see my change".
Does Service Worker replace Cache-Control? They complement: SW programmatically decides what to serve (offline strategies); Cache-Control governs underlying HTTP behavior. A badly written SW can serve eternal garbage ignoring your headers.
Inspect your site's real caching headers with our HTTP Headers Checker, free and right in your browser.