How HTTP Caching Works: Cache-Control, ETags, and Browser Storage

How HTTP Caching Works: Cache-Control, ETags, and Browser Storage

The Goal: Eliminate the Request Entirely

Most performance advice focuses on making requests faster — smaller payloads, CDN placement, HTTP/2 multiplexing. Caching is different. Its goal is to make the request not happen at all. A cache hit from the browser's local disk is effectively zero latency. That's one of the highest-value things you can optimize.

HTTP caching has two layers: the browser's local cache, and any intermediaries between the browser and your origin server (CDNs, reverse proxies). The same Cache-Control headers govern both, with some directives specific to one layer or the other. The full protocol is defined in RFC 9111 (HTTP Caching).

Cache-Control Directives

Cache-Control is a response header your server sends. It's a comma-separated list of directives:

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

max-age=N tells every cache (browser + CDN) to consider the response fresh for N seconds. After that, the cache must revalidate before serving it again. max-age=31536000 is one year — the standard value for versioned static assets. The complete directive list lives on MDN's `Cache-Control` reference.

no-cache is widely misunderstood. It does not mean "don't cache." It means "cache this, but always revalidate with the server before serving it." The browser stores the response, but checks in every time. If the server says nothing changed, the browser serves from cache without re-downloading the body. Useful for HTML.

no-store is the true "never cache" directive. The response is not stored anywhere, period. Use this for sensitive data — banking pages, auth tokens, personal dashboards.

private means only the browser can cache it; CDNs and shared proxies must not. For logged-in content — a page that shows a user's account — you want private so the CDN doesn't serve user A's data to user B.

public explicitly marks the response as cacheable by shared caches (CDNs). Responses to requests with Authorization headers default to private; you need public to override that.

immutable is a hint to the browser that the resource will never change for its max-age lifetime. This prevents conditional revalidation requests on page reload. It only makes sense paired with a long max-age and content-hashed filenames.

Cache Validation: ETags and Last-Modified

What happens when a cached resource expires? The browser doesn't throw it away and re-download — it validates with the server first, asking: "has this changed since I last fetched it?"

There are two mechanisms for this:

ETag + If-None-Match. The server sends an ETag header with the response — a unique identifier for this version of the content, typically a hash:

ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

When the cache expires, the browser sends the ETag back in an If-None-Match request header. The server compares it to the current resource version. If nothing has changed, it responds with 304 Not Modified — no body, just headers. The browser uses its cached copy. If the resource changed, the server sends the new version with a new ETag.

Last-Modified + If-Modified-Since. Older but still widely used. The server sends when the resource was last changed:

Last-Modified: Wed, 07 May 2026 12:00:00 GMT

On revalidation, the browser sends If-Modified-Since: Wed, 07 May 2026 12:00:00 GMT. Same logic: 304 if unchanged, full response if changed.

ETags are more reliable because timestamps have one-second granularity — fast-changing resources can be missed by Last-Modified. Most servers support both and prefer ETags when present.

Cache Busting with Content Hashes

The classic caching dilemma: you want your static assets (JS, CSS, fonts) cached forever for returning visitors, but you need changes to take effect immediately after a deploy.

The solution is content-addressed filenames. When your build tool generates a file, it includes a hash of the file's content in the filename:

main.a3f9b2c1.css
app.7e4d1892.js

Now you can set max-age=31536000, immutable on these files safely. The URL itself changes when the content changes — the old URL stays cached (correctly), and the new URL is fetched fresh. Your HTML references the new hashed URL after each build.

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

For the HTML file itself, you do the opposite: short max-age or no-cache, so browsers always get the latest version that points to the new hashed assets:

Cache-Control: no-cache

This two-tier strategy — long cache on assets, short or no cache on HTML — is how fast, reliably-updating sites work.

Service Workers and the Cache API

Service workers add a programmable cache layer that runs in a background thread. Unlike HTTP caching (controlled by server response headers), the Cache API lets your JavaScript decide what to store and when to serve it:

// In your service worker
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(cached => {
      return cached || fetch(event.request).then(response => {
        caches.open('v1').then(cache => cache.put(event.request, response.clone()));
        return response;
      });
    })
  );
});

