Web Development · Performance
HTTP Caching in Practice: Cache-Control, ETags, and CDN Invalidation
HTTP caching is one of the highest-leverage performance optimizations available, and one of the most consistently misunderstood. Here is how it actually works, why stale content happens, and how to design a caching strategy that does not surprise you in production.
Anurag Verma
8 min read
Sponsored
Your browser fetches a JavaScript file. Should it cache it for 5 minutes, 5 hours, or forever? Can it serve the cached version while revalidating in the background? If the file changes mid-session, when does the browser find out?
The answers depend on a handful of HTTP headers that most developers set once and forget. When the caching strategy is wrong, you get either unnecessary network requests (your CDN isn’t actually helping) or stale content delivered to users (the classic “hard reload fixes it” complaint). Getting it right takes about 30 minutes to understand and will pay off every day your application runs.
The Two Questions Caching Has to Answer
Every caching decision comes down to two questions:
- Is this response still fresh? (Can we serve the cached version without checking the server?)
- Has the content changed since we cached it? (If we have to check the server, can it confirm the cache is still valid without sending the full response?)
The first question is answered by expiration headers. The second is answered by validation headers. Understanding this split is the foundation of everything else.
Expiration: Cache-Control
Cache-Control is the primary header for controlling caching behavior. It replaced the older Expires header and gives you much more precise control.
The key directives:
max-age=N — The response is fresh for N seconds from the time it was fetched. After N seconds, the cache treats it as stale and must revalidate before serving it again.
s-maxage=N — Same as max-age, but applies only to shared caches (CDNs, proxies). When both are present, CDNs use s-maxage and browsers use max-age.
no-cache — Confusingly named. This does NOT prevent caching. It means the cache must revalidate with the origin server before serving the cached response, every time. The response is cached, but always checked. Use this for content that changes frequently but where conditional GET can still save bandwidth.
no-store — Actually prevents caching. The response is never stored in any cache, anywhere. Use for sensitive content you never want sitting in a cache.
immutable — Tells the browser this response will never change while it’s fresh. The browser won’t bother revalidating even when the user manually refreshes. Very useful combined with long max-age for content-addressed assets.
stale-while-revalidate=N — Serve the stale cached version immediately, then revalidate in the background. The user never waits for revalidation. This is ideal for content where slight staleness is acceptable.
stale-if-error=N — Serve the stale cached version if revalidation fails (origin server is down). Provides resilience without serving stale content during normal operation.
# Static assets with content-based hashes in the URL
# (e.g. main.a3f8b2c1.js, style.9d2e4f7a.css)
Cache-Control: public, max-age=31536000, immutable
# HTML pages
Cache-Control: public, max-age=0, must-revalidate
# API responses that change frequently but where stale is ok for 10s
Cache-Control: public, max-age=60, stale-while-revalidate=10
# User-specific API data
Cache-Control: private, max-age=300
# Authentication responses, payment endpoints
Cache-Control: no-store
The pattern that handles 80% of cases:
- Content-addressed static assets (JS, CSS, images with hash in filename):
Cache-Control: public, max-age=31536000, immutable. Cache forever; when the content changes, the filename changes. - HTML entrypoints:
Cache-Control: no-cache(ormax-age=0, must-revalidate). Always revalidate, so users get the latest JS/CSS filenames. - API responses: depends on how stale is acceptable. Most JSON APIs do fine with
Cache-Control: public, max-age=60, stale-while-revalidate=30. - Private user data:
Cache-Control: private, max-age=300.
Validation: ETags and Last-Modified
When a cached response is stale, the cache doesn’t immediately discard it. Instead, it sends a conditional request to the origin: “I have this version — is it still valid?” If the origin confirms it hasn’t changed, it responds with 304 Not Modified — no body, just headers. The cache updates the freshness timestamp and serves the existing cached copy.
This is validation, and it has two mechanisms:
ETags: An ETag is an opaque identifier for a specific version of a resource. The server generates it (usually a hash of the content or a version number). When the cache revalidates, it sends the ETag back in an If-None-Match header. If the current version matches the ETag, the server sends 304. If not, it sends the full response with a new ETag.
# First request
GET /api/products/42 HTTP/1.1
# Response
HTTP/1.1 200 OK
ETag: "d3b07384d113edec49eaa6238ad5ff00"
Cache-Control: public, max-age=300
Content-Type: application/json
{"id": 42, "name": "Widget", "price": 29.99}
# After max-age expires, conditional revalidation
GET /api/products/42 HTTP/1.1
If-None-Match: "d3b07384d113edec49eaa6238ad5ff00"
# If unchanged
HTTP/1.1 304 Not Modified
ETag: "d3b07384d113edec49eaa6238ad5ff00"
Cache-Control: public, max-age=300
# (no body — cache serves existing copy)
# If changed
HTTP/1.1 200 OK
ETag: "b026324c6904b2a9cb4b88d6d61c81d1"
Cache-Control: public, max-age=300
Content-Type: application/json
{"id": 42, "name": "Widget Pro", "price": 39.99}
Last-Modified: The server includes a Last-Modified timestamp with the response. The cache sends If-Modified-Since with that timestamp during revalidation. Less precise than ETags (timestamp granularity is seconds), but simpler to implement.
For most APIs, ETags are better. For static files served from disk, your web server (nginx, Caddy, Apache) generates them automatically and you don’t need to think about it.
CDNs Add Another Layer
A CDN sits between your users and your origin server, caching responses at edge locations. The caching rules work the same way — your Cache-Control and ETag headers govern CDN behavior — but there are two important differences.
CDNs distinguish max-age from s-maxage. When you send Cache-Control: public, max-age=60, s-maxage=3600, browsers cache for 60 seconds but your CDN caches for an hour. This is useful when you want users to see updates quickly but want CDN efficiency on the server-side load.
CDN invalidation does not invalidate browser caches. This is the most common source of confusion. When you push a new deployment and invalidate your CDN cache, users who have the old version in their browser cache still see the old version until their max-age expires. The fix is to not rely on invalidation — use content-addressed filenames for assets so the URL changes when the content changes.
# Wrong: relying on CDN invalidation to update users
Cache-Control: public, max-age=86400
# When you push new CSS, you invalidate the CDN path.
# But users still have the old version in their browser for 24 hours.
# Right: content-addressed filenames + long cache
# style.a3b9c2.css (hash changes with content)
Cache-Control: public, max-age=31536000, immutable
# When you push new CSS, the filename changes.
# The old URL is still cached correctly. The new URL is fetched fresh.
Modern build tools (Vite, webpack, Next.js, Astro) handle content hashing automatically. If you’re not getting content-addressed filenames from your build, that’s the first thing to fix.
Vary and Cache Segmentation
The Vary header tells caches to store separate versions of a response based on request headers. The most common use case is Vary: Accept-Encoding — the compressed and uncompressed versions of a response are different, so caches need to store both.
# Server sends compressed response
HTTP/1.1 200 OK
Content-Encoding: gzip
Vary: Accept-Encoding
Cache-Control: public, max-age=300
A less common but important case: Vary: Accept-Language or Vary: Cookie. If your response changes based on the user’s language preference or a session cookie, you need Vary to prevent one user’s personalized response from being served to another.
Be careful with Vary. Every header you include fragments your cache. Vary: User-Agent would create a separate cache entry for every browser version — effectively disabling caching for shared caches.
Debugging Caching Problems
When caching misbehaves, the browser DevTools Network tab is your first stop. Look at the response headers for Cache-Control, ETag, and Age. The Age header tells you how long the response has been in the cache.
For CDN debugging, most CDNs add their own diagnostic headers:
- Cloudflare:
CF-Cache-Status(HIT,MISS,BYPASS,EXPIRED) - Fastly:
X-CacheandX-Cache-Hits - CloudFront:
X-Cache(Hit from cloudfrontorMiss from cloudfront)
A MISS on every request means your CDN is not caching the response. Common causes: Cache-Control: private, Set-Cookie in the response (many CDNs bypass cache if the response sets a cookie), or the Authorization request header being present.
A cache that’s theoretically configured correctly but never hitting can often be diagnosed by checking what the CDN is actually receiving. Some CDNs strip incoming headers; others add them. Run a curl -I https://your-cdn-url and compare the response headers to what you’d get from the origin directly.
A Practical Starting Point
If you’re setting up caching for a new project, this configuration handles most cases correctly:
# nginx: static assets (JS, CSS, images, fonts)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# nginx: HTML
location ~* \.html$ {
add_header Cache-Control "public, no-cache";
}
# nginx: API responses
location /api/ {
add_header Cache-Control "public, max-age=60, stale-while-revalidate=30";
}
// Next.js: per-route cache configuration
export async function generateMetadata() {}
export const revalidate = 60; // ISR: regenerate after 60s
// Or per-fetch:
const data = await fetch('/api/products', {
next: { revalidate: 300 } // cache for 5 minutes
});
HTTP caching is not complicated. The headers have specific meanings, and once you know them, you can reason about any caching behavior you encounter. The investment in understanding it properly is probably two hours — after which every performance conversation you have about web applications gets a lot cleaner.
Sponsored
More from this category
More from Web Development
CSS Anchor Positioning: Tooltips and Popovers Without JavaScript
gRPC in 2026: When to Use It Instead of REST or GraphQL
k6 Load Testing: Performance Testing Your APIs Before Users Find the Problems
Sponsored
The dispatch
Working notes from
the studio.
A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored