Browser Caching: Cache-Control, ETag, and Stale-While-Revalidate
Cache-Control headers control how browsers and CDNs cache responses. max-age=N serves from cache without a network request. no-cache revalidates with the server on every request (using ETag/Last-Modified). no-store never caches. stale-while-revalidate serves stale content immediately while revalidating in background. Immutable assets (hashed filenames) should use max-age=31536000, immutable.
Cache-Control directives
Cache-Control is a response header that tells browsers and intermediate caches (CDNs, proxies) what to do with the response:
| Directive | Effect |
|---|---|
| max-age=3600 | Cache for 3600 seconds. Serve from cache without network request while fresh. |
| no-cache | Cache the response, but revalidate with server on every use (conditional request). |
| no-store | Never cache. Always fetch fresh from server. |
| private | Only the browser may cache — CDNs and shared caches must not. |
| public | Any cache (browser, CDN, proxy) may store the response. |
| immutable | Content will never change for the lifetime of max-age. Browser skips revalidation even on hard refresh. |
| stale-while-revalidate=60 | Serve stale response immediately for up to 60s while fetching fresh in background. |
| must-revalidate | Once stale (past max-age), must revalidate before serving. No serving stale on network failure. |
# Static asset with content hash in filename — cache 1 year, never revalidate
Cache-Control: public, max-age=31536000, immutable
# HTML page — cache 1 hour, but revalidate if stale
Cache-Control: public, max-age=3600, must-revalidate
# API response — don't use stale, revalidate every time
Cache-Control: no-cache
# Sensitive user data — don't cache anywhere
Cache-Control: no-store, private
Conditional requests: ETag and Last-Modified
no-cache doesn't mean "never cache" — it means "cache but always revalidate." The revalidation uses conditional request headers to avoid re-downloading unchanged content:
First request:
GET /api/data HTTP/1.1
Response:
HTTP/1.1 200 OK
Cache-Control: no-cache
ETag: "abc123"
Last-Modified: Sat, 22 Mar 2026 10:00:00 GMT
Content-Length: 4096
[body...]
Second request (browser revalidates):
GET /api/data HTTP/1.1
If-None-Match: "abc123"
If-Modified-Since: Sat, 22 Mar 2026 10:00:00 GMT
Response (if unchanged):
HTTP/1.1 304 Not Modified
ETag: "abc123"
[no body — browser uses cached copy]
Response (if changed):
HTTP/1.1 200 OK
ETag: "xyz789"
[new body...]
ETag (entity tag) is a hash or version identifier for the resource. Preferred over Last-Modified because:
Last-Modifiedhas 1-second granularity — rapid updates within a second aren't detectedETagcan reflect content changes that don't change the modification time (e.g., equivalent reformatting)
With a 304, the browser pays only for the round-trip (typically ~50ms on a good connection), not for re-downloading the body.
stale-while-revalidate
stale-while-revalidate separates freshness from usability:
Cache-Control: max-age=60, stale-while-revalidate=300
t=0s: First request — fetch from server, cache response
t=30s: Second request — serve from cache (fresh, no network)
t=90s: Third request — max-age expired. Serve stale immediately,
trigger background revalidation fetch
t=91s: Background fetch completes — cache updated
t=92s: Fourth request — serve updated response
t=400s: Fifth request — past stale-while-revalidate window (300s after max-age expired).
Must wait for network fetch.
This is ideal for content that's acceptable to be slightly stale (dashboards, feeds, list pages) but where you still want low latency for most users.
Hashed filenames + max-age=31536000,immutable eliminates revalidation for static assets entirely
PatternWeb Performance / CachingFor static assets (JS bundles, CSS, images), the standard pattern is: include a content hash in the filename (main.abc123.js), set Cache-Control: public, max-age=31536000, immutable. The immutable directive tells browsers to never revalidate this URL even on hard refresh — the URL itself guarantees the content is immutable. When you deploy a new version, the hash changes, the URL changes, and browsers fetch fresh. This gives perfect cache hit rates for returning visitors while ensuring they always get updated assets after deployment.
Prerequisites
- Cache-Control
- content-addressable storage
- build tooling (webpack/vite)
Key Points
- max-age=31536000 (1 year) is the maximum browsers reliably honor — longer values are truncated.
- immutable skips revalidation on hard refresh (Ctrl+Shift+R) — without it, browsers still send conditional requests.
- Content hash changes on any file content change — cache busting is automatic.
- HTML files should NOT use long max-age — they reference the hashed asset URLs and must be fresh to trigger updates.
Vary header and cache partitioning
Vary tells caches which request headers affect the response:
# Different responses for different Accept-Encoding values
Vary: Accept-Encoding
# Different responses for different origins (CORS preflight responses)
Vary: Origin
# Multiple headers
Vary: Accept-Encoding, Accept-Language
A cache stores separate entries for each Vary combination. A CDN caching a response with Vary: Accept-Encoding stores separate copies for gzip, br, and uncompressed responses.
Modern browsers also apply cache partitioning: the HTTP cache is keyed by (top-frame origin, frame origin, request URL), not just URL. This prevents cross-site cache probing attacks — a resource fetched by attacker.com doesn't share a cache entry with the same URL fetched by bank.com.
An API returns `Cache-Control: no-cache, max-age=0`. A client requests the same URL twice in 1 second. How many network requests reach the server?
mediumno-cache means the browser caches the response but revalidates on every use. max-age=0 sets freshness to 0 seconds.
A0 — the response is cached and served without any network requests
Incorrect.no-cache does not mean 'no network requests.' It means the browser can cache the response but must revalidate on every subsequent use. At least one network request happens.B2 — both requests reach the server because no-cache prevents caching entirely
Incorrect.no-cache allows caching — it just forces revalidation. If ETag is present, the second request sends If-None-Match and may receive a 304 (still a network request, but the server returns no body). Without ETag/Last-Modified, 2 full fetches occur.C2 conditional requests reach the server, but only 1 transfers a full body — if the response includes an ETag and is unchanged, the second request gets a 304 Not Modified
Correct!First request: full fetch, server returns 200 with ETag. Second request: browser sends If-None-Match header. If content unchanged, server returns 304 with no body. Both requests involve a network round-trip to the server, but only the first transfers a body. This is why no-cache still improves performance over no-store — it avoids re-downloading unchanged data.D1 — the second request is deduplicated by the browser
Incorrect.Browsers don't deduplicate requests to the same URL within a second — each request is processed independently. Only cache freshness (max-age > 0) prevents a network request.
Hint:What does no-cache actually cache? What headers enable conditional revalidation?