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
- curl 7.0 or later. Any build works. See the curl manual.
- A URL with a body worth saving, so the byte counts in steps 2 and 3 differ visibly.
- RFC 9110 section 8.8.3 for the ETag grammar, including the
W/prefix.
Steps
- 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.
- Step 2.
Send the value back in
if-none-matchand 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=0bytes=0is the whole point. The response carries headers and no body, and the client reuses what it already holds. - 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=248013248013 bytes against 0 is the saving the validator buys on every repeat request.
- 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" - Step 5.
Send the weak validator back, prefix included.
if-none-matchcompares 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=0The prefix belongs to the value. It goes back into
if-none-matchexactly 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
What to check next
- How to check last-modified header: the other validator, and what to do when both are sent.
- How to check cache-control header: the freshness window that decides when the validator is used at all.
- How to check if CDN cache is hit or miss: the edge may answer the conditional request before the origin sees it.
- How to check HTTP response headers with curl: the flags used above for printing and filtering headers.
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.
Related on this site
basic5 minpublished updated Maks Verny