1. Two Separate Questions in Every Cache Decision
Every caching decision in HTTP is actually two separate questions being answered independently:
- Is this response fresh enough to serve without going to the server? (Freshness)
- If I do go to the server, has the content actually changed? (Validation)
Most tutorials mix these together and that's why people end up confused. Keep them separate in your head and the whole system becomes much cleaner. Cache-Control: max-age answers question 1. ETag and Last-Modified answer question 2.
2. Cache-Control Directives: What Actually Matters
The Cache-Control header is a comma-separated list of directives. Different directives apply to different caches (browser, CDN, proxy). Here are the ones you will actually encounter:
# This is a response from your server
## "Store this, it's valid for 1 year" — for versioned assets
Cache-Control: public, max-age=31536000, immutable
## "Always revalidate, but CDN can cache" — for HTML pages
Cache-Control: public, max-age=0, must-revalidate
## "Only browser cache, CDN must not store" — for user-specific
Cache-Control: private, max-age=3600
## "Never, ever, by anyone" — for real-time data
Cache-Control: no-store
## "Cache, but always revalidate before serving"
Cache-Control: no-cacheno-cache does NOT mean "do not cache"This is the single most common misconception. Cache-Control: no-cache means "cache this, but always revalidate with the server before serving it." The response is cached. The server is hit every time, but only to confirm the cached version is still valid — a 304 Not Modified costs almost no bandwidth. If you actually want "do not cache this at all", use no-store.
The immutable Directive
Cache-Control: max-age=31536000, immutable is the correct header for any URL that is content-addressed — meaning the filename changes when the content changes (e.g. main.a3f2c1b4.js). The immutable directive tells the browser "this will never change for as long as max-age applies, do not revalidate even if the user hard-refreshes." This eliminates the 304 round-trip on reload for assets, which matters on mobile where even a small latency adds up.
3. ETags: Conditional Requests Done Right
An ETag is an opaque token that represents the version of a resource. When the server sends one, the browser stores it. On subsequent requests, the browser sends If-None-Match: "the-etag-value". The server checks: if the resource hasn't changed, it sends back 304 Not Modified with no body (tiny response). If it has changed, it sends the full response with a new ETag.
# First request — server responds with ETag
GET /api/products HTTP/1.1
HTTP/1.1 200 OK
Cache-Control: max-age=0, must-revalidate
ETag: "v3-abc123"
Content-Length: 45820
[body: full product list JSON]
# Subsequent request — browser sends the ETag back
GET /api/products HTTP/1.1
If-None-Match: "v3-abc123"
# If unchanged — server sends 304, no body, ~200 bytes total
HTTP/1.1 304 Not Modified
ETag: "v3-abc123"
# If changed — server sends full response with new ETag
HTTP/1.1 200 OK
ETag: "v4-def456"
[new body]The practical impact: your product listing page hits the server on every reload, but if the data hasn't changed, the browser downloads ~200 bytes instead of 45 KB. For high-traffic sites with stable data, this is significant.
Strong vs Weak ETags
ETags can be strong ("abc123") or weak (W/"abc123"). Strong ETags mean the response is byte-for-byte identical. Weak ETags mean "semantically equivalent" — useful when gzip compression might change the bytes but the content is logically the same. Most frameworks generate strong ETags; weak ETags are used when the server compresses responses dynamically.
4. stale-while-revalidate: The CDN Pattern You Should Be Using
Here is a directive that is genuinely underused and genuinely useful: stale-while-revalidate. It says: "serve the stale cached version immediately, but kick off a background revalidation at the same time."
# Serve fresh for 60s. If stale (but less than 60+3600s old),
# serve stale immediately AND revalidate in background.
# After 3600s of staleness, block on revalidation.
Cache-Control: max-age=60, stale-while-revalidate=3600The user experience difference is dramatic. Without this, when max-age expires, the user gets a loading delay while the browser waits for the server. With stale-while-revalidate, the user always gets an instant response — the data might be up to 60 seconds old, but it is instant. The cache updates transparently in the background.
This is the pattern behind most CDN "stale serving" configurations. AWS CloudFront calls it the same thing. Cloudflare has a similar setting called "Serve Stale Content".
Do not use this for prices, inventory counts, session state, or any data where showing old data causes wrong user actions. Perfect for: navigation menus, blog posts, product descriptions, public API data that changes slowly.
5. The CDN Adds Its Own Layer
Here is the part that confuses a lot of developers: a CDN does not just "pass through" your cache headers. It has its own caching rules, its own TTL settings, and it may or may not respect what your server sends.
The Vary Header
If your server sends gzipped responses to browsers that support it and plain text to those that don't, the CDN needs to cache both versions separately. The Vary header tells it which request headers affect the response:
Vary: Accept-EncodingA CDN that sees Vary: Accept-Encoding will maintain separate cache entries for Accept-Encoding: gzip and no encoding. If you use Vary: Cookie or Vary: Authorization, most CDNs will bypass their cache entirely — because personalised responses can't be shared.
CDN Purge vs Invalidation
When you deploy new code and need to clear the cache, you have two options that sound similar but behave very differently:
- Purge: Remove a specific URL from cache. Next request fetches fresh from origin. Use this for targeted invalidations.
- Invalidation: Mark the cached copy as stale. Next request will revalidate (gets a 304 if unchanged, new response if changed). The old copy may still be served briefly.
CloudFront's "CreateInvalidation" is closer to the purge model — it marks objects for removal. But there's propagation delay across edge nodes (up to 5–10 minutes), so "deploy → invalidate → all users see new version" is not instantaneous, especially for large CDNs with many PoPs.
6. The S3 + CloudFront Pattern (What Actually Works)
If you are deploying a static site to S3 + CloudFront, the correct approach is:
# HTML files — never cache, always revalidate
index.html:
Cache-Control: no-cache, no-store, must-revalidate
# JS/CSS with hashed filenames — immutable, 1 year
main.a3f2c1b4.js:
Cache-Control: public, max-age=31536000, immutable
# Images without hashed names — moderate TTL
logo.png:
Cache-Control: public, max-age=86400
# CloudFront invalidation on deploy — only for HTML
aws cloudfront create-invalidation \
--distribution-id E2WFDO3BMCWZSD \
--paths "/*.html" "/articles/*.html"The key insight: your HTML files are the "entry points" — they reference versioned assets by hash. So you invalidate HTML on every deploy (fast, cheap, reliable), and your JS/CSS/images never need invalidation because their filenames change whenever content changes. This is called cache-busting via content hashing and it is the standard approach in webpack, Vite, and every modern bundler.
7. What Your Browser DevTools Are Actually Showing
The "Status" column in the Network tab of Chrome DevTools can show something like "(from disk cache)" or "(from memory cache)" — these are browser-side distinctions:
- Memory cache: stored in RAM, faster, cleared on tab close. Used for resources loaded on the current page session.
- Disk cache: stored on disk, persists across sessions. Used when the browser revisits a URL in a new session.
- Service worker cache: cached by a service worker script. Fully under your control via the Cache API.
A 304 response shows as the actual status code (304) in DevTools — the browser did go to the server and confirmed freshness. "(from disk cache)" means the browser did not go to the server at all — it used max-age to confirm freshness locally.
8. The Common Mistakes in One List
- Hash your filenames (
main.abc123.js) and setmax-age=31536000, immutable - Set HTML to
no-cache, no-store, must-revalidate— HTML is cheap to fetch, never worth staleness - Use
ETagfor APIs that return large payloads which change infrequently - Use
stale-while-revalidatefor content that can tolerate 1-minute staleness (most things) - Add
Vary: Accept-Encodingif your server gzips responses - Never use
Vary: CookieorVary: Authorizationat the CDN layer — it kills caching - Treat CDN purge as eventual, not immediate — do not rely on sub-second propagation
- Do not send
Cache-Control: no-storefor everything because you don't understand caching — you are throwing away free performance
9. Quick Reference Card
┌─────────────────────────────────────────────────────────────────┐
│ Resource Type │ Cache-Control │
├─────────────────────────────────────────────────────────────────┤
│ HTML (app shell) │ no-cache, no-store, must-revalidate │
│ Versioned JS/CSS │ public, max-age=31536000, immutable │
│ Non-versioned images │ public, max-age=86400 │
│ API: public data │ public, max-age=60, s-wr=3600 │
│ API: user data │ private, max-age=300 │
│ API: real-time │ no-store │
│ Fonts (Google/self) │ public, max-age=31536000, immutable │
└─────────────────────────────────────────────────────────────────┘
(s-wr = stale-while-revalidate)Caching is not complicated — it is just a decision tree that you need to walk for each resource type. Once you have that table memorised, 90% of cache-related bugs and performance wins become obvious. The remaining 10% is CDN-specific documentation reading, which unfortunately cannot be avoided.
QR Code Generator
Generate QR codes for your API endpoints, documentation links, or anything else — entirely client-side.
Open QR Generator