Conditional Requests: ETags, Last-Modified, and Precondition Logic

LESSON

HTTP Protocol and Content Delivery

007 25 min intermediate

Conditional Requests: ETags, Last-Modified, and Precondition Logic

The core idea: Conditional requests let a client attach evidence about the version it has, so the server can avoid sending unchanged representations or reject writes that would overwrite newer state.

Core Insight

Imagine the same checkout system again. A support agent opens GET /payment-attempts/pay_901 in a dashboard while the mobile app is also polling that payment attempt. The dashboard shows a customer note, the agent edits it, and just before saving, an automated fraud review updates the payment attempt with a new risk status. If the dashboard sends a plain PATCH, it may overwrite a representation that is no longer the one the agent saw.

There is a second, quieter pressure. The mobile app polls the same status resource every few seconds after a 202 Accepted. Most polls return the same state: still pending. Downloading the full JSON every time wastes bandwidth and origin capacity. The client does not need the body if the representation has not changed; it needs a cheap way to ask, "Is my copy still current?"

HTTP uses the same family of ideas for both problems. The server gives the client a validator, usually an ETag or a Last-Modified timestamp. Later, the client sends a conditional request: "send the body only if this validator is stale" or "apply this write only if the current validator still matches." The server checks the condition before choosing the response.

The common misconception is that validators are only cache features. They are also concurrency evidence. If-None-Match helps a client revalidate a cached representation without paying for a body. If-Match helps a client avoid lost updates by proving which version it intends to modify. The trade-off is lower bandwidth and safer writes versus validator discipline: the system gains precise boundary behavior, but only if validators are generated consistently and compared under the right rules.

The Pieces You Can Inspect

Start with a normal response:

HTTP/1.1 200 OK
Content-Type: application/vnd.shop.payment.v2+json; charset=utf-8
Cache-Control: private, max-age=0, must-revalidate
ETag: "payment-pay_901-v17"
Last-Modified: Thu, 18 Jun 2026 10:15:30 GMT

{
  "id": "pay_901",
  "status": "pending",
  "risk_review": "required",
  "customer_note": "Please email the receipt"
}

The body is one representation of the payment attempt. The ETag is an opaque validator for that selected representation or version. Opaque means the client should not parse business meaning out of "payment-pay_901-v17". It should store and send it back exactly as a token. The Last-Modified timestamp is a weaker kind of validator based on time.

The visible pieces are:

The mechanism is not complicated, but the direction matters. The server creates validators. The client repeats them as evidence. The server compares that evidence with the current state before sending bytes or applying a mutation.

Revalidating a Read With If-None-Match

Suppose the mobile app already has version 17 of the payment attempt. It wants to check whether anything changed:

GET /payment-attempts/pay_901 HTTP/1.1
Accept: application/vnd.shop.payment.v2+json
If-None-Match: "payment-pay_901-v17"

The server loads or computes the current validator for the selected representation. If the current validator is still "payment-pay_901-v17", the body would be identical for the purpose of this request. The server can return:

HTTP/1.1 304 Not Modified
ETag: "payment-pay_901-v17"
Cache-Control: private, max-age=0, must-revalidate

304 does not mean "nothing exists." It means "your stored representation is still valid; reuse it." The client keeps its cached body and updates any response metadata that came with the 304. The savings are real because the server did not send the JSON body again.

If the fraud review changed the payment attempt to version 18, the condition fails in the other direction: the client's copy is no longer current. The server returns the full representation:

HTTP/1.1 200 OK
Content-Type: application/vnd.shop.payment.v2+json; charset=utf-8
ETag: "payment-pay_901-v18"

{
  "id": "pay_901",
  "status": "pending",
  "risk_review": "cleared",
  "customer_note": "Please email the receipt"
}

This is the read-side loop:

client stores body + ETag -> client sends If-None-Match -> server compares current ETag
  -> same: 304, no body
  -> different: 200, new body + new ETag

The status code, validator, and representation metadata work together. If the previous lesson taught the client what bytes it received, this mechanism teaches it whether those bytes are still fresh enough to reuse.

Protecting a Write With If-Match

Now return to the support dashboard. The agent loaded version 17 and edited the customer note. Without a precondition, the dashboard might send:

PATCH /payment-attempts/pay_901 HTTP/1.1
Content-Type: application/json

{"customer_note":"Customer asked for receipt by email"}

If version 18 was created after the agent opened the screen, this write may be based on stale context. Perhaps the field being changed is independent, or perhaps the new risk status should change what the agent is allowed to write. A plain request gives the server no evidence about what the agent saw.

With If-Match, the client says, "apply this only if the current validator is the one I edited":

PATCH /payment-attempts/pay_901 HTTP/1.1
Content-Type: application/json
If-Match: "payment-pay_901-v17"

{"customer_note":"Customer asked for receipt by email"}

If the current validator is still version 17, the server may apply the patch and return the new validator:

HTTP/1.1 200 OK
Content-Type: application/vnd.shop.payment.v2+json; charset=utf-8
ETag: "payment-pay_901-v18"

