NOETRION

Choose a topic

TECHNOLOGY · 7 MIN READ

HTTP ETags: Revalidate Cached Content and Prevent Lost Updates

Understand opaque entity tags, strong and weak comparisons, conditional reads, optimistic update preconditions, and the distinction between freshness and validation.

Reviewed October 6, 2026. Semantics are checked against RFC 9110 and RFC 9111. The Python 3.12.14 example is a bounded local state model, not a production HTTP server or standards-complete header parser.

Two clients download the same document. The first edits and saves it. The second saves its older copy and overwrites the first client's work. Elsewhere, a browser downloads an unchanged representation repeatedly because it has no validator with which to ask whether its stored copy is still suitable.

An entity tag can help with both problems, through different request conditions. If-None-Match supports conditional reads and other semantics defined by the method. If-Match can require the current representation to match the version a client intends to update.

Practical rule: treat ETags as opaque server-generated validators, use the comparison required by the particular condition, and evaluate write preconditions atomically with the update. Keep cache freshness, authorization, representation selection, and version validation as separate responsibilities.

An ETag identifies a selected representation

An ETag is not necessarily a content hash, a timestamp, or a human-readable version number. Its quoted value is opaque to the client. The server controls its meaning while meeting the validator's required semantics for the selected representation.

A weak tag has the W/ prefix. A strong comparison requires matching opaque tags and neither tag being weak. A weak comparison can match the same opaque tag even when one or both are marked weak. RFC 9110's validator section defines these distinctions.

Tags compared Strong comparison Weak comparison
"v1" and "v1" Match Match
W/"v1" and "v1" No match Match
W/"v1" and W/"v1" No match Match
"v1" and "v2" No match No match

Weak equivalence is useful when representations are considered semantically equivalent despite differences that prevent a strong validator. It is not a declaration that every use of the tag can safely ignore byte-level differences. The precondition determines which comparison applies.

Content negotiation also matters. Different encodings or selected variants need validators and cache keys consistent with the representation being served. A tag assigned to one response should not be casually reused across distinct representations without the correct semantics.

Conditional GET can reuse a stored body

A client that has a representation and its ETag can send If-None-Match on a later GET. If the selected representation matches under the required weak comparison, the server can return 304 Not Modified rather than sending the representation body again.

GET /guide.json HTTP/1.1
Host: example.invalid
If-None-Match: "v1"
HTTP/1.1 304 Not Modified
ETag: "v1"
Cache-Control: max-age=60

These are illustrative message fragments, not a captured exchange. A 304 response has no representation body to parse as fresh JSON. The client combines the validation result with its stored representation and updates relevant metadata under the caching rules.

If a client has only the tag but has discarded the corresponding body, a not-modified response does not reconstruct that content. Validator and stored representation ownership therefore need to stay connected. A retrying API client should also recognize 304 as a conditional-read outcome, rather than treating every non-200 response as the same failure.

Freshness and validation answer different questions

Freshness determines whether a stored response can be reused without contacting the origin under the applicable policy. Validation asks whether a stored response is still suitable by checking a validator. The HTTP caching specification treats these as related but distinct parts of cache behavior.

An ETag by itself does not specify a freshness lifetime. Cache-Control and other response metadata influence reuse. Conversely, a response can have a freshness lifetime without providing an ETag. Inspect the complete caching policy rather than assuming one header solves every cache problem.

Concern Relevant idea What ETag alone does not do
Fresh reuse Freshness lifetime and cache directives Set a universal expiration time
Revalidation Compare the stored validator Restore a missing stored body
Authorization Verify the requesting client's access Grant access to a representation
Shared caching Correct cacheability and variation rules Make user-specific content safe to share
Updated content New valid representation validator Decide the application's editing policy

A validator should not be treated as a credential. Knowing a document's ETag is not permission to read or update it. Authorization and representation selection must remain correct even when a client supplies a matching conditional header.

If-Match can prevent a stale overwrite

For an update, the client can send the ETag of the version it read in If-Match. That condition uses strong comparison. If the current representation no longer matches, a failed precondition can produce 412 Precondition Failed, instead of overwriting a newer representation.

A matching conditional read reuses content, while an update using a stale tag fails its precondition.
Read revalidation and optimistic update protection use different conditions and comparison rules. Open full-size diagram.

The crucial operation is atomic: check the version and apply the write as one protected state transition. A server that checks the tag, releases its lock, and writes later can still lose an intervening update. The HTTP header expresses the condition; storage must enforce it at the update boundary.

A local model with a restricted input contract

This example accepts one valid quoted tag at a time. Real headers also support lists and wildcard forms under their defined grammar. The model deliberately omits those forms, authorization, content negotiation, request parsing, and a network server. Its purpose is to reproduce comparison and stale-update behavior.

from threading import Lock

class Document:
    def __init__(self, body):
        self.body = body
        self.version = 1
        self.lock = Lock()

    def tag(self):
        return f'"v{self.version}"'

    def get(self, if_none_match=None):
        with self.lock:
            current = self.tag()
            supplied = if_none_match
            if supplied is not None and supplied.startswith("W/"):
                supplied = supplied[2:]
            if supplied == current:
                return 304, current, None
            return 200, current, self.body

    def put(self, body, if_match):
        with self.lock:
            current = self.tag()
            if if_match != current or if_match.startswith("W/"):
                return 412, current
            self.body = body
            self.version += 1
            return 200, self.tag()

document = Document("original")
status, first_tag, body = document.get()
assert (status, first_tag, body) == (200, '"v1"', "original")
assert document.get(first_tag) == (304, first_tag, None)
assert document.get('W/"v1"') == (304, first_tag, None)
assert document.put("weak update", 'W/"v1"')[0] == 412
assert document.put("first edit", first_tag) == (200, '"v2"')
assert document.put("stale second edit", first_tag) == (412, '"v2"')
assert document.get(first_tag) == (200, '"v2"', "first edit")
print("Conditional read, weak comparison, and stale-write protection passed")

The lock spans both comparison and replacement. Sequential assertions demonstrate the state transitions; this is not a concurrency stress test or a persistence guarantee. The body contract here is an immutable string, so returning it does not expose a mutable object that can bypass the protected update.

The version increments even if an accepted write supplies the same text. That conservative invalidation is permissible for this example's model; it is not an optimization that detects unchanged bytes. Strong tags must remain valid for the representation under the real server's supported transformations.

Operation in the model Result Stored content afterward
Initial GET 200 with v1 and body original
GET matching v1 304 without body original
Update with weak v1 412 original
Update with strong v1 200 with v2 first edit
Another update with stale v1 412 first edit

Handle a failed precondition as a conflict

After a stale-write rejection, the client can retrieve the current representation and compare changes. Blindly retrying with the new tag can overwrite the other writer's work anyway. The application must decide whether to merge, ask the user to resolve a conflict, or abandon the update.

An ETag also does not automatically make retries safe for every state-changing operation. Method semantics, idempotency policy, request identity, and whether the earlier attempt succeeded can matter. Conditional updates solve a specific version-precondition problem; they do not replace a complete retry design.

For a real implementation, test matching and mismatching conditions, weak versus strong validators, multiple tag grammar, wildcard behavior, selected variants, missing-resource behavior, and atomic competing writes. Verify cache metadata under RFC 9111 rather than assuming the toy model covers it.

Sources and reproducibility

Primary references: RFC 9110 validators, conditional requests, and RFC 9111 caching. The executable model is local and makes no network requests. HTTP fragments are labeled illustrations; the code is explicitly not a standards-complete server.