Skip to content
yutils

HTTP Caching — Cache-Control, ETag, and the Right Headers for Every Asset

max-age vs s-maxage, immutable, stale-while-revalidate, ETag vs Last-Modified, CDN vs browser cache, and the headers your static assets, HTML, and API responses each need.

~9 min read

A well-cached site loads the second visit almost instantly, with a tenth of the CDN cost and origin load. A poorly cached site shows users stale content for hours — or never caches anything and hits the origin on every request. This guide covers what each Cache-Control directive actually means, how ETag and Last-Modified work, the split between CDN and browser caches, and the right header combo for each asset type.

Two cache layers — browser vs shared (CDN)

The same response can be cached in two places.

  • Private (browser) — one user. Responses with Authorization default to private.
  • Shared (CDN, proxy) — multiple users. Use public to allow.

Some Cache-Control directives apply to both (max-age), some only to shared caches (s-maxage).

Cache-Control directives at a glance

DirectiveMeaningExample
max-age=NFresh for N seconds in all cachesmax-age=3600 (1 hour)
s-maxage=Nmax-age override for shared caches onlys-maxage=86400
publicStorable in shared cachespublic, max-age=300
privateBrowsers only — never sharedprivate, max-age=0
no-cacheStore, but validate with origin on every request (may return 304)no-cache
no-storeDon't store at allno-store (sensitive data)
immutableWhile fresh, never try to revalidate. Content never changes.max-age=31536000, immutable
stale-while-revalidate=NAfter expiry, serve stale for N seconds while revalidating in backgroundmax-age=60, stale-while-revalidate=600
must-revalidateForbid stale after expirymax-age=0, must-revalidate

Recipes per asset

Hashed static assets (JS/CSS/images)

Cache-Control: public, max-age=31536000, immutable

File names include a content hash (app.a3f9.js), so a content change forces a new file name. One year + immutable. Next.js, Vite, and webpack apply this automatically to hashed assets.

HTML (SPA index, SSR pages)

Cache-Control: no-cache

One URL must always point to the latest content. With no-cache, every request revalidates — a matching ETag returns 304 with no body. Don't confuse it with no-store, which forbids storage entirely.

API responses (per-user data)

Cache-Control: private, no-cache

CDN must not cache (risk of cross-user leakage). The browser may cache + revalidate.

API responses (public, slow-changing)

Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600

Browser one minute, CDN five minutes, ten minutes of stale allowed after expiry. Origin load drops sharply with traffic.

Sensitive responses (accounts, payments, auth)

Cache-Control: no-store

Never store. Even the browser's back navigation should refetch.

ETag — conditional GET

ETag is a hash (or version id) of the response body. The server attaches it; the browser sends it back in If-None-Match. If unchanged, the server returns 304 with no body.

# First request
HTTP/1.1 200 OK
ETag: "33a64df5"
Cache-Control: no-cache
Content-Type: text/html

<html>...</html>

# Second request
GET /index.html
If-None-Match: "33a64df5"

# Unchanged
HTTP/1.1 304 Not Modified
ETag: "33a64df5"

304 has no body — network savings. no-cache + ETag is the canonical combo for SPA index.html. See what 304 means in HTTP Status Codes.

Strong vs weak ETag

  • Strong: "abc123" — byte-for-byte identical.
  • Weak: W/"abc123" — semantically identical (whitespace differences allowed).

Strong is the default. Weak is used by transforms like gzip/brotli.

Last-Modified — time-based validation

The alternative to ETag. The body's last change time (HTTP date).

HTTP/1.1 200 OK
Last-Modified: Wed, 15 May 2026 14:30:00 GMT
Cache-Control: no-cache

# Next request
GET /image.jpg
If-Modified-Since: Wed, 15 May 2026 14:30:00 GMT

# Unchanged
HTTP/1.1 304 Not Modified

Compared to ETag:

  • ETag — sub-second precision and exact equality. Preferred.
  • Last-Modified — second granularity. Two changes in the same second collide.
  • Both — common; clients can use either.

CDN specifics — Vary, s-maxage

Vary header

Same URL returns different responses based on request headers → the CDN splits the cache per header value.

Vary: Accept-Encoding
# split per gzip / brotli

Vary: Accept-Language
# split per language

Vary: Cookie
# split per cookie — almost always bad (one cache key per user = no CDN benefit)

Vary: Cookie shreds the cache per user, defeating the CDN. Use private for authenticated data instead.

Cloudflare Cache Tag

The Cache-Tag header on Cloudflare Workers / Pages enables tag-based purging. Cache-Tag: product-42, category-shoes — when the API changes, purge by tag.

stale-while-revalidate — zero-wait refresh

Right after expiry, serve the stale response immediately and revalidate in the background. Users never wait.

Cache-Control: max-age=60, stale-while-revalidate=600

# T=0   response cached
# T=30  fresh, served from cache
# T=60  expiry begins
# T=90  stale served + background revalidate → new response cached
# T=120 fresh again

Supported by Vercel and Cloudflare. The standard pattern in Next.js Route Handlers and Server Components. Great fit for search results, dashboards, and most SSR responses.

Common pitfalls

1. Confusing no-cache with no-store

no-cache = store + revalidate every time. no-store = don't store. Sensitive data uses no-store; "always fresh" content uses no-cache.

2. no-store on every response

Over-defensive. Static images and CSS with no-store make the whole site slow. Different headers per asset.

3. max-age=0, must-revalidate = no-cache?

Practically yes. Both forms are common.

4. Unbounded CDN cache

Without s-maxage, the CDN uses max-age. Often you want the CDN to cache longer than the browser — split the values.

5. Caching a response with Set-Cookie

Responses with Set-Cookie default to private. Explicitly marking them public can leak one user's cookie to another — a serious incident. Never make auth responses public.

6. Forgetting to purge the CDN

Origin updates with the CDN still serving the old version. Wire CDN purges into your deploy pipeline, or use cache-busting URLs (?v=...).

7. Using Pragma: no-cache

HTTP/1.0 legacy. Modern clients ignore it. Don't bother.

Debugging — see the headers

curl -I https://example.com/api/users
# Cache-Control: public, max-age=60
# ETag: "abc123"
# Vary: Accept-Encoding

# CDN hit/miss (Cloudflare)
curl -I https://example.com/asset.js
# CF-Cache-Status: HIT  (or MISS / EXPIRED / DYNAMIC)

Build curl commands with method/header combinations in cURL Builder and add -I to see headers only. Use URL Parser to split URLs and compare behavior across paths.

Summary

  • Hashed static assets = max-age=31536000, immutable. One year.
  • HTML = no-cache + ETag. Revalidate every time, skip the body.
  • Public APIs = public, max-age + s-maxage + stale-while-revalidate. Lean on the CDN.
  • Sensitive APIs = private, no-cache or no-store.
  • ETag is the standard. Last-Modified is the fallback.
  • Avoid Vary: Cookie — it defeats caching.
Back to guides