How to check ETag header

Read the validator with curl -s -D - -o /dev/null <url> | grep -i etag, then send it straight back as if-none-match. A working ETag answers 304 with zero bytes of body. A 200 with the full body means the server issues validators it does not honour.

Why check this

An ETag only earns its place when the second request is cheap. Run this in regression after a change to the web server, the framework or the CDN, because each of those layers can rewrite or drop the header without anyone noticing. The failure it prevents is a mobile client that re-downloads a 248 KB document on every poll while the server has been advertising a validator the whole time. Reading the header alone proves nothing: the conditional request is the check.

Prerequisites

Steps

  1. Step 1.

    Read the validator from the resource. Copy the value exactly, quotation marks included.

    curl -s -D - -o /dev/null 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control' | grep -i '^etag'
    
    etag: "faade5e2c9fa340e4914cf3b10d0963d"

    The double quotes are part of the value, not punctuation added by the header. Strip them and the next step fails.

  2. Step 2.

    Send the value back in if-none-match and print the status and the downloaded byte count.

    curl -s -D - -o /dev/null -H 'if-none-match: "faade5e2c9fa340e4914cf3b10d0963d"' -w 'status=%{http_code} bytes=%{size_download}\n' 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control' | grep -i -E 'HTTP/|^etag|^content-length|status='
    
    HTTP/2 304
    etag: "faade5e2c9fa340e4914cf3b10d0963d"
    status=304 bytes=0

    bytes=0 is the whole point. The response carries headers and no body, and the client reuses what it already holds.

  3. Step 3.

    Repeat with a value the server cannot match. This separates a real comparison from a server that returns 304 whenever the header is present.

    curl -s -D - -o /dev/null -H 'if-none-match: "wrong-value"' -w 'status=%{http_code} bytes=%{size_download}\n' 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control' | grep -i -E 'HTTP/|^etag|^content-length|status='
    
    HTTP/2 200
    etag: "faade5e2c9fa340e4914cf3b10d0963d"
    content-length: 248013
    status=200 bytes=248013

    248013 bytes against 0 is the saving the validator buys on every repeat request.

  4. Step 4.

    Read a weak validator. The W/ prefix marks a value that guarantees equivalent content rather than byte-identical content.

    curl -s -D - -o /dev/null 'https://api.github.com/repos/curl/curl' | grep -i '^etag'
    
    etag: W/"f9623f8ad5f05d1d41d69d2569e68cc1a3949d581ef05d2e078c96ddd9613b8a"
  5. Step 5.

    Send the weak validator back, prefix included. if-none-match compares weakly, so a weak tag still produces a 304.

    curl -s -D - -o /dev/null -H 'if-none-match: W/"f9623f8ad5f05d1d41d69d2569e68cc1a3949d581ef05d2e078c96ddd9613b8a"' -w 'status=%{http_code} bytes=%{size_download}\n' 'https://api.github.com/repos/curl/curl' | grep -i -E 'HTTP/|^etag|status='
    
    HTTP/2 304
    etag: W/"f9623f8ad5f05d1d41d69d2569e68cc1a3949d581ef05d2e078c96ddd9613b8a"
    status=304 bytes=0

    The prefix belongs to the value. It goes back into if-none-match exactly as it arrived, with nothing trimmed.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Step 2 gives 304 and bytes=0 | The validator works end to end | Nothing. Record the byte saving as the baseline. | | Step 2 gives 200 with a full body | The server issues tags it never compares | Find the layer that drops if-none-match. A proxy or a rewrite rule is the usual place. | | Step 3 also gives 304 | The server answers on header presence, not on the value | Treat every 304 from this service as unproven. Clients will be served stale content. | | etag: W/"..." | A weak validator | Fine for caching. Not usable for byte ranges or for if-match on a write. | | etag: abc123 with no quotes | The value breaks the grammar in RFC 9110 | Some clients reject it, some quote it themselves. Fix the server rather than the client. | | No etag at all | Only last-modified is available | Second-resolution dates are coarser. See the last-modified procedure below. |

Common mistakes

Sign: The conditional request always returns 200, and the ETag looks correct.Cause: The quotation marks were stripped when the value was copied. The header value includes them, so an unquoted tag never matches and the server answers with the full body every time.
Sign: ETags stop matching after a deploy even though no file changed.Cause: Many servers derive the tag from the inode and the modification time, both of which change when a release copies files to a new directory. Behind a load balancer, two nodes then hand out different tags for the same bytes.
Sign: Compression is enabled and the ETag becomes weak or disappears.Cause: A gzip or brotli layer changes the bytes on the wire, so a strong tag would be wrong. Servers respond by weakening the tag or dropping it. Check the header on the compressed variant, not only on the plain one.
Sign: A 304 arrives but the browser still shows old content after a real change.Cause: The tag is computed from something that does not track the content, such as a template file rather than the rendered output. The comparison succeeds while the body has moved on.

What to check next

FAQ

How to check if etag is working?

Run steps 2 and 3 as a pair. A 304 with bytes=0 for the correct value and a 200 with the full body for a wrong one proves the server compares rather than guesses. Either result alone proves nothing.

ETag vs expires header?

They answer different questions. expires states when a stored copy stops being fresh, and ETag states how to revalidate it cheaply once it does. A resource wants both: one to avoid the request, one to shrink it.

What does the W/ prefix mean?

It marks a weak validator: the content is equivalent, not byte-identical. if-none-match still matches weak tags, so caching works. Range requests and if-match on a write need a strong tag and will reject a weak one.

Should the server send both ETag and last-modified?

Sending both is normal. When a client returns both if-none-match and if-modified-since, the ETag decides and the date is ignored. The date remains useful for clients and proxies that store no tag.

Verified

Verified by Maks Vernycurl 8.21.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic5 minpublished updated Maks Verny