ETag Generation and Validation Strategies

TL;DR: An ETag is an opaque identifier the origin attaches to a response body. Caches store it, send it back on the next request inside If-None-Match, and the origin either confirms the stored copy is still valid (304 Not Modified) or returns a new body with a new ETag. How you compute that identifier — a content hash, a file’s inode and modification time, or an application-level version tag — determines whether the validator survives across servers, deploys, and CDN transformations.

Quick-reference header block for a static asset validated by content hash:

HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: public, max-age=3600, must-revalidate
ETag: "a3f8c9d21e4b7061f9c2d8e5b6a10f77"
Last-Modified: Mon, 06 Jul 2026 09:12:44 GMT

Mechanism and RFC Alignment

RFC 9110 §8.8.3 defines ETag as an opaque validator: a quoted string that a cache or client uses to ask the origin “is the copy I’m holding, identified by this string, still current?” The mechanism runs in three stages, mirrored in the diagram below.

  1. Computation — the origin derives a value that changes if and only if the representation changes (for a strong validator) or changes whenever a semantically significant edit occurs (for a weak validator). The three dominant strategies are:
    • Content hash — MD5, SHA-1, or CRC32 of the response body (or of the underlying file). Deterministic purely from bytes; identical content anywhere produces an identical ETag.
    • Inode / mtime / size — the traditional web-server default (Apache’s FileETag INode MTime Size, Nginx’s hex-encoded modification time and length). Cheap because it needs no read of the file body, but tied to filesystem metadata that a load balancer cannot guarantee is identical across nodes.
    • Version tag — an application-supplied identifier, such as a database row’s updated_at timestamp, a monotonically increasing revision counter, or a build’s content-addressed asset hash (webpack [contenthash], Vite’s hashed filenames). Common for API responses where hashing the full serialized body on every request is wasteful.
  2. Emission — the value is quoted and placed in the ETag response header, optionally prefixed with W/ to mark it weak (see Strong vs Weak ETags Explained for the full comparison-function rules).
  3. Conditional revalidation — on a subsequent request, the client or shared cache sends If-None-Match: "<value>". The origin recomputes (or looks up) the current ETag and compares it against the one supplied. A match returns 304 Not Modified with no body; a mismatch returns 200 OK with the new representation and its new ETag. This exchange is the subject of Conditional Requests and 304 Responses.

ETag is a validator, not a freshness signal — it says nothing about how long a response can be reused without asking the origin. That’s governed separately by max-age and s-maxage. The two mechanisms compose: freshness directives control whether a cache needs to ask at all, and ETag controls what happens when it does ask. See Freshness vs Validation Models Explained for how the two layers fit together.


Scope and Precedence

Every tier in the cache hierarchy can generate, store, or check an ETag, but each plays a different role:

Tier Role
Origin server / application Computes the canonical ETag value; the only tier that can authoritatively answer an If-None-Match comparison.
CDN / shared cache Stores the origin’s ETag alongside the cached body; forwards If-None-Match upstream once the stored copy goes stale, or answers 304 itself if it independently tracks freshness and trusts the stored validator.
Browser Stores the ETag in its HTTP cache; automatically attaches If-None-Match on revalidation without any application code involved.

When both ETag and Last-Modified are present, RFC 9110 §13.2.2 is explicit: a recipient must ignore If-Modified-Since when If-None-Match is also present and the server supports it. ETag wins. The deeper comparison rules — precision, cost, and when to prefer one validator over the other — are covered in ETag vs Last-Modified: Which Validator to Use.

ETag comparison itself has two modes with different applicability:

Comparison type Rule Used for
Strong comparison Both validators are strong AND identical If-Match, If-Range, byte-range requests
Weak comparison Validators are identical after stripping any W/ prefix If-None-Match on a normal GET, ordinary cache revalidation

This means a weak ETag is perfectly usable for everyday conditional GET revalidation but is rejected outright for Range requests — the client resuming a partial download needs a byte-for-byte guarantee that a weak validator cannot make.


Diagram: ETag Generation and Validation Flow

