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.
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:
- Is this cached response still fresh? Answered by
Cache-Controldirectives and expiry times. No network request needed if the answer is yes. - If it’s stale, has the content actually changed? Answered by conditional requests using
ETagorLast-Modified. This still hits the network, but the server can reply with a tiny304 Not Modifiedinstead 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.publicvsprivate— 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 beprivate.immutable— tells the browser the response will never change for the duration ofmax-age, so don’t even revalidate on a hard refresh. Ideal for hashed static assets likeapp.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:
| Layer | What it caches | Typical control |
|---|---|---|
| Browser cache | Full responses, per user | Cache-Control, ETag |
| CDN edge cache | Full responses, shared across users | Cache-Control (s-maxage, public) |
| Reverse proxy | Application responses | Cache-Control, custom rules |
| Service worker | Anything the app chooses | JavaScript 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.
Keep reading
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.
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.
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.