This is the foundation of offline-capable apps. The service worker intercepts all requests and can serve from its own cache even without a network connection. It's also useful for precise cache invalidation — you can version your cache stores and delete old ones programmatically. MDN's Service Worker API documentation covers lifecycle, scope, and the Cache API in detail.

Service worker caching layers on top of HTTP caching, not instead of it. A request that hits the service worker cache never reaches the network. One that misses may still hit the HTTP cache before reaching your origin.

Common Mistakes

Caching HTML with a long max-age is the most damaging mistake. If your HTML is cached for a week and you push a fix, users see the broken version for up to a week. Keep HTML on no-cache or max-age=0.

Setting a short max-age on hashed asset bundles throws away the benefit. If main.a3f9b2c1.js has max-age=3600, every user re-downloads it hourly. Since the hash guarantees freshness, there's no reason not to set a year-long cache.

CDN caching vs browser caching: A CDN in front of your origin caches responses at the edge. This is separate from what's in the user's browser. You can serve Cache-Control: public, max-age=3600, s-maxage=86400max-age controls browser TTL, s-maxage controls shared caches like CDNs. This lets you keep CDN copies fresh for a day while still allowing browsers to revalidate hourly.

For understanding what's actually in a URL before caching decisions, the URL Encoder helps decode and inspect query strings. The Hash Generator is useful for manually verifying content hashes if you're debugging a cache busting setup. For a broader look at the headers that go back and forth with every cached or revalidated request, Understanding HTTP Headers covers the full header vocabulary. And How Browsers Render a Page explains how the cache fits into the overall resource loading sequence during page load.

FAQ

Does `Cache-Control: no-cache` mean "don't cache"?

No, and the name is genuinely misleading. no-cache means "cache this, but always revalidate with the server before serving from cache." The browser stores the response and sends a conditional request (with ETag or Last-Modified) on each use. Use no-store for the actual "don't cache anything" behavior.

What's the difference between max-age and s-maxage?

max-age applies to all caches (browser + CDN). s-maxage overrides max-age for shared caches only (CDN, reverse proxy). This lets you do things like "cache 1 hour in browsers, 24 hours in the CDN" with max-age=3600, s-maxage=86400. Useful when CDN cost is the bottleneck but you want users to see updates quickly.

Should HTML have a long cache lifetime?

No — HTML should be Cache-Control: no-cache or short max-age (5-60 seconds). HTML is what links to your hashed JS/CSS bundles, so it must update quickly when you deploy. A week-long HTML cache means users see the old broken version for up to a week. Hashed assets get the long cache; HTML gets the short one.

When should I use ETag vs Last-Modified?

ETag is more reliable — Last-Modified has 1-second granularity, so fast-changing resources can miss updates. Use ETag for anything that might change multiple times per second (logs, real-time data feeds). Use Last-Modified for naturally slow-changing resources where the timestamp is meaningful. Servers can send both; browsers prefer ETag when present.

Why does my CDN keep serving the old version after I deploy?

CDN cache TTL likely hasn't expired. Either wait it out (matches your s-maxage or default), call your CDN's purge API to invalidate the URL, or use cache-busting URLs (hashed filenames) so new content has new URLs. Cloudflare, Fastly, and AWS CloudFront all have purge endpoints; for content-hashed assets, purging is unnecessary because the URL itself changes.

What does `immutable` actually do?

immutable tells the browser the resource will never change for its max-age lifetime — so don't even bother sending a conditional revalidation request on page reload. Without immutable, browsers send If-None-Match requests on F5 even for cached responses. With it, the request is skipped entirely. Only safe for content-hashed URLs.

How do I cache responses that depend on user authentication?

Use Cache-Control: private so CDNs don't store them, only the user's browser. The Vary: Cookie or Vary: Authorization header also tells caches to key responses by those headers, but in practice private is simpler and more reliable for per-user content. For sensitive data, prefer no-store to avoid any caching at all.

What's the difference between `Pragma: no-cache` and `Cache-Control: no-cache`?

Pragma is the HTTP/1.0 ancestor. Modern caches (anything since 2000) honor Cache-Control and ignore Pragma when both are present. Pragma is only included for ancient compatibility — you don't need to send it. Some old enterprise proxies still respect it, but they're rare enough to ignore in 2026.