{
  "id": "pay_901",
  "status": "pending",
  "risk_review": "required",
  "customer_note": "Customer asked for receipt by email"
}

If the current validator has already moved to version 18, the server rejects the write:

HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
ETag: "payment-pay_901-v18"

{
  "type": "https://api.shop.test/problems/stale-write",
  "title": "The payment attempt changed before this update was applied",
  "status": 412,
  "current": "/payment-attempts/pay_901"
}

That 412 is not a generic conflict. It specifically says the condition attached to the request evaluated to false. The client can fetch the current representation, merge or discard the local edit, and ask the user to retry with fresh context. If the server requires such preconditions for a risky resource and the client omits them, 428 Precondition Required can make that policy explicit.

ETag, Last-Modified, Strong, and Weak

ETag is usually the best validator for APIs because it can represent an exact version, hash, revision number, or internal change token without exposing implementation details. The client treats it as an opaque string. The server controls what it means.

Last-Modified is easier to generate, but it is coarser. HTTP dates have one-second granularity, and clocks can be awkward in distributed systems. If two updates happen in the same second, a time-based validator may not separate them. That may be acceptable for revalidating public documentation pages. It is usually too weak for protecting important writes.

ETags can be strong or weak. A strong ETag, written like "payment-pay_901-v18", is suitable when the server means that the selected representation is exactly the version being validated. A weak ETag, written like W/"payment-pay_901-semantic-18", says the representations are semantically equivalent enough for some cache validation, but not necessarily byte-for-byte identical. Weak validators can be useful for read revalidation, but they are the wrong tool for precise lost-update protection. For If-Match on writes, prefer a strong validator or an explicit resource version.

There is one more subtlety from content negotiation: know what your validator tags. If /payment-attempts/pay_901 can return v1 JSON, v2 JSON, and CSV, each representation may need its own ETag. A v2 JSON ETag should not accidentally validate a v1 JSON body unless the server has deliberately designed the validator as a resource-level version and knows how to compare it safely. Representation metadata and validators have to agree.

For many backend APIs, the cleanest design is to keep two ideas separate internally. Store a resource version such as payment_attempts.version = 18, then derive representation validators from that version plus the representation dimensions that matter: media type, language, and sometimes encoding. The client still sees only an opaque ETag. The implementation, however, has a clear rule for why the tag changes. That rule is what makes validators debuggable during incidents.

The rule should be written down near the API contract. If the ETag changes after a harmless serialization refactor, caches lose efficiency. If it fails to change after a meaningful state transition, clients reuse stale data or overwrite newer state. A validator policy is therefore part of the data model, not just a web framework feature.

Worked Path: Poll, Edit, Reject, Refresh

Trace the whole flow with intermediate states:

t0: payment attempt stored as version 17
t1: dashboard GET returns body v17 + ETag "payment-pay_901-v17"
t2: mobile poll sends If-None-Match "payment-pay_901-v17"
t3: server sees no change and returns 304
t4: fraud review changes risk_review and creates version 18
t5: dashboard PATCH sends If-Match "payment-pay_901-v17"
t6: server compares current v18 with expected v17
t7: server returns 412 Precondition Failed with current ETag
t8: dashboard refetches, shows the newer state, and lets the agent reapply the note

Under the naive approach, t5 overwrites based on stale state. With conditional requests, the stale write becomes visible at the boundary. No distributed lock was needed. The server simply refused to apply a mutation whose evidence no longer matched.

The read path also improved. The mobile client can poll without downloading the same body every time. But the server still controls correctness. If the representation changes, the validator changes, and the client receives the new body.

Failure Modes to Review

Using one ETag for several incompatible variants. If JSON v1, JSON v2, and CSV share a validator accidentally, caches and clients may believe the wrong representation is fresh. Include representation dimensions in the validator or design a clear resource-level version strategy.

Protecting writes with Last-Modified alone. Time-based preconditions can miss rapid updates or suffer from clock assumptions. They are better than no evidence for some resources, but important write protection should use strong validators or domain version numbers.

Generating ETags from unstable serialization. If field order, whitespace, compression, or nondeterministic values change on every response, clients will never get useful 304 responses. Validators should change when meaningful representation state changes, not because serialization noise changed.

Treating 412 as a server error. A failed precondition is expected control flow. It should not page the server team by itself. The operational signal to watch is the rate and distribution of 412: a spike may reveal a hot resource, a UI merge problem, or clients editing stale screens for too long.

Close the lesson and design a conditional update for one endpoint you know. What validator does the GET return? Is it strong enough for If-Match? What response does the server send when the validator is stale? What metric would show whether stale writes are common?

Connections

Content negotiation decides which representation the client received. Conditional requests decide whether that representation is still current or safe to modify. The two ideas meet through validators: an ETag must make sense for the representation and metadata that produced it.

The next lesson broadens this into HTTP caching. ETag, Last-Modified, 304, and Vary are not enough by themselves; caches also need freshness lifetimes, shared-cache rules, and privacy boundaries before they can reuse responses safely.

Resources

Key Takeaways

PREVIOUS Content Negotiation, Media Types, and Representation Metadata NEXT HTTP Caching: Freshness, Validation, and Shared Caches