HTTP Caching & Headers
Master the caching layer that makes the web fast. Understand how Cache-Control, ETag, and conditional requests let browsers skip network round trips entirely — and why getting caching wrong causes stale data bugs that are painful to debug.
Table of Contents
Overview
HTTP caching lets browsers store copies of server responses and reuse them for future requests — skipping DNS, TCP, TLS, and server processing entirely. A cached response loads in under 1ms. A network request takes 200ms+. Caching is the single biggest performance lever on the web.
Caching behavior is controlled by HTTP headers — primarily Cache-Control. The server tells the browser how long a response is valid, whether it can be stored, and how to check if it's still fresh. Get these headers right and your site loads instantly on repeat visits. Get them wrong and users see stale data or make unnecessary requests.
This topic connects directly to the HTTP lifecycle — caching short-circuits the entire flow. It's also one of the most practical interview topics: interviewers expect you to know the difference between no-cache and no-store, how ETags work, and how to design a caching strategy for different resource types.
Why this matters
The fastest HTTP request is the one that never happens. Proper caching eliminates 80-95% of network requests on repeat visits. For a site with 40 resources, that's 35+ requests that load from disk in <1ms instead of traveling across the internet.
The Problem: Repeated Requests
Without caching, every page load fetches every resource from the server — even if nothing has changed. The same CSS file, the same JavaScript bundle, the same logo image — downloaded again and again.
User visits example.com/dashboard: GET /dashboard → 200 OK (14KB HTML) ~200ms GET /styles.css → 200 OK (45KB CSS) ~180ms GET /app.js → 200 OK (120KB JS) ~220ms GET /logo.png → 200 OK (8KB image) ~150ms GET /api/user → 200 OK (2KB JSON) ~250ms GET /fonts/inter.woff2 → 200 OK (35KB font) ~190ms Total: 6 requests, 224KB transferred, ~250ms (parallel) User reloads the page (nothing changed): GET /dashboard → 200 OK (14KB HTML) ~200ms GET /styles.css → 200 OK (45KB CSS) ~180ms GET /app.js → 200 OK (120KB JS) ~220ms GET /logo.png → 200 OK (8KB image) ~150ms GET /api/user → 200 OK (2KB JSON) ~250ms GET /fonts/inter.woff2 → 200 OK (35KB font) ~190ms Same 6 requests, same 224KB. Nothing changed, but the browser downloaded everything again. Wasted bandwidth, wasted time, wasted server resources.
Wasted Bandwidth
Users re-download the same files on every visit. On mobile networks, this burns through data plans and drains battery with unnecessary radio usage.
Slower Load Times
Every resource requires a full HTTP lifecycle — DNS, TCP, TLS, request, response. Even with HTTP/2, network latency adds 100-300ms per uncached resource.
Server Overload
Without caching, every page view hits the server for every resource. At scale, this means millions of redundant requests serving identical bytes.
The core insight
Most web resources don't change between page loads. Your CSS file is the same today as it was 5 minutes ago. Your logo hasn't changed in months. Caching tells the browser: "you already have this — use your copy."
What Is HTTP Caching?
HTTP caching is a mechanism where the browser (or an intermediary like a CDN) stores a copy of a server response and reuses it for subsequent requests to the same URL. The server controls caching behavior through response headers.
Where Caches Live
Browser Cache (Private)
Stored on the user's device. Only serves that specific user. Controlled by Cache-Control: private. Stores HTML, CSS, JS, images, API responses.
CDN Cache (Shared)
Stored on edge servers worldwide. Serves all users in a region. Controlled by Cache-Control: public. Ideal for static assets that are the same for everyone.
Proxy Cache (Shared)
Corporate proxies, ISP caches, or reverse proxies (Nginx, Varnish). Sit between browser and origin server. Respect Cache-Control: public/private directives.
Browser requests GET /styles.css: ┌─────────────────┐ │ Browser Cache │ Is there a cached copy? │ (private) │ Is it still fresh (not expired)? └────────┬────────┘ │ HIT & FRESH ──→ Use cached copy. Done. (<1ms) │ MISS or STALE ▼ ┌─────────────────┐ │ CDN / Proxy │ Is there a shared cached copy? │ (shared) │ Is it still fresh? └────────┬────────┘ │ HIT & FRESH ──→ Return cached copy. (~5-20ms) │ MISS or STALE ▼ ┌─────────────────┐ │ Origin Server │ Generate fresh response. │ │ Set caching headers. └─────────────────┘ Return to browser. (~100-500ms) Each level checks: do I have it? Is it still valid? The closer the cache hit, the faster the response.
Fresh vs stale
A cached response is fresh if it hasn't exceeded its max-age. A stale response has expired but may still be valid — the browser can ask the server to validate it (conditional request) instead of downloading the full resource again.
Cache-Control Header
Cache-Control is the primary header that controls caching behavior. It's set by the server in the response and tells the browser (and intermediaries) exactly how to cache the response.
Key Directives
| Directive | Meaning | Example |
|---|---|---|
max-age=N | Cache is fresh for N seconds. No network request needed during this window. | Cache-Control: max-age=3600 (1 hour) |
no-cache | Cache the response, but ALWAYS revalidate with the server before using it. Does NOT mean 'don't cache'. | Cache-Control: no-cache |
no-store | Do NOT cache at all. Every request goes to the server. Used for sensitive data. | Cache-Control: no-store |
public | Any cache (browser, CDN, proxy) can store this response. Used for shared resources. | Cache-Control: public, max-age=86400 |
private | Only the browser can cache this. CDNs and proxies must NOT store it. Used for user-specific data. | Cache-Control: private, max-age=600 |
immutable | The resource will NEVER change. Browser skips revalidation even on hard reload. | Cache-Control: public, max-age=31536000, immutable |
s-maxage=N | Like max-age but only for shared caches (CDN/proxy). Overrides max-age for intermediaries. | Cache-Control: public, max-age=60, s-maxage=3600 |
stale-while-revalidate=N | Serve stale content for N seconds while fetching a fresh copy in the background. | Cache-Control: max-age=60, stale-while-revalidate=300 |
no-cache vs no-store — The Most Confused Pair
Cache-Control: no-cache ───────────────────────────────────────────── ✅ Browser DOES cache the response ✅ But MUST revalidate with server before using it → Browser sends conditional request (If-None-Match / If-Modified-Since) → Server responds 304 (use cache) or 200 (new data) → Saves bandwidth if resource hasn't changed (no body in 304) Use for: HTML pages, API responses that change frequently Cost: 1 round trip per request (but no download if unchanged) Cache-Control: no-store ───────────────────────────────────────────── ❌ Browser does NOT cache the response at all ❌ Every request downloads the full resource → No conditional requests, no 304s → Full download every time Use for: Sensitive data (bank statements, medical records) Cost: Full download on every request ⚠️ "no-cache" does NOT mean "don't cache" "no-store" means "don't cache"
The interview trap
"What's the difference between no-cache and no-store?" is asked in almost every frontend interview that covers caching. no-cache = cache it but always check with the server first. no-store = don't cache it at all. The name "no-cache" is misleading — it should have been called "must-revalidate."
Expiration vs Validation
HTTP caching uses two complementary strategies to decide whether a cached response can be reused. Understanding both is essential for designing effective caching policies.
Strategy 1: Expiration (Time-Based)
The server says "this response is valid for X seconds." During that window, the browser uses the cached copy without contacting the server at all. Zero network requests. Maximum speed.
Server response: Cache-Control: max-age=3600 (fresh for 1 hour) Request at T+0: → Network request → 200 OK → cached Request at T+30m: → Cache HIT (still fresh) → no network request (<1ms) Request at T+59m: → Cache HIT (still fresh) → no network request (<1ms) Request at T+61m: → Cache STALE (expired) → needs revalidation or fresh fetch
Strategy 2: Validation (Revalidation)
When a cached response expires (or uses no-cache), the browser asks the server: "has this changed?" If not, the server responds with 304 Not Modified and no body — saving bandwidth.
Server response (first request): HTTP/2 200 OK Cache-Control: no-cache ETag: "abc123" Content-Length: 45000 [45KB body] Browser request (subsequent): GET /styles.css If-None-Match: "abc123" ← "I have version abc123, is it still good?" Server response (resource unchanged): HTTP/2 304 Not Modified ← "Yes, your copy is still good" ETag: "abc123" Content-Length: 0 ← No body! Saves 45KB of bandwidth Server response (resource changed): HTTP/2 200 OK ← "No, here's the new version" ETag: "def456" Content-Length: 47000 [47KB body]
| Expiration | Validation | |
|---|---|---|
| How it works | Browser uses cached copy without asking server | Browser asks server if cached copy is still valid |
| Network request? | None (during fresh period) | Yes, but response may be tiny (304, no body) |
| Speed | <1ms (instant, from disk) | ~50-200ms (1 round trip, but no download if unchanged) |
| Freshness guarantee | May serve stale data until max-age expires | Always up-to-date (server confirms on every request) |
| Best for | Static assets that rarely change (JS, CSS, images with hashes) | Dynamic content that changes unpredictably (HTML, API responses) |
| Headers | Cache-Control: max-age=N | ETag + If-None-Match, or Last-Modified + If-Modified-Since |
Best practice: combine both
Use expiration for the fast path (serve from cache instantly) and validation as a fallback (check with server when expired). Example: Cache-Control: max-age=3600 + ETag: "abc123". Fresh for 1 hour, then revalidates.
Conditional Requests
Conditional requests are how validation works in practice. The browser sends a request with a condition: "give me this resource only IF it has changed." If it hasn't changed, the server responds with 304 and no body.
ETag + If-None-Match
An ETag is a version identifier for a resource — typically a hash of the content. It's the most reliable validation mechanism because it detects any change to the content, no matter how small.
Server sends ETag with the response
First response includes ETag: "abc123" — a fingerprint of the current content. The browser stores this alongside the cached response.
Browser sends If-None-Match on next request
When the cache expires, the browser sends If-None-Match: "abc123" — asking "is my version still current?"
Server compares ETags
If the current ETag matches → 304 Not Modified (no body, use cache). If different → 200 OK with new content and new ETag.
── First Request ────────────────────────────────── GET /api/products HTTP/2 Host: api.example.com HTTP/2 200 OK Cache-Control: max-age=60 ETag: "a1b2c3d4" Content-Type: application/json Content-Length: 15000 [15KB JSON body] ── After 60s (cache expired) ────────────────────── GET /api/products HTTP/2 If-None-Match: "a1b2c3d4" ← "I have this version" HTTP/2 304 Not Modified ← "Your version is still good" ETag: "a1b2c3d4" Content-Length: 0 ← No body! Saved 15KB Browser uses cached copy. Total transfer: ~200 bytes (headers only).
Last-Modified + If-Modified-Since
An older mechanism that uses timestamps instead of content hashes. Less precise than ETags (1-second resolution) but simpler to implement.
── First Request ────────────────────────────────── HTTP/2 200 OK Last-Modified: Sat, 21 Mar 2026 14:30:00 GMT Content-Length: 45000 [45KB body] ── Subsequent Request ───────────────────────────── GET /styles.css HTTP/2 If-Modified-Since: Sat, 21 Mar 2026 14:30:00 GMT HTTP/2 304 Not Modified ← Not changed since that date Content-Length: 0
| ETag | Last-Modified | |
|---|---|---|
| Mechanism | Content hash / version string | Timestamp of last change |
| Precision | Byte-level (any change detected) | 1-second resolution |
| Request header | If-None-Match | If-Modified-Since |
| Reliability | High — detects all changes | Lower — misses sub-second changes |
| Use case | API responses, dynamic content | Static files, simple setups |
Prefer ETags
ETags are more reliable than Last-Modified because they detect any content change, not just timestamp changes. A file can be modified twice in the same second (Last-Modified misses this) or touched without changing content (Last-Modified triggers a false invalidation). Use ETags as your primary validation mechanism.
Other Important Headers
Beyond Cache-Control and ETags, several other headers influence caching behavior. Some are legacy but still appear in production systems.
| Header | Purpose | Notes |
|---|---|---|
Expires | Sets an absolute expiration date for the cached response | Legacy. Superseded by Cache-Control: max-age. If both are present, max-age wins. Uses absolute dates which break if clocks are wrong. |
Vary | Tells caches that the response varies based on specific request headers | Vary: Accept-Encoding means gzip and non-gzip responses are cached separately. Vary: Cookie means each user gets a separate cache entry. Critical for CDNs. |
Pragma: no-cache | HTTP/1.0 equivalent of Cache-Control: no-cache | Legacy. Only included for backward compatibility with very old proxies. Always use Cache-Control instead. |
Age | How long the response has been in a shared cache (seconds) | Set by CDNs/proxies. If Age: 300 and max-age: 3600, the response has 3300 seconds of freshness remaining. |
Clear-Site-Data | Instructs the browser to clear cached data for the origin | Clear-Site-Data: "cache" clears all cached responses. Useful for logout flows to ensure no sensitive data remains cached. |
The Vary Header — Why It Matters for CDNs
Server response: Cache-Control: public, max-age=3600 Vary: Accept-Encoding Content-Encoding: gzip What this means for the CDN: The response varies based on Accept-Encoding. A client sending "Accept-Encoding: gzip" gets one cached version. A client sending "Accept-Encoding: br" gets a different cached version. A client sending no Accept-Encoding gets yet another version. Without Vary, the CDN might serve a gzip response to a client that doesn't support gzip — breaking the page. Common Vary values: Vary: Accept-Encoding → Different compression formats Vary: Accept-Language → Different language versions Vary: Cookie → Different per user (⚠️ defeats CDN caching) Vary: * → Never cache (equivalent to no-store for shared caches)
Watch out for Vary: Cookie
Setting Vary: Cookie on a CDN-cached response means every unique cookie value creates a separate cache entry. Since cookies are often unique per user, this effectively disables CDN caching. Use Cache-Control: private instead for user-specific responses.
Caching Strategies
Different resources need different caching strategies. The key is matching the cache policy to how often the resource changes and how critical freshness is.
Strategy 1: Cache Forever (Fingerprinted Static Assets)
Files with content hashes in the filename: app.a1b2c3.js styles.d4e5f6.css logo.g7h8i9.png Cache-Control: public, max-age=31536000, immutable Why this works: • The filename changes when the content changes (cache busting) • So the URL is effectively a unique version identifier • Safe to cache for 1 year (31536000 seconds) • "immutable" tells the browser: don't even revalidate on hard reload • CDNs cache it globally — served from edge nodes worldwide When the file changes: • Build tool generates a new hash: app.x9y8z7.js • HTML references the new filename • Browser sees a new URL → fetches it fresh • Old cached version is never requested again
Strategy 2: Always Revalidate (HTML Pages)
Cache-Control: no-cache ETag: "page-v42" Why: • HTML is the entry point — it references CSS/JS filenames • If HTML is stale, it may reference old CSS/JS files that no longer exist • no-cache ensures the browser always checks with the server • ETag enables 304 responses — if HTML hasn't changed, no download needed • Cost: 1 round trip per page load, but usually just headers (304)
Strategy 3: Short Cache (API Responses)
Cache-Control: private, max-age=60, stale-while-revalidate=300 Why: • "private" — user-specific data, don't cache on CDN • "max-age=60" — fresh for 1 minute (acceptable staleness) • "stale-while-revalidate=300" — if stale, serve cached version immediately while fetching fresh data in the background • Good for dashboards, feeds, user profiles • Balances freshness with performance
Strategy 4: Never Cache (Sensitive Data)
Cache-Control: no-store Why: • Bank account balances, medical records, payment details • Must NEVER be stored on disk (even encrypted) • Every request fetches fresh from the server • Most restrictive policy — use only when necessary
| Resource Type | Strategy | Cache-Control | Validation |
|---|---|---|---|
| Fingerprinted JS/CSS/images | Cache forever | public, max-age=31536000, immutable | Not needed (URL changes) |
| HTML pages | Always revalidate | no-cache | ETag |
| API responses (public) | Short cache + CDN | public, max-age=60, s-maxage=300 | ETag |
| API responses (private) | Short cache + SWR | private, max-age=60, stale-while-revalidate=300 | ETag |
| Sensitive data | Never cache | no-store | None |
| Fonts | Long cache | public, max-age=31536000 | Not needed (rarely change) |
The golden pattern
Fingerprint your static assets (content hash in filename) + cache them forever + always revalidate your HTML. This gives you instant loads for returning visitors AND instant cache invalidation when you deploy — because the HTML points to new filenames.
Real-World Example
Let's see the difference caching makes for a real e-commerce product page with typical resources.
❌ Without Caching Headers
GET /products/shoes → 200 OK (12KB HTML) ~200ms GET /app.js → 200 OK (150KB JS) ~250ms GET /styles.css → 200 OK (40KB CSS) ~180ms GET /logo.png → 200 OK (15KB image) ~160ms GET /hero.jpg → 200 OK (80KB image) ~220ms GET /api/products/123 → 200 OK (3KB JSON) ~300ms GET /fonts/inter.woff2 → 200 OK (35KB font) ~190ms Total: 335KB, 7 requests, ~300ms (parallel with HTTP/2) ── User navigates to another product, then comes back ── GET /products/shoes → 200 OK (12KB HTML) ~200ms GET /app.js → 200 OK (150KB JS) ~250ms ← SAME FILE! GET /styles.css → 200 OK (40KB CSS) ~180ms ← SAME FILE! GET /logo.png → 200 OK (15KB image) ~160ms ← SAME FILE! GET /hero.jpg → 200 OK (80KB image) ~220ms ← SAME FILE! GET /api/products/123 → 200 OK (3KB JSON) ~300ms GET /fonts/inter.woff2 → 200 OK (35KB font) ~190ms ← SAME FILE! Total: 335KB again. 100% wasted bandwidth for unchanged files.
✅ With Proper Caching Headers
── Server response headers ── /products/shoes → Cache-Control: no-cache, ETag: "page-v5" /app.a1b2c3.js → Cache-Control: public, max-age=31536000, immutable /styles.d4e5f6.css → Cache-Control: public, max-age=31536000, immutable /logo.png → Cache-Control: public, max-age=86400 /hero.jpg → Cache-Control: public, max-age=86400 /api/products/123 → Cache-Control: private, max-age=60, ETag: "prod-v8" /fonts/inter.woff2 → Cache-Control: public, max-age=31536000 ── Return visit (within 60 seconds) ── GET /products/shoes → 304 Not Modified (0KB) ~80ms ← just headers GET /app.a1b2c3.js → (from cache) <1ms ← no request! GET /styles.d4e5f6.css → (from cache) <1ms ← no request! GET /logo.png → (from cache) <1ms ← no request! GET /hero.jpg → (from cache) <1ms ← no request! GET /api/products/123 → (from cache) <1ms ← still fresh GET /fonts/inter.woff2 → (from cache) <1ms ← no request! Total: ~0.5KB transferred, 1 network request, ~80ms Savings: 99.8% less bandwidth, 73% faster
| Metric | Without Caching | With Caching |
|---|---|---|
| Network requests | 7 requests | 1 request (304) |
| Data transferred | 335KB | ~0.5KB (headers only) |
| Load time | ~300ms | ~80ms |
| Server load | 7 requests processed | 1 lightweight 304 |
The compound effect
This is one page load. Multiply by thousands of users and dozens of page navigations per session. Proper caching can reduce your server bandwidth by 90%+ and your CDN costs proportionally. It's one of the highest-ROI optimizations in web development.
Performance Insights
Caching is the highest-leverage performance optimization on the web. Here are the specific techniques and their impact.
Fingerprint Static Assets + Cache Forever
Content-hash filenames (app.a1b2c3.js) with max-age=31536000 and immutable. Eliminates all network requests for static assets on repeat visits. The single most impactful caching strategy.
Always Revalidate HTML with ETags
Cache-Control: no-cache + ETag on HTML pages. Ensures users always get the latest HTML (which references the latest asset filenames) while saving bandwidth with 304 responses when unchanged.
stale-while-revalidate for API Responses
Serves cached API data instantly while fetching fresh data in the background. Users see content immediately (even if slightly stale) and get updated data on the next interaction. Great for dashboards and feeds.
CDN Caching with s-maxage
Use s-maxage to set longer cache durations on CDN edges than in the browser. Example: max-age=60, s-maxage=3600 — browser revalidates after 1 minute, CDN caches for 1 hour. Reduces origin server load dramatically.
Preload Critical Resources
Use <link rel='preload'> for fonts and critical CSS that the browser discovers late in the HTML. Combined with long cache durations, this ensures critical resources are both cached and loaded early.
Service Worker Cache (Offline Support)
A Service Worker can intercept requests and serve from a programmatic cache — enabling offline support and custom caching strategies beyond what HTTP headers offer. Useful for PWAs.
Measure your cache hit rate
In Chrome DevTools Network tab, look at the "Size" column. Responses served from cache show "(disk cache)" or "(memory cache)" instead of a byte count. A well-cached site should show 80-95% of requests served from cache on repeat visits.
Common Mistakes
Using no-store when you mean no-cache
no-store prevents ALL caching — every request downloads the full resource. Developers often use it when they just want fresh data, but no-cache (revalidate every time) would save bandwidth with 304 responses.
✅Use no-cache + ETag for content that must be fresh. Reserve no-store only for truly sensitive data (bank balances, medical records) that must never be stored on disk.
Long max-age on non-fingerprinted assets
Setting max-age=31536000 on /app.js (no hash in filename) means users are stuck with the cached version for a year. When you deploy a fix, users won't see it until the cache expires.
✅Either fingerprint your assets (app.a1b2c3.js) so the URL changes on deploy, or use short max-age with ETag validation. Never set long max-age on URLs that don't change when content changes.
Forgetting to cache static assets at all
Without Cache-Control headers, browsers use heuristic caching (based on Last-Modified date) which is unpredictable. Some resources get cached, some don't, and the duration varies.
✅Explicitly set Cache-Control on every response. Don't rely on browser heuristics. Be intentional: cache forever (fingerprinted), revalidate always (HTML), or never cache (sensitive).
Caching user-specific responses on CDN
Setting Cache-Control: public on responses that contain user-specific data (account info, personalized content) means the CDN serves User A's data to User B.
✅Use Cache-Control: private for any response that varies per user. This restricts caching to the browser only. For CDN-cached pages with some personalization, load user-specific data via separate API calls.
Not setting Vary: Accept-Encoding
If your server sends gzip-compressed responses without Vary: Accept-Encoding, a CDN might cache the gzip version and serve it to a client that doesn't support gzip — breaking the page.
✅Always include Vary: Accept-Encoding when your server supports content negotiation for compression. Most CDNs and web servers handle this automatically, but verify it.
Interview Questions
Q:What is the difference between no-cache and no-store?
A: no-cache stores the response in cache but requires revalidation with the server before every use — the browser sends a conditional request (If-None-Match) and the server responds with 304 (use cache) or 200 (new data). no-store prevents caching entirely — every request downloads the full resource. Use no-cache for content that must be fresh (HTML pages), no-store for sensitive data that must never be stored on disk.
Q:What is an ETag and how does it work?
A: An ETag is a version identifier for a resource, typically a hash of the content (e.g., ETag: "abc123"). When the browser has a cached response with an ETag, it sends If-None-Match: "abc123" on subsequent requests. The server compares ETags — if they match, it responds with 304 Not Modified (no body), saving bandwidth. If different, it sends 200 with the new content and a new ETag.
Q:How would you design a caching strategy for a web application?
A: Three-tier approach: (1) Fingerprinted static assets (JS, CSS, images with content hashes) get Cache-Control: public, max-age=31536000, immutable — cached forever, URL changes on deploy. (2) HTML pages get Cache-Control: no-cache with ETag — always revalidated but 304 saves bandwidth. (3) API responses get Cache-Control: private, max-age=60 with stale-while-revalidate — short freshness window with instant stale serving.
Q:What is a 304 Not Modified response?
A: 304 is a status code that tells the browser 'your cached copy is still valid — use it.' It's sent in response to a conditional request (If-None-Match or If-Modified-Since). The 304 response has no body, only headers, saving the bandwidth of re-downloading the resource. It's the mechanism behind cache validation — the browser checks with the server, and the server confirms the cache is still fresh.
Q:What is cache busting and why is it needed?
A: Cache busting is the technique of changing a resource's URL when its content changes, forcing browsers to fetch the new version instead of using the cached old one. The most common approach is content-hash filenames (app.a1b2c3.js). When you deploy new code, the build tool generates a new hash (app.x9y8z7.js), the HTML references the new URL, and browsers fetch it fresh. This lets you set very long max-age on static assets without worrying about stale caches.
Q:What does the Vary header do?
A: Vary tells caches that the response depends on specific request headers. Vary: Accept-Encoding means the cache should store separate versions for gzip, brotli, and uncompressed responses. Without Vary, a CDN might serve a gzip response to a client that doesn't support it. Vary: Cookie effectively disables shared caching because each user has different cookies. Vary: * means never cache in shared caches.
Q:What is stale-while-revalidate?
A: stale-while-revalidate is a Cache-Control directive that lets the browser serve a stale cached response immediately while fetching a fresh copy in the background. Example: max-age=60, stale-while-revalidate=300 means the response is fresh for 60 seconds, then for the next 300 seconds the browser serves the stale version instantly and updates the cache in the background. The user gets instant response times with eventual freshness.
Q:Why should you use Cache-Control: private for user-specific data?
A: Cache-Control: private restricts caching to the browser only — CDNs and shared proxies must not store the response. Without it (or with public), a CDN could cache User A's personalized dashboard and serve it to User B. This is a security and correctness issue. Use private for any response containing user-specific data: account info, personalized feeds, authenticated API responses.
Practice Section
The Stale Dashboard
Users report that after a deploy, they still see the old version of your dashboard for hours. Your JS bundle is served as /app.js with Cache-Control: public, max-age=86400. What's wrong and how do you fix it?
Answer: The filename doesn't change on deploy, so browsers serve the cached /app.js for 24 hours. Fix: add content hashes to filenames (app.a1b2c3.js) so the URL changes on every deploy. Set max-age=31536000 with immutable on hashed files. Always revalidate the HTML (no-cache + ETag) so it picks up new asset filenames immediately.
The Stale API Data
Your dashboard API returns user-specific data with Cache-Control: public, max-age=3600. A user logs out and another user logs in on the same shared computer. The second user sees the first user's data. What happened?
Answer: Cache-Control: public allows shared caches (and the browser cache keyed by URL) to store the response. The second user's request hits the same cached response. Fix: use Cache-Control: private (browser-only cache) and add Vary: Cookie or Vary: Authorization so different users get different cache entries. For sensitive data, consider no-store.
The Bandwidth Bill
Your CDN bandwidth bill is unexpectedly high. Investigation shows that your 150KB JavaScript bundle is being downloaded on every page load despite having Cache-Control: max-age=3600. The bundle hasn't changed in weeks. What could cause this?
Answer: Possible causes: (1) The HTML page has no-store, preventing the browser from caching the HTML that references the JS — each visit generates a new request. (2) The server isn't sending ETag or Last-Modified, so the browser can't do conditional requests. (3) Vary: Cookie is set, creating a separate cache entry per user. Fix: fingerprint the JS file, set immutable, and ensure the HTML uses no-cache (not no-store).
The Cache Invalidation Problem
You need to immediately invalidate a cached API response across all users. The response was served with Cache-Control: public, max-age=3600 and is cached on your CDN. How do you force all users to get fresh data?
Answer: For the CDN: use the CDN's purge/invalidation API to clear the cached response from all edge nodes. For browser caches: you can't force invalidation — the browser will serve the cached version until max-age expires. For future prevention: use shorter max-age with stale-while-revalidate, or use no-cache with ETag so every request validates with the server.
The Font Loading Delay
Your custom font (200KB) is re-downloaded on every page navigation within your SPA, causing a flash of unstyled text each time. The font file never changes. How would you optimize this?
Answer: Set Cache-Control: public, max-age=31536000 on the font file — it never changes, so cache it forever. Add <link rel='preload' as='font' crossorigin> in the HTML to start downloading it early. The combination of long cache + preload means the font loads instantly from cache on subsequent navigations with no flash.
Cheat Sheet
Quick Revision Cheat Sheet
Cache-Control: max-age=N: Response is fresh for N seconds. No network request during this window. Browser serves from cache in <1ms.
Cache-Control: no-cache: Cache the response but ALWAYS revalidate with the server before using it. Does NOT mean 'don't cache'. Enables 304 responses.
Cache-Control: no-store: Do NOT cache at all. Every request downloads the full resource. Use only for sensitive data (bank statements, medical records).
public vs private: public: any cache (browser, CDN, proxy) can store it. private: only the browser can store it. Use private for user-specific data to prevent CDN leaks.
ETag + If-None-Match: ETag is a content fingerprint. Browser sends If-None-Match with the cached ETag. Server responds 304 (use cache) or 200 (new data). Saves bandwidth.
304 Not Modified: Server confirms the cached copy is still valid. No body in the response — just headers. The browser uses its cached version. Costs 1 round trip but saves the download.
Fingerprinted assets: Content hash in filename (app.a1b2c3.js). Cache forever with max-age=31536000, immutable. URL changes when content changes — automatic cache busting.
HTML caching: Always use no-cache + ETag for HTML. HTML references asset filenames — if HTML is stale, it may point to old assets. Revalidation ensures users get the latest entry point.
stale-while-revalidate: Serve stale content instantly while fetching fresh data in the background. Great for API responses where slight staleness is acceptable. Instant UX with eventual freshness.
s-maxage: Cache duration for shared caches (CDN/proxy) only. Overrides max-age for intermediaries. Use to cache longer on CDN than in browser: max-age=60, s-maxage=3600.
Vary header: Tells caches the response varies by request header. Vary: Accept-Encoding = separate cache per compression. Vary: Cookie = separate cache per user (defeats CDN caching).
The golden rule: The fastest request is no request. Fingerprint + cache forever for static assets. Revalidate HTML. Short cache + SWR for APIs. no-store only for sensitive data.