ETag Generation and Validation Flow Diagram showing three ETag generation strategies feeding into an origin response, and the subsequent conditional revalidation exchange between client and origin using If-None-Match. GENERATION STRATEGIES content hash (MD5/SHA-1) inode + mtime + size app version tag Origin Response ETag: "a3f8...0f77" Client / Shared Cache Store stores body + ETag together for future revalidation LATER REQUEST — REVALIDATION Conditional Request If-None-Match: "a3f8...0f77" sent to origin Origin Comparison recompute current ETag, compare to supplied value Match 304 Not Modified Mismatch 200 OK + new ETag

Implementation Patterns

Pattern 1: Static asset — content-hash ETag

HTTP/1.1 200 OK
Content-Type: text/css
Cache-Control: public, max-age=86400
ETag: "9c1f2a7bd4e08135f6c9a2b1d7e4f003"

The hash is computed once at build time (or on first request and cached), so every server in a load-balanced pool serving the same file emits an identical value. This is the safest default for any asset served from more than one process or machine.

Pattern 2: API resource — version-tag ETag

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, no-cache
ETag: "user-4821-rev-77"

Rather than hashing the serialized JSON body on every request (wasted CPU for a resource that might not have changed), the application derives the ETag from a monotonically increasing revision column already stored alongside the record. no-cache still permits storage but forces revalidation on every use — see no-cache vs no-store for when that combination is appropriate.

Pattern 3: Authenticated, per-user response — weak ETag over rendered HTML

HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: private, max-age=0, must-revalidate
ETag: W/"dashboard-v3-locale-en-theme-dark"

The rendered markup differs by whitespace or attribute ordering between requests even when the underlying data hasn’t changed, so a weak validator — one asserting semantic rather than byte-for-byte equivalence — avoids false-negative revalidations. See Strong vs Weak ETags Explained for exactly which requests a weak validator can and cannot satisfy.

Pattern 4: CDN split — origin computes, edge stores and forwards

HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: public, s-maxage=31536000, max-age=3600
ETag: "3fa8112cd6091ba7452e0f9c8d1a6ee2"

The CDN honors s-maxage for its own TTL and stores the ETag verbatim. When the edge copy’s TTL lapses, the CDN issues its own conditional request upstream using If-None-Match before falling back to a full fetch — sparing origin bandwidth on unchanged assets even after the cached copy has technically expired.


Server and CDN Configuration

Nginx — default and custom ETag emission

# Nginx's built-in ETag (on by default): hex(mtime)-hex(size)
# Cheap, but the value differs across servers if file mtimes are not
# byte-identical (a common problem after independent deploys).
location /assets/ {
    etag on;
}

# Custom content-hash ETag for proxied/dynamic responses:
# compute the hash upstream and forward it as the ETag.
location /api/ {
    proxy_pass http://app_upstream;
    proxy_pass_header ETag;
    # Ensure the upstream app, not Nginx, sets the authoritative ETag.
}

Apache — controlling ETag composition

# Default combines inode, mtime, and size — unsafe across a
# load-balanced cluster because inode numbers are host-local.
FileETag INode MTime Size

# Cluster-safe: drop the inode component.
FileETag MTime Size

# Fully disable Apache's filesystem-derived ETag and let the
# application emit its own content-hash based validator instead.
FileETag None
Header always set ETag "\"%{APP_ETAG}e\""

Cloudflare — preserving origin ETags through the edge

Cache-Control: public, s-maxage=86400, max-age=3600
ETag: "3fa8112cd6091ba7452e0f9c8d1a6ee2"

Cloudflare forwards the origin’s ETag unchanged by default and uses it for edge-triggered conditional revalidation. If Auto Minify, Rocket Loader, or image transformation modifies the response body at the edge, the served bytes no longer match what the origin’s ETag described — configure a Cache Rule or Transform Rule to strip or regenerate the header for any zone where such transformations are active, to avoid serving a stale validator alongside altered content.


max-age / s-maxage — freshness directives decide whether a cache needs to check with the origin at all; ETag decides what happens once it does. A long max-age with a well-designed ETag gives you both a low request volume and cheap, bandwidth-light revalidation once the freshness window lapses.

must-revalidate — pairs naturally with ETag: once the response is stale, must-revalidate forbids serving it without a successful conditional check, forcing the If-None-Match round trip described above rather than silently serving expired content.

stale-while-revalidate — see Serving Stale Content with stale-while-revalidate: the cache serves the stale copy immediately while issuing the If-None-Match revalidation in the background. A cheap, stable ETag keeps that background check inexpensive even under high request volume.

