Skip to main content

HTTP Caching Explained: Cache-Control, ETags, and CDNs

performance caching http cdn web development
Illustration of HTTP caching layers between browser, CDN, and origin server

Caching is the highest-leverage performance work most sites never finish. A correct caching setup costs nothing per request, survives traffic spikes, and improves the exact metrics Google measures. And yet the two failure modes we see constantly are opposites: sites that cache nothing and re-download everything, and sites that cache too aggressively and serve stale pages for hours.

This is the mental model we use, the headers that actually matter, and a set of defaults you can copy.

Two caches, not one

When people say “the cache,” they usually mean one of two very different things:

  • The browser cache is private. It stores responses for one user, so it can hold personalized content safely.
  • Shared caches sit between the browser and your server: CDNs (Cloudflare, Fastly, CloudFront), reverse proxies, corporate proxies. They serve the same stored response to many users, so they must never hold someone’s logged-in account page.

Almost every caching bug is a confusion between these two. The header that controls them, Cache-Control, has directives aimed at each.

Cache-Control, the directive that runs the show

Cache-Control is the modern, authoritative caching header. Here are the directives worth knowing:

DirectiveWhat it does
max-age=SECONDSHow long any cache may reuse the response without checking back
s-maxage=SECONDSSame, but for shared caches only; overrides max-age for CDNs
publicShared caches may store it, even if it’d otherwise be private
privateOnly the browser may store it; CDNs must not. Use for per-user responses
no-cacheStore it, but revalidate with the origin before every reuse
no-storeNever store it anywhere. For genuinely sensitive responses only
must-revalidateOnce stale, don’t serve it without checking the origin first
immutableThis will never change during its lifetime; don’t even revalidate
stale-while-revalidate=SECONDSServe the stale copy instantly while refreshing in the background

The two that get misused most are no-cache and no-store. no-cache does not mean “don’t cache” — it means “cache, but always check freshness first.” no-store is the real “don’t keep this anywhere.” Slapping no-store on everything is a common over-correction that throws away free performance.

Validators: how a cache checks without re-downloading

When a cached response goes stale, the cache doesn’t have to re-download the whole thing. It can ask the origin “is my copy still good?” using a validator. If nothing changed, the server replies 304 Not Modified with no body, which is fast and cheap.

There are two validators:

  • ETag / If-None-Match. The server sends an ETag (a fingerprint of the content). On the next request the browser sends it back in If-None-Match; if it still matches, the server returns 304.
  • Last-Modified / If-Modified-Since. Same idea, keyed on a timestamp instead of a fingerprint. Less precise but cheap to generate.

You don’t usually configure these by hand — most servers and frameworks emit them automatically. The thing to verify is that they’re actually present on your responses, because a missing validator turns every revalidation into a full re-download.

A default strategy that works

Here’s the policy we apply to most sites. It splits the world into three buckets:

Static assets with hashed filenames (app.4f9a2c.js, logo.8b1e.css). The hash changes when the content changes, so the URL is effectively permanent. Cache them forever:

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

A year, immutable, no revalidation. When you deploy a change, the filename changes, so there’s no staleness risk. This is the single biggest win and the one most hand-built sites miss.

HTML documents. These change and shouldn’t be cached for long by shared caches, but they also shouldn’t be re-fetched needlessly. A short freshness window plus revalidation is the safe default:

Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=86400

Browsers revalidate immediately; the CDN serves a fresh-enough copy for 60 seconds and can keep serving a stale copy while it refreshes in the background. Tune s-maxage to how often your content really changes.

Personalized or sensitive responses (account pages, carts, anything behind auth):

Cache-Control: private, no-store

Never let a shared cache touch these. This is the one place no-store belongs.

CDNs add an edge, and a few footguns

A CDN is just a shared cache distributed around the world. It respects the same headers, with two practical wrinkles:

  • s-maxage is your CDN dial. It lets you cache HTML hard at the edge while keeping browsers honest with a low max-age. This combination — long at the CDN, short in the browser — is how high-traffic sites serve cached HTML without going stale.
  • Purging beats guessing. When something must change now (a price, a typo on the homepage), don’t wait for TTLs to expire. Purge the URL through your CDN’s API or dashboard. Build purging into your deploy.

The classic CDN bug is the Vary header. Vary: Cookie or an unbounded Vary fragments your cache into thousands of one-off entries and quietly destroys your hit rate. Vary only on things you genuinely serve differently, like Accept-Encoding.

The mistakes we fix most

  • no-store everywhere. Usually a panic reaction to one stale-page incident. It throws away every easy win. Fix the one endpoint that was wrong instead.
  • Cache-busting with query strings. style.css?v=3 works, but hashed filenames are cleaner and play nicer with some proxies. Prefer content hashes.
  • Caching HTML for hours. Then wondering why a published edit won’t show up. Keep HTML short or purge on publish.
  • No validators. Without an ETag or Last-Modified, a stale cache can’t do a cheap 304 and re-downloads the full response every time.
  • Caching API responses without thinking about auth. A public cache directive on a per-user JSON endpoint is how one user sees another’s data. This is a security bug, not just a performance one.

How to check what you’re actually sending

You don’t need a tool to start. From a terminal:

curl -I https://yoursite.com/

Read the Cache-Control, ETag, and Age headers. In Chrome DevTools, the Network panel shows (from disk cache) / (from memory cache) and the response headers per request. The Age header tells you how long a CDN has been holding a response.

For a faster read on the server-side factors that feed into this — compression, HTTP/2, TTFB, and caching together — run a URL through our Performance Checker. Caching is one of the biggest levers behind a good LCP, so it ties directly into Core Web Vitals.

The short version

Hash your static assets and cache them for a year as immutable. Keep HTML fresh with a short browser TTL and a sensible s-maxage at the CDN. Mark personalized responses private. Make sure validators are present so revalidation is cheap. Purge on publish instead of guessing at TTLs.

Get those five right and you’ve captured most of the value, usually without touching application code. If your cache headers are a mystery and your TTFB is higher than it should be, send us the URL and we’ll tell you what’s leaking.

Need help shipping?

We help teams build and ship software that works. Performance, SEO, features, weekly demos, full ownership.

Get a Free Audit