Content Delivery Network Blog

ETag Headers Explained: Cache Validation That Actually Works

Written by BlazingCDN | Aug 10, 2026, 10:00:36 AM

You can have correct ETag headers and working 304 revalidation across origin and CDN edge in about 30 minutes on an existing nginx origin. The payoff is measurable: a 304 Not Modified response is roughly 150–400 bytes of headers against a full response body, so revalidating a 250 KB bundle instead of re-downloading it removes about 99.8% of the bytes. This playbook covers emitting deterministic ETag headers, choosing strong versus weak validators, wiring conditional requests through the edge, and verifying that an If-None-Match request actually returns 304 rather than a silent 200.

How ETag headers make cache validation work

An ETag is an opaque validator the origin attaches to a representation. The cache stores it, and when the entry goes stale it re-asks the origin with If-None-Match carrying that value. If the current representation still matches, the origin answers 304 with no body and the cache resets its freshness timer.

An ETag validation exchange, standardized in RFC 9110, replaces a full response body with a 304 Not Modified that carries only headers, typically 150 to 400 bytes on the wire. For a 250 KB JavaScript bundle revalidated hourly, that removes roughly 99.8% of the bytes per check, which is why cache validation, not cache lifetime, is what keeps revalidation cheap in 2026.

Strong validators promise a byte-for-byte identical representation. Weak validators, prefixed with W slash, promise only semantic equivalence. If-None-Match uses weak comparison, so both kinds revalidate fine. If-Range and If-Match use strong comparison, so a weak ETag silently disables byte-range resumption and optimistic-concurrency writes that rely on 412 Precondition Failed. Media and large downloads need strong validators; HTML and JSON usually do not care.

Prerequisites

  • An origin you can restart: nginx 1.7.3 or newer, Apache 2.4, or an application server whose response headers you control.
  • A CDN or reverse proxy in front of it with per-path cache rules.
  • An HTTP client that lets you set arbitrary request headers and read the raw status line.
  • Origin access logs with status code and bytes-sent, plus edge logs with cache status.

Configure ETag headers step by step

Step 1 — Audit the ETag headers your origin sends today

Request the same three URLs from every origin node: one HTML document, one API JSON response, one static asset. Record ETag, Last-Modified, Cache-Control, and Vary for each. If the ETag differs between nodes for identical bytes, cache validation is already broken and every revalidation will cost you a full body.

Step 2 — Make ETags identical across every origin node

nginx derives its ETag from file modification time and content length, so a deploy that rewrites mtimes changes every validator even when the bytes are unchanged. Apache 2.2 defaulted to including the inode, which guarantees divergence across nodes. Pin Apache explicitly:

FileETag MTime Size

The durable fix is a content hash computed at build time and served as the ETag by your application layer. Cost of that fix: one build step and a hash lookup per request. Benefit: validators survive redeploys, rsyncs, and node replacement.

Step 3 — Split routes into immutable and revalidated

Fingerprinted build output does not need validation at all; a one-year immutable TTL is strictly cheaper than any conditional request. Everything without a content hash in its URL gets a short shared TTL and leans on the ETag header.

