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.

Storage eligibility, direct reuse and conditional validation are separate gates. A cached ten-byte body A is retained after an applicable 304, while metadata is updated. Changed body B requires a 200 replacement and a different strong tag even though both bodies have ten bytes. If-None-Match takes precedence over a matching date and uses weak comparison. The article test constructs requests; it does not simulate a browser cache.
A validator checks whether a stored representation still matches. A 304 refreshes metadata while the cache retains its body.

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, agreeing with entity-tag precedence for this combination; the reverse combination is not exercised here. 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

Sources