Last-Modified — the two validators are commonly emitted together for compatibility; see ETag vs Last-Modified: Which Validator to Use for the precedence rule and when each is preferable. Date-granularity nuances are covered in Last-Modified and Date-Based Validation.

Vary-selected variants** — a response with Vary: Accept-Encoding produces distinct stored bodies per encoding; each variant should carry its own distinct ETag, since the gzip and identity encodings of the same logical resource are different byte sequences.


Verification Workflow

Step 1 — Confirm ETag emission and freshness directives

curl -sI https://example.com/app.js | grep -iE 'etag|cache-control|last-modified'

Expect an ETag header quoted exactly, e.g. ETag: "9c1f2a7bd4e08135f6c9a2b1d7e4f003".

Step 2 — Issue a conditional request using the captured value

ETAG=$(curl -sI https://example.com/app.js | grep -i '^etag:' | cut -d' ' -f2 | tr -d '\r')
curl -sI -H "If-None-Match: $ETAG" https://example.com/app.js

A correctly implemented origin returns HTTP/1.1 304 Not Modified with no body.

Step 3 — Confirm the ETag changes when content changes

# Modify the underlying resource, then repeat step 1
curl -sI https://example.com/app.js | grep -i etag

A different quoted value confirms the validator tracks content changes rather than being cached statically somewhere in the pipeline.

Step 4 — Confirm consistency across a clustered origin

for i in 1 2 3; do
  curl -sI --resolve example.com:443:10.0.0.$i https://example.com/app.js | grep -i etag
done

If any node returns a different ETag for identical content, the generation strategy is not cluster-safe — switch from filesystem metadata to a content hash.


Failure Modes and Gotchas

  1. Inode-based ETags differ across load-balanced nodes — two servers holding byte-identical files can still emit different ETag values if FileETag includes INode and the file was copied rather than replicated with matching inodes. Clients bounce between servers on every request, seeing a perpetual mismatch and never getting a 304.

  2. Deploys that touch mtime without touching content — a git checkout or rsync without -t updates file modification times even when bytes are unchanged, silently invalidating every mtime-derived ETag fleet-wide after a routine deploy.

  3. Gzip/Brotli compression changes the served bytes but not the ETag — if a proxy compresses a response on the fly without adjusting the validator, the ETag describes the uncompressed body while the client received the compressed one. Some servers append a suffix (e.g., -gzip) to signal this; forgetting to also weaken the prefix to W/ violates the strong-comparison guarantee.

  4. CDN content transformation without ETag regeneration — image resizing, minification, or Polish-style optimizations at the edge alter bytes but may forward the origin’s unmodified ETag, producing a validator that never actually matches the transformed bytes it now describes.

  5. Missing Vary alignment — if a resource has multiple encoded variants but all share one ETag, a conditional request from a client requesting gzip may get a false 304 when only the br variant actually changed.

  6. ETag omitted on responses without Last-Modified either — leaves the cache no way to revalidate at all once stale; every stale hit forces a full re-fetch. Always emit at least one validator.

  7. Case-sensitive or whitespace-mismatched comparison bugs — some proxies re-quote or re-serialize the ETag value (adding/removing surrounding whitespace) before forwarding it, breaking exact-string comparison on the origin side.


Frequently Asked Questions

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

A strong ETag guarantees byte-for-byte identity between two responses that share it and can be used for range requests. A weak ETag, prefixed with W/, only guarantees semantic equivalence and cannot be used to validate partial content requests.

Is a content-hash ETag always better than one based on mtime and inode?

For single-server setups, mtime/inode-based ETags are cheaper and usually sufficient. For clustered origins or multi-region deployments, content-hash ETags are strongly preferred because inode numbers and file timestamps are not guaranteed to match across servers, which causes spurious cache misses.

Do CDNs preserve the origin’s ETag unchanged?

Only when the CDN does not transform the response body. If the CDN applies on-the-fly compression, minification, or image optimization, it typically must invalidate or weaken the original ETag because the bytes it serves no longer match the bytes the origin’s ETag described.

Can I rely on ETag alone without Last-Modified?

Yes. ETag is a complete validator on its own and is preferred by RFC 9110 when both are present. Emitting Last-Modified alongside it is still useful for clients and intermediaries that only implement date-based validation.


Back to Cache Validation & Conditional Requests