Designing API Conditional Requests: If-Match, If-None-Match, and the Patterns That Enable Optimistic Concurrency
Conditional HTTP requests are one of the underused features that make APIs survive concurrent edits and unnecessary transfers. If-Match prevents lost updates. If-None-Match saves bandwidth. ETags carry the contract. The patterns are simple, well-specified, and consistently absent from APIs t...
HTTP has had conditional request semantics since RFC 2068 in 1997. The mechanism is small and well-specified: a server sets an ETag header on a response, and clients can send If-Match or If-None-Match headers on subsequent requests to make those requests conditional on the resource state matching the ETag. Servers respond with the normal response if the condition is met, 304 Not Modified (for If-None-Match GETs that match), or 412 Precondition Failed (for If-Match writes that do not match). The patterns this enables (lost-update prevention via optimistic concurrency on writes, bandwidth saving on cached GETs) are textbook HTTP. They are also consistently absent from APIs that should use them, which produces bugs and bandwidth that the protocol was designed to prevent.
What conditional requests do
Conditional requests give the server a way to refuse to do work if the resource state has changed since the client last saw it. For GET, this means returning 304 instead of the resource body when the client's cached copy is still current. For PUT/PATCH/DELETE, this means returning 412 instead of writing when the resource has been modified by someone else since the client read it.
The two header pairs are If-None-Match (used on GET, succeeds if the ETag does not match, normally to enable cache validation) and If-Match (used on writes, succeeds if the ETag does match, normally to prevent lost updates). The server-side semantics are mechanical: compute the current ETag, compare with the client's, return the conditional status code or proceed with the request. Most HTTP frameworks have middleware that handles the comparison once the application provides the ETag.
The lost-update problem If-Match prevents
The canonical lost-update bug works like this. Two clients each GET a resource, getting version A. Both clients modify their local copy and PUT it back. The second PUT overwrites the first, and the first client's change is silently lost. The clients never see an error; the server happily applies both writes; the data is wrong.
If-Match prevents this by making the second PUT fail with 412. Both clients GET version A. Both modify locally. Client one PUTs with If-Match: "etag-A", succeeds, and the server now has version B. Client two PUTs with If-Match: "etag-A", but the current ETag is "etag-B", so the server returns 412. Client two has to GET the current state, reapply its change to the new base, and retry. The conflict is detected and surfaced to the client, which can resolve it intelligently or surface it to a human.
This is optimistic concurrency control via HTTP, structurally similar to optimistic concurrency with version columns in databases but implemented at the API layer. The trade-off is the same: cheap when conflicts are rare, expensive when conflicts are common (because the client has to redo work). For B2B SaaS APIs where the same resource is rarely edited simultaneously by multiple sessions, the trade-off is almost always favorable.
The cache-validation use case If-None-Match enables
A client that has cached a resource can ask the server "is the current version different from etag-X" via GET with If-None-Match: "etag-X". If the resource has not changed, the server returns 304 Not Modified with no body and no content-related headers. The client uses its cached copy. The wire saved is the response body, which for large resources is significant.
This is the mechanism browsers use for resource caching, and it is equally useful for API clients that have local caches. A dashboard polling for resource updates can poll with If-None-Match: previous-etag and get 304 responses for unchanged resources, sending only the small headers rather than the full response body. For resources that change rarely (account settings, schema metadata, configuration), the bandwidth saving can be order-of-magnitude.
The ETag format question
The ETag is an opaque string the server sets and the client echoes back. The server controls what goes into it. Two common patterns are weak ETags (a hash of resource-relevant content, prefixed with W/) and strong ETags (a hash or version number that uniquely identifies the byte-exact representation).
For most API use cases, weak ETags derived from a hash of the resource state are the right default. A hash of the JSON serialization of the resource state, possibly with relevant headers, identifies the resource version uniquely enough for the conditional request use case. The format we use across our four products is W/"sha256-prefix" where sha256-prefix is the first 16 hex characters of a SHA-256 hash of the resource representation.
An alternative is to use a monotonically-increasing version number stored in the database, formatted as a quoted string. This is simpler and indexable, and it survives serialization changes that would change a content hash. The trade-off is that you need a version column on the resource, which most application schemas should have anyway for optimistic concurrency control at the database layer.
The implementation surface
A minimum-viable implementation requires: a way to compute the ETag for each resource (hash function or version column), middleware that sets the ETag header on successful GET responses, middleware that handles If-None-Match on GET by returning 304 when the ETag matches, middleware that handles If-Match on writes by returning 412 when the ETag does not match, and documentation telling customers how to use the feature.
The implementation costs are small once the resource serialization is stable. The main pitfall is forgetting to invalidate cached ETags when the resource changes outside the normal write path (background jobs, cascading deletes, schema migrations that change serialization). Most teams handle this by deriving the ETag from a database column or a deterministic hash of the canonical serialized form, so any state change automatically produces a new ETag.
Three patterns that fail
First, mismatched semantics where the API returns 200 on conditional GETs that should return 304. This wastes the bandwidth the conditional was meant to save and is usually a middleware misconfiguration where the framework computes the ETag but does not check the request header.
Second, weak ETags treated as strong, where the server computes a content hash but returns the ETag without the W/ prefix. The semantic difference matters for proxies and range requests: a strong ETag promises byte-exact equivalence, and a hash-derived ETag generally does not guarantee that. Use W/ for hash-derived ETags.
Third, missing If-Match support on writes, where the server sets ETags on GET responses but ignores If-Match on PUT/PATCH/DELETE. This is half-implementing the protocol: clients can validate cache freshness but cannot prevent lost updates. The If-Match support is the higher-leverage half because it prevents data-loss bugs, not just bandwidth waste.
Our use
Our four products (DocuMint for PDF invoice generation, CronPing for cron job monitoring, FlagBit for feature flags, and WebhookVault for webhook debugging) all set ETag headers on resource GET endpoints and honor If-None-Match for cache validation. FlagBit additionally honors If-Match on flag-update endpoints because flag rules are the resource most likely to be edited by multiple sessions simultaneously (a deployment dashboard plus a CLI plus the web UI), and lost-update bugs there produce silent rule corruption that customers would only notice when behavior diverged from expectation.
Deeper observation
Conditional requests are a feature HTTP has had for nearly thirty years and most APIs do not use. The cost of adding them is small once the resource serialization is stable. The benefit (lost-update prevention plus bandwidth saving on polled resources) is real. The reason APIs do not use them is mostly that the patterns are not visible in the schoolroom HTTP curriculum and most framework defaults do not implement them. Reaching for ETags before reaching for application-layer version columns or custom concurrency tokens is one of the rare cases where the standard HTTP machinery actually fits the application problem, and the resulting API is both simpler and more interoperable with HTTP-aware tooling than custom solutions.