Skip to content

/commands/http/test-etag-if-none-match-304

Test ETag caching with curl: send If-None-Match and get 304 Not Modified

verified onenvironmentlocal · windows 10.0.26200 arm64 · GNU bash 5.3.15 (Git Bash) · curl 8.21.0 (x86_64-w64-mingw32) libcurl/8.21.0 Schannel zlib/1.3.2updated
tool
HTTP
versions
curl 7.68+ (--etag-save, --etag-compare), RFC 9110
os
linux, macos, windows

To check that a server supports conditional requests, fetch the resource once, copy its ETag header, and send it back in If-None-Match: curl -i -H 'If-None-Match: "ETAG"' URL. A server that implements it answers 304 Not Modified with no body while the resource is unchanged, and 200 OK with the full body after it changes. curl 7.68 and later can do the bookkeeping with --etag-save FILE and --etag-compare FILE.

Try it against a local server

The server below computes a strong ETag from the body and honours If-None-Match:

bash
cat > server.js <<'EOF'
const http = require("http");
const crypto = require("crypto");
const body = '{"items":[1,2,3]}\n';
const etag = '"' + crypto.createHash("sha1").update(body).digest("hex").slice(0, 12) + '"';
http.createServer((req, res) => {
  res.setHeader("ETag", etag);
  res.setHeader("Cache-Control", "no-cache");
  if (req.headers["if-none-match"] === etag) { res.writeHead(304); return res.end(); }
  res.setHeader("Content-Type", "application/json");
  res.end(body);
}).listen(5207, "127.0.0.1");
setTimeout(() => process.exit(0), 10000);
EOF
node server.js &
sleep 1
etag=$(curl -s -I http://127.0.0.1:5207/items | tr -d '\r' | awk 'tolower($1)=="etag:" {print $2}')
echo "ETag: $etag"
echo "== matching ETag"
curl -s -i -H "If-None-Match: $etag" http://127.0.0.1:5207/items | tr -d '\r' | grep -iE '^(HTTP|etag|content-length)'
echo "== stale ETag"
curl -s -o /dev/null -w '%{http_code}\n' -H 'If-None-Match: "old"' http://127.0.0.1:5207/items
echo "== curl keeps the ETag in a file"
curl -s -o items.json --etag-save etag.txt http://127.0.0.1:5207/items
curl -s -o items.json -w '%{http_code}\n' --etag-compare etag.txt http://127.0.0.1:5207/items
wait

Output

ETag: "c2792215b282"
== matching ETag
HTTP/1.1 304 Not Modified
ETag: "c2792215b282"
== stale ETag
200
== curl keeps the ETag in a file
304

The first request with the matching ETag gets 304 and the same ETag header; a stale value gets the full 200 response. With --etag-compare, curl reads the saved value and sends it as If-None-Match for you.

Headers involved

HeaderSent byMeaning
ETagServerOpaque validator for the current version of the resource, in double quotes
If-None-MatchClientSend the body only if the current ETag differs from these values (GET and HEAD answer 304)
Last-ModifiedServerDate of the last change, a weaker validator than ETag
If-Modified-SinceClientSend the body only if it changed after this date; ignored when If-None-Match is present
Cache-Control: no-cacheServerClients may store the response but must revalidate it before each reuse

Last-Modified instead of ETag

For servers that only send Last-Modified, use curl -z "date" URL or curl -z file URL: curl sends If-Modified-Since with that date (or the file's modification time), and a server that supports it returns 304 for unchanged content.

Pitfalls

  • The quotes belong to the ETag. If-None-Match: abc does not match ETag: "abc".
  • Compression can change the ETag: some servers append a suffix such as -gzip or make the tag weak (W/) when they compress, so a value captured without Accept-Encoding may not match a browser request.
  • Cache-Control: no-store tells clients not to keep the response at all, so browsers will never send If-None-Match; see no-cache vs no-store.

FAQ

Why does my server always return 200 even with If-None-Match?

Either the value you send does not match the current ETag exactly (quotes and the W/ prefix are part of it), or the server or framework does not implement conditional GET for that route. Some proxies and compression layers also change or drop the ETag.

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

A weak ETag starts with W/ and only promises that the content is semantically equivalent. If-None-Match uses weak comparison, so W/"abc" matches "abc"; byte-range requests with If-Range need a strong ETag.

Does a 304 response have a body?

No. A 304 Not Modified response never carries content; the client reuses its cached copy and may update stored headers from the 304.

Sources