Response header
< ETag: "33a64df5"ETag Header: Strong vs Weak, If-None-Match and 304 Debugging
ETag identifies a resource version for cache validation and optimistic locking. Strong vs weak ETags, If-None-Match, If-Match, and why nginx gzip weakens them.
- Direction
- Response
- Category
- Caching
- Spec
- RFC 9110 §8.8.3
On this page
TL;DR:
ETagis a version fingerprint for a response. Clients send it back asIf-None-Matchto get a bodiless304 Not Modified, or asIf-Matchto refuse writes to a changed resource (412). If you never see 304, your ETags are unstable.
How it works
GET /app.css HTTP/1.1
HTTP/1.1 200 OK
Cache-Control: no-cache
ETag: "33a64df5"
Content-Length: 18240
Later, the client revalidates:
GET /app.css HTTP/1.1
If-None-Match: "33a64df5"
HTTP/1.1 304 Not Modified
ETag: "33a64df5"
Cache-Control: no-cache
The 304 has no body; the cache reuses its stored copy and updates the headers. The quotes are part of the value. Defined in RFC 9110 section 8.8.3 and used by caches per RFC 9111 section 4.3.
Strong and weak
ETag: "33a64df5"
ETag: W/"33a64df5"
Strong: byte-for-byte identical. Weak: equivalent for the application’s purposes. Comparison rules (RFC 9110 section 8.8.3.2): If-None-Match uses weak comparison, while If-Match and If-Range need strong. That means a weak ETag works for cache revalidation but cannot support safe writes or resuming a partial download.
Why gzip makes your ETag weak
When nginx compresses a response on the fly, it rewrites a strong ETag into a weak one (W/"...") because the compressed body is a different byte sequence. Apache’s mod_deflate historically appended -gzip to the tag ("33a64df5-gzip"), which broke If-None-Match matches on the uncompressed variant in some versions. Both are harmless for caching but defeat If-Range. Options: serve precompressed files (gzip_static on;, Brotli equivalents) with their own strong tags, or accept weak tags.
etag on; # default for static files; derived from mtime and size
gzip_static on; # serve app.css.gz as-is with a stable ETag
Generating ETags
- Hash of the content: stable across servers, costs CPU on dynamic content. Express generates weak ETags by default via
app.set('etag', 'weak'). - Version or revision number from the database row: cheap and strong semantics for APIs (
"v17"). - File mtime plus size (nginx default): fine on a single node. nginx’s format is
"<mtime hex>-<size hex>". - Avoid inode-based ETags behind a load balancer. Apache 2.4 defaults to
FileETag MTime Size; if a config still enablesINode(the old Apache 2.2 default), remove it and check that file modification times agree across nodes.
Optimistic concurrency with If-Match
PUT /api/docs/7 HTTP/1.1
If-Match: "v17"
Content-Type: application/json
{"title":"Updated"}
HTTP/1.1 412 Precondition Failed
ETag: "v18"
Someone else saved v18 first. Return 428 Precondition Required if you demand the header and it is missing. See If-Match, 412 and 428.
Debug “no 304”
curl -sI https://example.com/app.css | grep -i etag
curl -sI https://example.com/app.css | grep -i etag # same value?
curl -si https://example.com/app.css -H 'If-None-Match: "33a64df5"' | head -1
If the value changes between calls, you are hitting different origins or a layer rewrites it. If it is stable but the second command returns 200, the server does not implement conditional handling or Vary differs. A CDN that does not forward If-None-Match to the origin can still answer 304 itself from its cache, and Cloudflare may drop strong ETags on compressed responses, so check cf-cache-status and compare ETags at the origin directly.
Also note an ETag does nothing for freshness; pair it with Cache-Control no-cache or max-age.
Related
Frequently asked questions
What is an ETag header?
An ETag is an opaque validator the server attaches to a specific version of a resource, such as "33a64df5". Clients store it and send it back in If-None-Match so the server can answer 304 Not Modified without resending the body, or in If-Match to guard writes against lost updates.
What is the difference between strong and weak ETags?
A strong ETag changes whenever any byte of the representation changes. A weak ETag, prefixed W/, signals that two versions are semantically equivalent even if bytes differ. Weak validators are fine for If-None-Match, but If-Match and If-Range require strong comparison, so a weak ETag cannot be used for byte-range resumption or safe writes.
Why is my ETag weak when I send a strong one?
nginx converts a strong ETag to weak (W/"...") when it compresses the response on the fly with gzip, because the compressed bytes differ from the original. Apache mod_deflate historically appended -gzip to the value instead. Precompress files, or generate weak ETags deliberately.
Why am I never getting 304 Not Modified?
The ETag changes between requests. Usual causes are multiple origin servers generating ETags from inode and mtime (so each node differs), a CDN or middleware regenerating them, a build that embeds timestamps, or a Vary mismatch. Compare ETags from two consecutive curl -I requests.
Should I use ETag or Last-Modified?
Send both when you can. If-None-Match takes precedence when a request carries both. ETag is precise for dynamic content; Last-Modified has one-second resolution and is also what heuristic caching uses.
What does 412 Precondition Failed with If-Match mean?
The resource changed since the client read it, so its ETag no longer matches. This is optimistic concurrency working as intended: refetch, merge, and retry with the new ETag.
Sources
Related
ETag vs Last-Modified: HTTP Cache Validators
ETag vs Last-Modified: strong and weak validation, one-second timestamps, conditional request precedence, multi-server consistency, and CDN debugging.
REST API Design with HTTP Semantics
Choose HTTP methods and status codes for a REST API: 201, 202, 204, 400 vs 422, 409, ETag with If-Match, pagination, problem+json and idempotency keys.
Last-Modified
Learn how the Last-Modified header indicates when a resource was last changed. Enable efficient cache validation with If-Modified-Since conditional requests.
Age Header
Learn how the Age header indicates how long a response has been cached in seconds. Understand cache freshness calculations and CDN behavior.
Expires Header
Learn how the Expires header specifies when cached responses become stale. Understand date formats and when to use Expires vs Cache-Control for caching.