HTTP Caching Guide: Cache-Control, ETag & CDN Tuning
HTTP Caching: The First Line of Defense in Performance Optimization
With properly configured caching, repeat visits require zero network requests, and load times drop from seconds to milliseconds.
| Cache Type | Mechanism | Hits the Server? | Speed |
|---|---|---|---|
| Strong cache | Cache-Control / Expires | No | Fastest (0ms) |
| Negotiated cache | ETag / Last-Modified | Yes (conditional request) | Fast (304 response) |
| No cache | no-cache / no-store | Yes (full request) | Slowest |
1. Strong Caching
Cache-Control Directives
Cache-Control: max-age=31536000, immutable
| Directive | Effect | Use Case |
|---|---|---|
max-age=N |
Cache for N seconds | Static assets |
s-maxage=N |
CDN caches for N seconds (overrides max-age) | CDN scenarios |
immutable |
Never revalidate, even after expiry | Files with hashed names |
public |
Allow intermediate caches | CDN |
private |
Browser cache only | Personalized content |
no-cache |
Must revalidate before each use | HTML pages |
no-store |
Never cache at all | Sensitive data |
must-revalidate |
Must revalidate once expired | API responses |
stale-while-revalidate=N |
Serve stale content while refreshing in the background | Progressive updates |
Expires (Legacy Standard)
Expires: Thu, 02 Jun 2027 00:00:00 GMT
Priority: Cache-Control: max-age > Expires.
2. Negotiated Caching
ETag / If-None-Match
# First request
GET /api/data
→ 200 OK
ETag: "abc123"
Cache-Control: max-age=0
# Subsequent request (conditional)
GET /api/data
If-None-Match: "abc123"
→ 304 Not Modified (no response body, saves bandwidth)
Last-Modified / If-Modified-Since
# First request
GET /style.css
→ 200 OK
Last-Modified: Mon, 01 Jun 2026 12:00:00 GMT
# Subsequent request
GET /style.css
If-Modified-Since: Mon, 01 Jun 2026 12:00:00 GMT
→ 304 Not Modified
ETag vs Last-Modified
| Dimension | ETag | Last-Modified |
|---|---|---|
| Precision | Byte-level | Second-level |
| Performance | Requires hash computation | Reads file timestamp directly |
| Reliability | High | Low (misses multiple changes within 1 second) |
| Distributed setups | Requires consistent hashing | Naturally consistent |
Recommendation: Prefer ETag.
3. Caching Strategy Matrix
HTML Pages
Cache-Control: no-cache
HTML is the entry file — it must be revalidated every time so users always get the latest version.
Static Assets with Content Hash
Cache-Control: max-age=31536000, immutable
When the filename contains a hash (e.g. app.abc123.js), any content change produces a new hash → new URL → new request. The old URL never changes, so it can be cached for a year and marked immutable.
Static Assets without Hash
Cache-Control: max-age=86400, stale-while-revalidate=604800
After expiry, the stale version is served while the cache silently refreshes in the background — users never notice.
API Responses
Cache-Control: private, max-age=0, must-revalidate
API responses usually contain user data. private prevents CDN caching, and must-revalidate ensures stale values are never served after expiry.
4. The Vary Header: Extending the Cache Key
Vary: Accept-Encoding, Accept-Language
Vary tells caches: besides the URL, also distinguish cached versions by the values of these request headers.
| Vary Value | Meaning | Scenario |
|---|---|---|
Accept-Encoding |
Cache per compression method | gzip vs br |
Accept-Language |
Cache per language | Multilingual sites |
Cookie |
Cache per cookie | Personalized pages |
* |
Every request cached independently | Equivalent to no caching |
Caution: Vary: Cookie leads to an extremely low cache hit rate — use with care.
5. CDN Caching Strategies
Origin-Pull Control
# CDN caches for 1 day, browser caches for 5 minutes
Cache-Control: public, s-maxage=86400, max-age=300
| Scenario | s-maxage | max-age | Notes |
|---|---|---|---|
| Static assets | 31536000 | 31536000 | Long cache on both CDN and browser |
| API responses | 300 | 0 | CDN caches 5 minutes, browser doesn't cache |
| HTML | 60 | 0 | CDN caches 1 minute, browser doesn't cache |
| Personalized content | — | 0 | Neither CDN nor browser caches |
CDN Cache Purging
| Method | Speed | Best For |
|---|---|---|
| URL prewarming | Fast | New content launches |
| Full purge | Slow | Emergency fixes |
| Directory purge | Medium | Bulk updates |
| Cache-Control: no-cache | Automatic | API scenarios |
Stale-While-Revalidate in Practice
Cache-Control: max-age=60, stale-while-revalidate=86400
Second 0: request → cache MISS → origin pull → fresh value returned
Second 30: request → cache HIT → cached value returned
Second 90: request → cache STALE → stale value returned immediately + background origin pull
Second 91: background pull completes → cache updated
Second 120: request → cache HIT → new value returned
Result: Users never wait for an origin pull; updates happen silently in the background.
6. Cache-Busting Strategies
Content Hash (Recommended)
<!-- Hash generated at build time -->
<script src="/app.abc123.js"></script>
<link href="/style.def456.css">
// webpack configuration
output: {
filename: '[name].[contenthash:8].js',
}
Version Query Parameter
<!-- Simple, but some CDNs may not cache ?v= parameters -->
<script src="/app.js?v=2.0.0"></script>
Problem: Some CDNs ignore query parameters, causing stale caches.
Timestamp (Not Recommended)
<!-- Changes every time, completely breaking the cache -->
<script src="/app.js?t=1717315200"></script>
7. Service Worker Caching
Cache First Strategy
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then(cached => cached || fetch(event.request))
);
});
Stale While Revalidate Strategy
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('dynamic').then(cache =>
cache.match(event.request).then(cached => {
const fetchPromise = fetch(event.request)
.then(response => {
cache.put(event.request, response.clone());
return response;
});
return cached || fetchPromise;
})
)
);
});
Choosing a Strategy
| Strategy | Primary Source | Best For |
|---|---|---|
| Cache First | Cache | Fonts, images |
| Network First | Network | APIs, HTML |
| Stale While Revalidate | Cache + background update | CSS, JS |
| Network Only | Network | Non-GET requests |
| Cache Only | Cache | Offline pages |
8. Debugging Caches
Chrome DevTools
- DevTools → Network panel
- Check the
Sizecolumn:from disk cache: disk cache hitfrom memory cache: memory cache hit- Actual size: cache miss
- Check the
Ageresponse header: how long the cached copy has existed - Enable Disable cache: disables caching (while DevTools is open)
Verifying with curl
# Inspect response headers
curl -I https://example.com/style.css
# Send a conditional request
curl -I -H 'If-None-Match: "abc123"' https://example.com/style.css
# 304 means negotiated cache hit
# Simulate different Accept-Encoding values
curl -I -H 'Accept-Encoding: br' https://example.com/style.css
9. Best Practices Checklist
- HTML: no-cache — keep the entry file always fresh
- Hashed JS/CSS: max-age=1 year + immutable
- APIs: must-revalidate — always revalidate after expiry
- CDN: s-maxage to control origin-pull frequency
- Vary: Accept-Encoding to distinguish compression formats
- Use ETag instead of Last-Modified
- stale-while-revalidate for seamless updates
- Service Worker to add offline capability