HTTP Caching: Cache-Control, ETags and CDN Strategies
A practical guide to HTTP caching headers — what each directive actually does, when to use ETags, and how to stop fighting your CDN.

Most applications treat HTTP caching as an afterthought — sprinkle a Cache-Control: max-age=3600 header on static assets and call it done. Then a bug ships, users see stale data for an hour, and suddenly caching is the enemy. The problem isn't caching. It's that most engineers never fully understand what the headers actually control, which layers they affect, and how they compose.
Getting caching right doesn't require a CDN wizard. It requires understanding four concepts well: directives, validators, freshness, and scope. Once those click, you stop fighting the cache and start using it.
Cache-Control Directives That Actually Matter
Cache-Control is a comma-separated list of directives, and most tutorials only cover two or three of the eight that matter in production. Here's the realistic decision table:
| Directive | Controls | Who respects it |
|---|---|---|
max-age=N | Freshness lifetime in seconds | Browsers, CDNs, proxies |
s-maxage=N | Freshness lifetime for CDNs only | CDNs, shared caches |
no-cache | Always revalidate before serving | Everyone |
no-store | Never cache | Everyone |
private | Browser-only, not CDN | CDNs (they obey and skip) |
public | CDN and browser both may cache | CDNs |
must-revalidate | Don't serve stale if expired | Everyone |
stale-while-revalidate | Serve stale, refresh async | Modern browsers, CDNs |
The most commonly confused pair is no-cache vs no-store. no-cache does not mean "don't cache" — it means "cache it but validate before serving." no-store is the real "don't cache" directive.
// ❌ Misused — this still caches the response
res.setHeader("Cache-Control", "no-cache");
// ✅ Correct usage — revalidate on every request
res.setHeader("Cache-Control", "no-cache, must-revalidate");
// ✅ Correct usage — truly no caching for sensitive data
res.setHeader("Cache-Control", "no-store, private");For API responses that are user-specific, private, no-cache is usually the right choice. For public, rarely-changing data like product catalogs, public, s-maxage=3600, stale-while-revalidate=86400 is far more useful.
ETags and Conditional Requests
max-age solves freshness for known TTLs. But what about content that changes unpredictably? ETags give clients a fingerprint to validate against the origin without re-downloading unchanged content.
The flow works in two round trips. On the first request, the server sends an ETag header. On subsequent requests, the client sends If-None-Match with that value. The server replies with either 200 OK and a new body, or 304 Not Modified with an empty body.
import { createHash } from "crypto";
function generateETag(data: string): string {
return `"${createHash("sha256").update(data).digest("hex").slice(0, 16)}"`;
}
export async function GET(req: Request) {
const data = await fetchProductCatalog();
const body = JSON.stringify(data);
const etag = generateETag(body);
// Check conditional request header
const ifNoneMatch = req.headers.get("if-none-match");
if (ifNoneMatch === etag) {
return new Response(null, {
status: 304,
headers: { ETag: etag },
});
}
return new Response(body, {
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=0, must-revalidate",
ETag: etag,
},
});
}A 304 response carries no body — on a 100 KB JSON payload over a slow connection, that's significant. ETags are especially valuable for endpoints where you can't predict how often data changes but want to avoid unnecessary data transfer when it hasn't.
Use weak ETags (W/"hash") when byte-for-byte identity doesn't matter — for example, when gzip encoding may differ between requests. Strong ETags are required for range requests.
Stale-While-Revalidate: The Most Useful Directive Nobody Uses
stale-while-revalidate is the closest thing to a free lunch in HTTP caching. It tells the cache: serve the stale response immediately (zero latency), then refresh in the background.
// ❌ Binary choice — fresh or wait for origin
res.setHeader("Cache-Control", "public, max-age=60");
// ✅ Serve immediately, refresh in the background
res.setHeader(
"Cache-Control",
"public, max-age=60, stale-while-revalidate=600"
);With the second header, after 60 seconds the response is "stale" but still served instantly. The cache revalidates in the background. Only after 660 seconds (60 + 600) will it actually block a request to refresh. For most content — dashboards, blog feeds, search results — users never notice the brief staleness window.
CDNs like Cloudflare and Vercel's Edge Network respect this directive. Browsers are catching up — Chrome and Firefox both support it. Always pair it with must-revalidate if you need hard expiry guarantees.
Separating CDN Cache from Browser Cache
One of the most powerful and underused features of Cache-Control is the distinction between max-age and s-maxage. They look similar but control different layers.
function getHeadersForResource(type: "user-data" | "public-api" | "asset") {
switch (type) {
case "user-data":
// Browser caches, CDN skips
return "private, max-age=300";
case "public-api":
// CDN holds for 5 minutes, browser revalidates every 60 seconds
return "public, s-maxage=300, max-age=60, stale-while-revalidate=600";
case "asset":
// Both cache forever — content-addressed URLs handle invalidation
return "public, max-age=31536000, immutable";
}
}The immutable directive is worth calling out separately. It tells caches the response will never change — no conditional requests needed, ever. Only use it on content-addressed resources (files with a hash in the URL). When you rebuild your assets, the URL changes, so old cached responses are never a problem.
Invalidation and the Vary Header
Cache invalidation is famously hard. The practical answer in most architectures is to design around it rather than fight it: content-addressed URLs for assets (handled by your bundler), short TTLs or no-cache with ETags for mutable data, and surrogate-key tagging for CDN purges.
The Vary header tells caches to store separate responses based on request headers. This is critical when you serve different content based on Accept-Encoding, Accept-Language, or Authorization.
// ❌ CDN may serve gzipped content to a client that sent no Accept-Encoding
res.setHeader("Cache-Control", "public, max-age=3600");
// ✅ Vary on encoding so CDN stores separate versions
res.setHeader("Cache-Control", "public, max-age=3600");
res.setHeader("Vary", "Accept-Encoding");Be careful with Vary: * — it effectively disables shared caching because no two requests are considered equivalent. Some CDNs ignore the Vary header entirely and handle encoding normalization themselves. Check your CDN's documentation before relying on Vary for anything beyond encoding.
Never use Vary: Authorization with a public CDN. If the CDN respects it, every unique token creates a separate cache entry, exploding your cache size. If it doesn't, you risk serving one user's data to another.
Key Takeaways
no-cacheis not "don't cache" — it means revalidate every time. Useno-storewhen you genuinely want no caching.- Separate CDN and browser TTLs with
s-maxageandmax-age— they're independent knobs for independent layers. - ETags pay off on large, unpredictably-changing responses — a 304 with no body is always faster than a 200 with a full payload.
stale-while-revalidateeliminates the tradeoff between freshness and latency for most non-sensitive content.- Design for invalidation, not against it — content-addressed URLs, surrogate keys, and short TTLs beat manual purges every time.
- Test your actual CDN behavior — directives are interpreted differently across Cloudflare, Fastly, AWS CloudFront, and Vercel. What the spec says and what your CDN does can diverge.


