All posts
·8 min read·performance · caching · cdn

Cache-Control vs ETag: which one is breaking your CDN

Cache-Control vs ETag confusion quietly wrecks CDN hit rates and serves stale pages. Here is how each header works, where they collide, and how to fix it.

Your CDN is supposed to make your site fast by serving copies of your files from a server near each visitor. When it works, most requests never touch your origin at all. When it does not, you get the worst of both worlds: an extra network hop and a trip back to your server anyway. Two response headers decide which outcome you get, and they are constantly confused for each other. Cache-Control sets the rules for how long a copy stays fresh. ETag decides what happens after a copy goes stale. Get the relationship wrong and your CDN either serves outdated pages or revalidates everything on every request, and both look like the cache is "not working."

This post walks through what each header actually does, the exact ways they collide on a CDN, and how to tell which one is the problem on your site.

What Cache-Control actually controls

Cache-Control is the primary instruction for any cache that sits between your origin and your visitor, including the browser, your CDN, and any proxy in between. It answers one question: how long can this response be reused without checking back with the origin?

The directives you will use most:

  • max-age=N sets how many seconds a copy is considered fresh. During that window, a cache can serve the stored copy with no revalidation at all. This is the fast path.
  • s-maxage=N does the same thing but only for shared caches like your CDN, overriding max-age for them while leaving the browser on max-age.
  • no-cache is the most misread directive on the web. It does not mean "do not cache." It means "you may store this, but you must revalidate with the origin before every reuse." That revalidation step is where ETag enters the picture.
  • no-store is the real "do not cache anything" instruction. Use it for genuinely private or sensitive responses.
  • public and private decide whether a shared cache like a CDN is allowed to store the response at all. A private response can sit in the browser but not on the CDN.

The MDN reference for Cache-Control covers every directive in detail. The short version: max-age and s-maxage are what give you cache hits. If those are missing or set to zero, your CDN has nothing to work with, no matter what your ETag says.

What an ETag actually does

An ETag is a fingerprint for a specific version of a file. The server generates it, often from a hash of the file contents or a combination of size and modification time, and sends it on the response. The cache stores that fingerprint alongside the file.

When the stored copy goes stale, the cache does not blindly download the whole thing again. It sends a conditional request back to the origin with an If-None-Match header carrying the saved ETag. The origin compares it to the current version. If they match, the origin replies 304 Not Modified with an empty body, and the cache reuses what it already has. If they differ, the origin sends the new file with a new ETag.

The payoff is real: a 304 saves bandwidth because the body is empty. The catch is just as real: a 304 is still a full round trip to your origin. The connection still has to be opened, the request sent, and the response received. ETag saves you bytes, not latency. That distinction is the root of most CDN caching disappointment, and it is exactly where the two headers start working against each other.

Where the two collide on a CDN

Here are the failure patterns that show up over and over, each one a mismatch between what Cache-Control permits and what ETag is doing.

Pattern one: no-cache plus ETag, so nothing is ever a true hit

This is the most common one. A framework or server default sends Cache-Control: no-cache along with an ETag. People see the ETag and assume caching is on. It is, technically, but no-cache forces a revalidation on every single request. Your CDN dutifully sends an If-None-Match to your origin every time, gets a 304, and serves the file. You save bandwidth and gain nothing on speed, because every visitor still waits for a round trip to your origin. The cache hit ratio in your CDN dashboard looks low, and it should, because these are revalidations, not hits.

The fix is to give cacheable assets a real freshness window. Static files with hashed names (think app.4f3a1b.js) can safely take Cache-Control: public, max-age=31536000, immutable, which tells caches to serve them for a year without ever revalidating. The ETag becomes a backstop for the rare case the cache evicts the file early, not a per-request tax.

Pattern two: mismatched ETags across CDN nodes

CDNs are distributed. A large origin may sit behind multiple servers, or the same file may be deployed to several machines. If your ETag is generated from inode numbers or modification timestamps rather than content, two servers holding the identical file can produce two different ETags. Now every CDN edge node that fetched from a different origin server has a different fingerprint, conditional requests stop matching, and you get full downloads where you expected 304s. Apache's default ETag historically included the inode, which is exactly this trap. The fix is to base ETags on content only, or disable them and lean on Cache-Control plus Last-Modified.

Pattern three: Cache-Control says cache, ETag never changes

The mirror image of pattern one. A long max-age is set, but the ETag is static or the origin always answers 304 even after the file changed. Visitors hold a stale copy for the full freshness window and the revalidation that should catch the update never reports a change. This is how a deploy goes out and some users keep seeing yesterday's site for hours. Content-based ETags avoid this, because changing the file changes the fingerprint automatically.

For the precise rules behind conditional requests and how 304 responses are meant to behave, RFC 9111 on HTTP caching is the authoritative source.

So which one is breaking your CDN?

Reach for the simplest test first. Request one of your static assets twice and look at the headers on each response.

  • If you see Cache-Control: no-cache or max-age=0 next to an ETag, your problem is the Cache-Control directive, not the ETag. You are revalidating on every request by design. Give cacheable assets a real max-age.
  • If you see a healthy max-age but every repeat request still returns 200 with a full body instead of a 304 or a cache hit, your ETags are probably unstable across nodes or deploys. Switch to content-based ETags or turn them off and rely on Last-Modified.
  • If you see the right headers on your origin but the wrong ones at the edge, your CDN is rewriting them. Many CDNs strip or override caching headers unless you configure them to honor the origin. Cloudflare's caching documentation explains how its rules interact with origin headers, and most major CDNs document the same behavior.

The principle to carry away: Cache-Control decides whether you get a fast cache hit, and ETag only decides how cheap the slow path is when freshness runs out. If your cache hit ratio is low, the answer is almost always in Cache-Control. If your bandwidth is high despite good hit ratios, look at your ETags.

Check your headers without guessing

You can inspect all of this by hand with browser developer tools or curl -I, one URL at a time, reading the headers and reasoning through which directive wins. It works, but it is slow and easy to misread when a CDN is rewriting headers between your origin and the edge.

A faster path is to let a scan read the headers for you and flag the mismatches. The AcuityScan HTTP Headers tool reports the exact Cache-Control and ETag values your server is sending and points out caching directives that work against each other. Because misrouted caching often travels with redirect chains that change which host answers, it pairs naturally with the redirect checker, and with the performance tool when you want to see how those caching choices land in real load timing.

For the full picture across performance, caching, security, accessibility, and email authentication, run a complete scan at acuityscan.com. It runs 350+ checks across 8 modules and returns specific findings with plain-English fixes, so you see not just that caching is misconfigured but which header is causing it and what to change.

TL;DR

  • Cache-Control decides how long a copy stays fresh and whether you get a true cache hit. ETag decides only how cheap the revalidation is once a copy goes stale.
  • no-cache does not mean "do not cache." It means "revalidate every time," which kills your CDN hit ratio while still serving from cache.
  • The most common break is no-cache plus an ETag: every request still hits your origin for a 304. Give cacheable assets a real max-age.
  • Unstable ETags based on inode or timestamp instead of content cause full downloads across CDN nodes and after deploys. Use content-based ETags or disable them and rely on Last-Modified.
  • Check both headers on a repeat request: low hit ratio points to Cache-Control, high bandwidth despite good hits points to ETag.

Scan your own site

See what 350+ checks find on your domain.

Free, no signup, 60 seconds. Email auth · DNS · SSL · Performance · SEO · Accessibility · Privacy · Mobile.