Skip to content

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.

4 min read
Diagram showing HTTP cache layers from browser through CDN to origin server

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:

DirectiveControlsWho respects it
max-age=NFreshness lifetime in secondsBrowsers, CDNs, proxies
s-maxage=NFreshness lifetime for CDNs onlyCDNs, shared caches
no-cacheAlways revalidate before servingEveryone
no-storeNever cacheEveryone
privateBrowser-only, not CDNCDNs (they obey and skip)
publicCDN and browser both may cacheCDNs
must-revalidateDon't serve stale if expiredEveryone
stale-while-revalidateServe stale, refresh asyncModern 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.

tstypescript
// ❌ 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.

tstypescript
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.

tstypescript
// ❌ 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.

tstypescript
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.

tstypescript
// ❌ 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

  1. no-cache is not "don't cache" — it means revalidate every time. Use no-store when you genuinely want no caching.
  2. Separate CDN and browser TTLs with s-maxage and max-age — they're independent knobs for independent layers.
  3. ETags pay off on large, unpredictably-changing responses — a 304 with no body is always faster than a 200 with a full payload.
  4. stale-while-revalidate eliminates the tradeoff between freshness and latency for most non-sensitive content.
  5. Design for invalidation, not against it — content-addressed URLs, surrogate keys, and short TTLs beat manual purges every time.
  6. 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.
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX