An origin defines storage and freshness policy; a cache decides whether its stored response can satisfy the next request. When it cannot reuse that response directly, validators can make checking cheaper. These are separate decisions. Finding Cache-Control and ETag in one successful response does not prove that the next request follows the intended path.
Who is allowed to reuse and for how long
Private restricts storage to single-user caches, while public explicitly permits caching subject to other requirements. Max-age supplies a freshness lifetime; s-maxage overrides it for shared caches. No-cache permits storage but requires successful validation before reuse. No-store prohibits HTTP cache storage, not application logging or every possible saved copy. None of these directives substitutes for authorization or transport security.
For a content-fingerprinted asset, public, max-age=31536000, immutable can be appropriate when changing the bytes always changes the URL. That duration is one year, not one month. A mutable document at a stable URL needs a policy consistent with its update requirements. A short lifetime with a validator permits bounded direct reuse; no-cache instead requires validation for each subsequent cache reuse.
Validators answer a different question
A client can echo an ETag in If-None-Match. For conditional GET, a matching tag produces 304 without content; a nonmatching tag ordinarily produces 200 with the current representation. If-None-Match uses weak comparison, so weak tags are useful for validation too. Last-Modified supports If-Modified-Since, but second-level timestamps can miss rapid changes. When If-None-Match is present, If-Modified-Since is ignored, regardless of whether the supplied entity tag matches.
A cache receiving an applicable 304 retains its stored body and updates metadata according to the caching rules. The origin must include relevant fields such as Date, ETag, and Cache-Control when they would appear on the corresponding 200. A program that only downloads responses does not automatically implement this cache behavior; treating an empty 304 as a replacement document loses the actual content.
When it breaks
An ETag that never changes when meaningful content changes can incorrectly confirm an old response. A tag regenerated for every identical response causes unnecessary transfers instead. Both defects require repeated-request tests. Strong tags must distinguish differing representation data, including content-coded variants. A hash of the actual bytes is one possible implementation, not a requirement to expose the hash algorithm as a client contract.
Changing origin policy does not recall already-fresh copies that have no reason to contact it. Likewise, a short TTL on personalized JSON does not isolate users. Test cache keys and account transitions separately. Browser history restoration and application-managed caches are additional mechanisms, so a passing HTTP test should not be described as proof that every displayed page will refresh immediately.
Check it quickly
Use Python 3.11 or newer and a POSIX shell. Install Werkzeug 3.1.8 below, then run the Python block with "$validation_lab/venv/bin/python". It creates a loopback development server on an automatically allocated port and shuts it down afterward. Installation needs the network; all seven requests remain local. This is an origin-side protocol test, not a production deployment or browser-cache simulation.
validation_lab=$(mktemp -d)
python3 -m venv "$validation_lab/venv"
"$validation_lab/venv/bin/python" -m pip install Werkzeug==3.1.8
import hashlib
from datetime import datetime, timezone
from http.client import HTTPConnection
from threading import Thread
from werkzeug.serving import make_server
from werkzeug.wrappers import Request, Response
state = {"body": b"release A\n", "modified": datetime(2026, 1, 1, tzinfo=timezone.utc)}
directive = "public, max-age=0, must-revalidate"
@Request.application
def application(request):
if request.path != "/document":
return Response("not found\n", status=404)
if request.method != "GET":
return Response("GET only\n", status=405, headers={"Allow": "GET"})
body = state["body"]
response = Response(body, mimetype="text/plain")
response.headers["Cache-Control"] = directive
response.set_etag(hashlib.sha256(body).hexdigest())
response.last_modified = state["modified"]
return response.make_conditional(request)
server = make_server("127.0.0.1", 0, application)
worker = Thread(target=server.serve_forever)
worker.start()
def get(headers=None):
connection = HTTPConnection("127.0.0.1", server.server_port, timeout=3)
try:
connection.request("GET", "/document", headers=headers or {})
response = connection.getresponse()
body = response.read()
assert response.headers["Cache-Control"] == directive
assert response.headers["ETag"] and response.headers["Date"]
print(response.status, "bytes:", len(body))
return response.status, response.headers, body
finally:
connection.close()
try:
status, first, body = get()
assert status == 200 and body == b"release A\n"
status, headers, body = get({"If-None-Match": first["ETag"]})
assert status == 304 and body == b"" and headers["ETag"] == first["ETag"]
status, _, body = get({"If-None-Match": '"different"',
"If-Modified-Since": first["Last-Modified"]})
assert status == 200 and body == b"release A\n"
status, _, body = get({"If-None-Match": "W/" + first["ETag"]})
assert status == 304 and body == b""
state.update(body=b"release B\n", modified=datetime(2026, 1, 2, tzinfo=timezone.utc))
status, second, body = get({"If-None-Match": first["ETag"]})
assert status == 200 and body == b"release B\n"
assert second["ETag"] != first["ETag"]
status, _, body = get({"If-None-Match": second["ETag"]})
assert status == 304 and body == b""
status, _, body = get({"If-Modified-Since": second["Last-Modified"]})
assert status == 304 and body == b""
finally:
server.shutdown()
server.server_close()
worker.join()
Expected statuses are 200, 304, 200, 304, 200, 304, and 304. Each 200 carries ten bytes; each 304 carries zero. The conflicting-validator request returns 200 even though its date matches, proving entity-tag precedence. The weak-form request still validates the unchanged representation. Both A and B have the same length, so the changed-body test also rules out using length alone as a validator.
The final two requests validate B using its new ETag and then its Last-Modified value alone. Cache-Control, Date, and ETag are checked throughout. Max-age zero with must-revalidate permits storage but requires successful validation once stale; it does not itself cause Python’s HTTP client to cache anything. The client deliberately constructs every request. Werkzeug handles conditional parsing and wire-level body suppression.
Carry the changed-body and conflicting-validator cases into deployment tests. Inspect the actual GET response, not only HEAD, and check that intermediaries preserve the validator for the selected representation. A good result includes both avoided transfers when appropriate and full replacement when the content really changed.
Further reading
- Content-Type should describe the bytes you actually send
- Cache headers are a contract with two clocks
Sources
- RFC 9111: cache validation, stored-body reuse and metadata updates, checked 2026-09-07.
- RFC 9110: conditional requests, entity-tag comparison and precondition precedence, checked 2026-09-07.
- MDN: Cache-Control, storage directives and resource-specific freshness, checked 2026-09-07.
- Werkzeug conditional responses, the tested response API and body handling, checked 2026-09-07.