A 304 is useful only when the old representation really remains an acceptable substitute. ETag supplies the origin’s version claim; it does not independently verify that claim. An incorrect tag can make conditional requests efficiently preserve wrong content. Test both unchanged data and meaningful changes before counting reduced transfers as a performance improvement.
Validators identify the version
Entity tags are opaque, quoted identifiers. Clients should return them as received rather than interpreting them as timestamps or removing the weak marker. The generator might use a revision identifier or a collision-resistant digest. A digest is convenient when the final bytes already exist; repeatedly rendering and hashing an expensive document merely to return 304 can still consume substantial origin work.
Strong and weak are different promises
A strong validator must change when the selected representation’s data changes. Pretty-printed and compact JSON have different bytes even when they decode into the same object. Gzip and unencoded forms also have different representation data. Their strong validators must preserve that distinction. Identical lengths or a database timestamp with coarse resolution do not establish byte identity.
A weak tag, marked with W/, can group representations that the origin considers interchangeable. For a JSON endpoint whose contract ignores formatting, one semantic revision can cover both compact and indented serialization. A change to the actual field values requires a new revision when the old object is no longer an acceptable substitute. Weak validation is an explicit equivalence policy, not permission to leave a tag unchanged indefinitely.
If-None-Match uses weak comparison for cache validation. A matching conditional GET receives 304 without content; a nonmatching tag normally receives 200 with the selected representation. If-Match uses strong comparison, which matters for update preconditions. Weak tags are not valid client validators for If-Range. These fields have different contracts, so stripping W/ and applying one generic string comparison everywhere is incorrect.
When it breaks
A fixed tag on changing data can produce false 304 responses. The opposite mistake, issuing a new tag for identical data on every request, causes needless body transfers. Neither is repaired by adding a long max-age. Freshness decides when validation is needed; validation decides whether an existing response remains usable. A successful 304 also does not guarantee that application parsing or rendering work disappears.
Comparison must follow representation selection and normal access checks. A matching validator is not authorization. For negotiated responses, preserve the relevant Vary dimensions and distinguish representation data correctly. Header lists require a real parser: a quoted entity tag can itself contain a comma, so splitting the header text on commas corrupts valid identifiers.
Check it quickly
Use Python 3.11 or newer and Werkzeug 3.1.8. Create the temporary environment, then run the Python block with "$etag_lab/venv/bin/python". The test client exercises the WSGI application in-process without binding a port or making network requests. Installation requires the network. The deliberately broken endpoint is a negative fixture, not an implementation to deploy.
etag_lab=$(mktemp -d)
python3 -m venv "$etag_lab/venv"
"$etag_lab/venv/bin/python" -m pip install Werkzeug==3.1.8
import hashlib
import json
from werkzeug.http import parse_etags
from werkzeug.test import Client
from werkzeug.wrappers import Request, Response
state = {"value": 1, "revision": 1, "indent": None}
def body():
return json.dumps({"value": state["value"]}, indent=state["indent"]).encode()
@Request.application
def application(request):
if request.path not in {"/strong", "/weak", "/broken"}:
return Response(status=404)
response = Response(body(), mimetype="application/json")
response.headers["Cache-Control"] = "no-cache"
if request.path == "/strong":
response.set_etag(hashlib.sha256(response.data).hexdigest())
elif request.path == "/weak":
response.set_etag(f"record-{state['revision']}", weak=True)
else:
response.set_etag("fixed") # Deliberately incorrect failure fixture.
return response.make_conditional(request)
client = Client(application)
def get(route, tag=None):
response = client.get(route, headers={"If-None-Match": tag} if tag else {})
print(route, response.status_code, "bytes:", len(response.data))
return response
first = get("/strong")
assert first.status_code == 200 and first.json == {"value": 1}
same = get("/strong", first.headers["ETag"])
assert same.status_code == 304 and same.data == b""
state["indent"] = 2
formatted = get("/strong", first.headers["ETag"])
assert formatted.status_code == 200 and formatted.json == first.json
assert formatted.data != first.data and formatted.headers["ETag"] != first.headers["ETag"]
weak = get("/weak")
state["indent"] = None
assert body() != weak.data and json.loads(body()) == weak.json
equivalent = get("/weak", weak.headers["ETag"])
assert equivalent.status_code == 304 and equivalent.data == b""
state.update(value=2, revision=2)
changed = get("/weak", weak.headers["ETag"])
assert changed.status_code == 200 and changed.json == {"value": 2}
assert changed.headers["ETag"] != weak.headers["ETag"]
tags = parse_etags('"unrelated", W/"record-2", "a,b"')
assert tags.contains_weak("record-2") and not tags.contains("record-2")
assert tags.contains("a,b")
print("weak match: True; strong match: False; quoted comma preserved")
broken = get("/broken")
state.update(value=3, revision=3)
assert json.loads(body()) != broken.json
false_confirmation = get("/broken", broken.headers["ETag"])
assert false_confirmation.status_code == 304 and false_confirmation.data == b""
print("broken validator: changed content was incorrectly confirmed")
The strong endpoint returns 200, 304, then 200. Formatting changes the body from twelve to sixteen bytes and changes its digest, even though the parsed object remains equal. The weak endpoint returns 200, 304, then 200 too, but for different reasons: formatting alone is equivalent; changing the value and revision is not. Both 304 responses contain no body.
The parser check accepts the weak revision for weak comparison but rejects it for strong comparison, while preserving the quoted comma. Finally, the broken endpoint returns 200 followed by 304 even though its current object has changed. That assertion intentionally documents incorrect origin behavior. The framework trusted the supplied tag; it did not detect that the application had attached the wrong version claim.
The fixture does not test range assembly, concurrent writes, authorization, or a shared cache. Production revision changes must be coupled to the represented data, and write preconditions need atomic checking with the update. Retain the changed-content counterexample in tests: a high 304 ratio alone cannot distinguish correct savings from persistent stale answers.
Further reading
- Content-Type should describe the bytes you actually send
- Cache headers are a contract with two clocks
Sources
- RFC 9110: validators, strong and weak representation claims, checked 2026-09-07.
- MDN: ETag, opaque identifiers and validator use, checked 2026-09-07.
- Werkzeug entity-tag collections, strong and weak membership tests, checked 2026-09-07.
- Werkzeug test client, in-process WSGI request testing, checked 2026-09-07.