Articles

HTTP Caching Headers: Cache-Control and ETag Explained

Cache-Control and ETag are the two headers that control HTTP caching — how long a response stays fresh and how to revalidate it cheaply, explained.

Takina Takina · · 5 min read
Network switch with cables plugged into ports

Cache-Control and ETag are the two HTTP headers that decide whether a browser or CDN can reuse a previous response instead of fetching it again. Cache-Control sets the rules for how long a response is considered fresh; ETag (and its cousin Last-Modified) let a cache check whether a stale response is still actually valid without re-downloading the whole thing. Together they’re the backbone of nearly every performance win that doesn’t involve touching your application code.

Two separate problems: freshness and validation

HTTP caching splits into two questions, and it helps to keep them apart:

  1. Is this cached response still fresh? Answered by Cache-Control directives and expiry times. No network request needed if the answer is yes.
  2. If it’s stale, has the content actually changed? Answered by conditional requests using ETag or Last-Modified. This still hits the network, but the server can reply with a tiny 304 Not Modified instead of resending the full body.

A well-tuned cache maximizes freshness windows (avoiding requests entirely) and uses cheap revalidation when a request is unavoidable. This is the same layered thinking behind resource hints like preload and prefetch — reduce round trips first, make the ones you can’t avoid faster. HTTP is also just one of the layers a request crosses; what caching is maps the full stack from browser to database.

Cache-Control directives that matter

Cache-Control is a comma-separated list of directives set on the response:

  • max-age=<seconds> — how long the response is fresh, from the time it was fetched. The single most important directive.
  • no-cache — confusingly, this does not mean “don’t cache.” It means “cache it, but revalidate with the server before using it every time.” Use it for content that changes but is worth conditional-requesting.
  • no-store — the real “don’t cache anything, anywhere” directive. Use for responses containing sensitive, per-request data.
  • public vs private — whether intermediate caches (CDNs, corporate proxies) may store the response, or only the end user’s browser. Anything with per-user data — auth state, account details — should be private.
  • immutable — tells the browser the response will never change for the duration of max-age, so don’t even revalidate on a hard refresh. Ideal for hashed static assets like app.a3f9c1.js.
  • stale-while-revalidate=<seconds> — serve the stale response immediately while fetching a fresh one in the background. Great for content where a few extra seconds of staleness is harmless but a slow response isn’t.

A typical split: hashed JS/CSS bundles get Cache-Control: public, max-age=31536000, immutable (cache for a year, never revalidate — a new deploy just changes the filename). An HTML document gets something much shorter, often no-cache, since it’s the entry point that needs to reflect the latest deploy.

ETag and conditional requests

An ETag is an opaque identifier — usually a hash — of a specific version of a resource. The server sends it on the first response:

ETag: "33a64df551425fcc55e4d42a148795d9f25f89d"

On the next request, the browser sends it back in an If-None-Match header. If the server’s current ETag matches, it replies with a bare 304 Not Modified and no body — the browser reuses its cached copy. If the content changed, the server returns a normal 200 with the new body and a new ETag.

Last-Modified / If-Modified-Since work the same way but compare timestamps instead of hashes. ETags are more precise (a hash catches changes a timestamp might miss, like a file rewritten with identical mtime) but cost slightly more to compute server-side.

Where caching happens

A response can be cached at several layers, each checking the same headers:

LayerWhat it cachesTypical control
Browser cacheFull responses, per userCache-Control, ETag
CDN edge cacheFull responses, shared across usersCache-Control (s-maxage, public)
Reverse proxyApplication responsesCache-Control, custom rules
Service workerAnything the app choosesJavaScript logic, not just headers

The s-maxage directive overrides max-age specifically for shared caches like a CDN, letting you set a longer edge-cache lifetime than the browser-side freshness window — useful when you want a CDN to hold content longer than an individual browser tab should trust it. If your CDN is Cloudflare or similar, edge caching rules typically layer on top of these standard headers rather than replacing them.

A service worker sits at a different layer entirely — it’s JavaScript you write, so it can implement caching strategies (cache-first, network-first, stale-while-revalidate as actual logic) that go beyond what headers alone can express. That’s the mechanism behind most offline-capable PWAs.

Cache busting with hashed filenames

Because immutable and long max-age values tell caches to stop asking, you need a way to force a fresh fetch when content actually changes. The standard technique is cache busting: include a content hash in the filename (app.a3f9c1.js), so a new deploy produces a new URL entirely. The old cached file just becomes unreferenced — no invalidation needed, no stale-content risk. This is what most modern bundlers do automatically, and it’s why static assets can safely use the most aggressive caching directives available while the HTML that references them stays short-lived.

Debugging cache behavior

Browser devtools’ Network tab shows the Cache-Control and ETag response headers directly, along with whether a given request was served from disk cache, from memory cache, or hit the network. A 304 status with no response body confirms revalidation is working. If you’re seeing stale content that should have updated, check for an overly long max-age with no immutable-driven filename change, or a CDN layer caching more aggressively than the origin intends via s-maxage.

The takeaway

Cache-Control sets how long a response can be reused without asking; ETag and conditional requests make the inevitable re-ask cheap when it happens. Use long max-age with immutable and hashed filenames for static assets, short or no-cache lifetimes for documents that need to reflect the latest deploy, and private versus public to keep per-user data out of shared caches. Getting this right eliminates a large share of unnecessary network requests before any other performance work even starts.

Takina Takina · · 4 min read

HTTP Range Requests and Partial Content, Explained

HTTP range requests let a client ask for just part of a resource, enabling video seeking, resumable downloads, and partial file fetches over HTTP.

#Networking #Web Development #Performance
Takina Takina · · 5 min read

Gzip vs Brotli: HTTP Compression Compared

Gzip and Brotli both shrink HTTP responses before they hit the wire. How each algorithm works, and why Brotli usually compresses text tighter.

#Performance #Web Development #Networking
Takina Takina · · 4 min read

HTTP/2 vs HTTP/3: What Actually Changed

HTTP/2 fixed request multiplexing but stayed on TCP; HTTP/3 moves to QUIC over UDP to kill head-of-line blocking at the transport layer. The real differences.

#Networking #Performance #Web Development