server {
    listen 443 ssl;
    http2 on;
    server_name YOUR_HOSTNAME;      # substitute your public hostname
    root SITE_ROOT_PATH;            # substitute the absolute path to your document root

    # Fingerprinted assets: no validation needed, ever
    location /static/ {
        etag off;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # HTML and JSON: short shared TTL, the ETag does the real work
    location / {
        etag on;
        gzip_static on;
        add_header Cache-Control "public, max-age=0, s-maxage=60, stale-while-revalidate=30";
        add_header Vary "Accept-Encoding";
    }
}

Why max-age=0 with s-maxage=60: browsers revalidate on every navigation while the edge absorbs the load for a minute. Note that add_header does not inherit into nested locations, so repeat it where you nest.

Step 4 — Keep compression from corrupting validators

Compressed and uncompressed representations are different bytes and must not share a strong ETag. nginx downgrades the validator to weak when its gzip filter rewrites the body, which is correct behavior. Always send Vary Accept-Encoding alongside it. If you serve Brotli and gzip variants from a build pipeline, give each variant its own validator.

Step 5 — Let the edge own the conditional request

Configure the CDN to forward If-None-Match to the origin unchanged and to accept 304 as a cache refresh rather than an error. Enable request coalescing or origin shielding so a thundering herd of expiring edge entries produces one conditional request upstream, not ten thousand.

Validate: which headers to check and what to expect

Send two requests to the same URL, the second carrying the ETag returned by the first. Set the request headers explicitly with your HTTP client of choice.

Request 1 (cold)
GET /pricing.html HTTP/1.1
Host: YOUR_HOSTNAME

HTTP/1.1 200 OK
ETag: "6a1f2d-2c40"
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=30
Vary: Accept-Encoding
Content-Length: 11328

Request 2 (revalidation)
GET /pricing.html HTTP/1.1
Host: YOUR_HOSTNAME
If-None-Match: "6a1f2d-2c40"

HTTP/1.1 304 Not Modified
ETag: "6a1f2d-2c40"
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=30
Vary: Accept-Encoding

Expected values: status 304, no body, and the same ETag echoed back. A 304 that omits Cache-Control will refresh nothing, because the cache has no new freshness lifetime to apply. In origin logs, healthy revalidation shows as 304 responses with bytes-sent under 500. Repeat the second request against every origin node.

Rollback

Set etag off in the affected location, restore the previous Cache-Control line, reload the server, then purge the affected paths at the edge. Purging matters: caches that stored the new validator will keep sending If-None-Match against an origin that no longer emits one, and you will get 200s with full bodies until the entries expire. Watch origin egress for 15 minutes after the purge to confirm it settles.

Failure modes: symptom, cause, fix

SymptomCauseFix
Edge always pulls a full 200, never a 304A middleware layer strips If-None-Match, or the framework regenerates the body before comparingWhitelist If-None-Match in proxy request headers; move validator comparison ahead of body rendering
Same file, different ETag per nodeInode in the validator, or mtimes rewritten by deployPin to MTime and Size, preserve mtimes on copy, or emit a build-time content hash
Range requests and video seeking fail or restartWeak validator used with If-Range, which requires strong comparisonDisable on-the-fly compression for media paths so the strong ETag survives
Clients revalidate on every single requestThe 304 omits Cache-Control, so freshness is never extendedInclude Cache-Control, ETag, Date, and Vary on all 304 responses
Wrong compressed variant served to some clientsEncoding variants share one ETag and Vary is missingAdd Vary Accept-Encoding and give each encoding its own validator

Most broken cache validation traces back to one of two things: a validator that is not stable across origin nodes, or a 304 that forgets to carry Cache-Control.

Tuning once ETag validation works

Track the ratio of 304 to 200 responses at the origin for revalidatable paths. On a content site with a 60-second shared TTL, that ratio should sit well above 10:1 within a day. Run the arithmetic on your own volume: 5 million daily revalidations of a 250 KB asset move about 1,220 GB as full responses versus roughly 1.5 GB as 304s, close to 830x fewer bytes for the same freshness guarantee.

Two honest trade-offs. Application-generated ETags cost CPU on every miss, and for cheap-to-render responses a Last-Modified header with one-second granularity is simpler and nearly as effective. And stale-while-revalidate hides revalidation latency at the cost of serving briefly outdated bytes, which is unacceptable for pricing pages and inventory counts.

Where the revalidation logic runs matters as much as the origin config, so check that your provider lets you set conditional-request and TTL behavior per path rather than per property. BlazingCDN, for instance, exposes cache rules at that granularity; comparable per-path CDN cache configuration features are worth confirming before you commit to an edge design that depends on them.

FAQ: ETag headers and cache validation

What is the difference between a strong and a weak ETag?

A strong ETag guarantees the representation is byte-for-byte identical; a weak ETag, written with a W slash prefix, guarantees only semantic equivalence. Both work for If-None-Match revalidation because that uses weak comparison. Only strong validators satisfy If-Range and If-Match, so weak ETags break byte-range resumption and optimistic-concurrency updates.

Should I use an ETag header or Last-Modified for cache validation?

Use an ETag header when content can change more than once per second or when generation is not filesystem-backed, since Last-Modified has one-second granularity. Send both when you can: caches prefer If-None-Match and ignore If-Modified-Since when both are present, per RFC 9110, but older clients and intermediaries still fall back to dates.

Why does the origin return 200 instead of 304 when If-None-Match is sent?

The usual cause is that the request header never reaches the comparison logic, either because a proxy strips it or because the application renders the body before evaluating the validator. Second most common: the validator itself changed, typically because a deploy rewrote file modification times or a different origin node answered.

Do immutable fingerprinted assets need ETag headers?

No. Content-hashed filenames with a one-year max-age and the immutable directive eliminate revalidation entirely, which is cheaper than any conditional request. Reserve ETag headers for URLs whose content changes while the path stays constant: HTML documents, API responses, feeds, and manifests.

Instrument your 304 ratio this week

Add status code and bytes-sent to an origin log query filtered to revalidatable paths, then chart 304 count against 200 count for seven days. If 304s are under 10% of that traffic, your validators are not stable across nodes and the fix is Step 2, not a longer TTL. Then repeat the two-request check against each origin node individually. Divergent ETags for identical bytes is the single most common reason cache validation quietly costs full-body transfers instead of saving them.