Web Development

HTTP Preconditions for Safe Updates - Let Stale Editors Fail Instead of Overwriting New Work

HTTP Preconditions for Safe Updates - Let Stale Editors Fail Instead of Overwriting New Work

Two people open the same article in an editor. Both see revision 17. One fixes the introduction and saves revision 18. The other, still looking at revision 17, changes the conclusion and saves a few minutes later. If the second request simply replaces the stored content, the first person's work disappears without either editor seeing an error.

This is the lost update problem. It does not require a large distributed system; two browser tabs, an API client and an admin page, or a delayed mobile request are enough. A database transaction can keep one write internally consistent, but it does not by itself tell the server whether the client started from current data. HTTP already has a vocabulary for that missing question: validators and preconditions.

The client needs to say which version it edited

A request such as PUT /api/posts/42 says which resource to change and carries the proposed new representation. Without another condition, it can also imply, “apply this regardless of what happened since I last read it.” That may be correct for some operations, but it is dangerous for a replace-style editor.

RFC 9110 Section 13 defines a conditional request as one whose header fields state a precondition that is tested before the method is applied. For an update, the useful precondition is often:

Apply this change only if the resource still has the version I previously received.

The server communicates that version with a validator. HTTP defines modification dates and entity tags as common validator forms. An ETag is an opaque value chosen by the origin server; it is not required to be a content hash, a database timestamp, or a public revision number. The important property is whether it changes with the representation it validates.

A small GET and PUT exchange

Suppose the client first retrieves an article:

GET /api/posts/42 HTTP/1.1
Host: example.com

HTTP/1.1 200 OK
Content-Type: application/json
ETag: "post-17"

{"title":"A careful title","content":"..."}

The client should retain the entity tag with the data it displays. When the user saves, the client returns that exact validator in If-Match:

PUT /api/posts/42 HTTP/1.1
Host: example.com
Content-Type: application/json
If-Match: "post-17"

{"title":"A more careful title","content":"..."}

If revision 17 is still current, the server can apply the write and return a validator for the new representation. If another request has already produced revision 18, the condition is false. The server must not apply the requested method merely as though the condition were absent. The ordinary response for this flow is:

HTTP/1.1 412 Precondition Failed
Content-Type: application/json

{"error":"The post changed after you loaded it. Fetch the current version before saving."}

RFC 9110 defines 412 for request conditions that evaluate to false. The failure is useful information, not merely an inconvenience: it preserves both versions long enough for the application to offer a reload, a comparison, or a deliberate merge. Automatically resending the stale body without first resolving the difference would recreate the overwrite that the precondition prevented.

If-Match needs a strong validator

The details of the tag matter. Under the If-Match rules in RFC 9110, entity tags are compared with the strong comparison function. A weak tag such as W/"post-17" can describe representations that the server considers equivalent for some uses, but it cannot satisfy If-Match. The MDN reference summarizes the practical result: a weak entity tag never matches in this header.

A revision counter can produce a suitable strong tag only if its invariant is strong enough: every change visible in the selected representation must result in a different validator. If an article's JSON includes title, content, and publication state, but the counter changes only when the content field changes, the tag does not reliably identify that representation. A collision-resistant hash over the final representation is another option, although calculating it may be unnecessary when the application already has trustworthy revision control.

Dates are less attractive when exact change detection matters. RFC 9110 notes that clock resolution can make a modification time a weak validator when a resource might change more than once within that resolution. If-Unmodified-Since exists for servers that do not provide entity tags, but a well-defined strong ETag is usually easier to reason about for an application-controlled editor.

The storage check must be atomic too

Correct HTTP fields do not repair a check-then-write race inside the application. Imagine PHP reading version = 17, comparing the header, and then issuing an unconditional UPDATE. Another request could commit revision 18 between the read and the update. Both HTTP requests appeared to check correctly, yet the later SQL statement can still overwrite newer work.

A compact approach is to include the expected version in the write itself. This illustrative PDO fragment assumes authentication, authorization, JSON validation, resource-existence checks, and exception handling have already happened. It also deliberately accepts one application-generated ETag rather than implementing the complete list syntax allowed by HTTP:

<?php
$ifMatch = $_SERVER['HTTP_IF_MATCH'] ?? null;

if ($ifMatch === null) {
    http_response_code(428);
    echo json_encode(['error' => 'Send If-Match with the current post ETag.']);
    exit;
}

if (!preg_match('/^"post-(\d+)"$/D', $ifMatch, $match)) {
    http_response_code(400);
    echo json_encode(['error' => 'Invalid If-Match value.']);
    exit;
}

$expectedVersion = (int) $match[1];

$update = $pdo->prepare(
    'UPDATE posts
     SET title = :title,
         content = :content,
         version = version + 1
     WHERE id = :id AND version = :expected_version'
);
$update->execute([
    'title' => $validatedTitle,
    'content' => $validatedContent,
    'id' => $postId,
    'expected_version' => $expectedVersion,
]);

if ($update->rowCount() !== 1) {
    http_response_code(412);
    echo json_encode(['error' => 'The post changed; fetch the current version.']);
    exit;
}

$newVersion = $expectedVersion + 1;
header('ETag: "post-' . $newVersion . '"');
http_response_code(204);

The decisive operation is the conditional UPDATE. Only a row that still has the expected version can change, and the version advances in the same statement. Zero affected rows means this write did not win. A real endpoint still needs to distinguish a missing or inaccessible resource according to its API policy before reaching this fragment.

For a change spanning several tables, place all required writes in a database transaction and make the version check part of that transaction. HTTP supplies the client's precondition; the database must still preserve the storage invariant. These are cooperating layers, not competing alternatives.

412, 428, and 409 answer different questions

These three status codes are easy to blur:

  • 412 Precondition Failed: the client supplied a recognized precondition, but it evaluated to false against the current resource state.
  • 428 Precondition Required: the origin requires the request to be conditional, but the necessary condition was omitted.
  • 409 Conflict: the request conflicts with the current resource state in a broader way that is not more precisely expressed as a failed HTTP precondition.

RFC 6585 defines 428 specifically so an origin can require conditional requests, with lost-update avoidance as its typical example. The status is optional, so an API must document whether it enforces this contract. A server that quietly accepts an unconditional update has not gained protection merely because its GET responses include ETags.

409 remains useful for domain conflicts: perhaps a requested state transition is incompatible with the resource's current workflow state. When an actual If-Match condition fails, 412 communicates the protocol event more precisely.

Create only if nothing exists

The related condition If-None-Match: * expresses another useful intent: apply an unsafe method only when the target does not have a current representation. It can turn “create this name” into “create this name, but do not replace anything already there.” RFC 9110 explicitly describes this as protection when multiple clients might attempt the initial creation.

PUT /api/pages/about HTTP/1.1
Content-Type: application/json
If-None-Match: *

{"title":"About","content":"..."}

This is not merely theoretical protocol furniture. Amazon S3 documents conditional writes using both If-Match and If-None-Match. Its permissions, object-version behavior, and error details are product-specific, but the example shows the same HTTP vocabulary applied to real write operations.

What preconditions do not solve

A matching validator says that the relevant representation has not changed according to the server's validator policy. It does not say that the user is allowed to edit it, that the submitted fields are valid, or that the request is protected against cross-site request forgery. Authorization, input validation, and browser security controls remain necessary.

Preconditions also detect a stale write; they do not merge it. A text editor might show both revisions and ask the user to reconcile them. A settings form might reload and require the changes to be entered again. Character-by-character collaborative editing needs a different model. The right recovery depends on what can be safely combined.

Finally, an ETag policy must account for representation selection. If the same resource has materially different JSON, HTML, compressed, or language representations, the server needs validators that accurately identify the selected representation. An attractive-looking tag that sometimes survives an observable change is worse than an explicit, tested revision rule.

A focused test plan

  • GET a resource and confirm that its response carries a strong ETag.
  • Update it with the matching value in If-Match; confirm success and a new validator.
  • Repeat the old request with the stale tag; confirm 412 and verify that stored content did not change.
  • Omit If-Match from an endpoint that requires it; confirm the documented 428 response.
  • Run two writes with the same validator concurrently; confirm that at most one conditional storage update succeeds.
  • Change every field represented by the response and confirm that each observable change produces a different strong validator.
  • Test authorization and validation failures separately so precondition handling does not leak inaccessible resource state.

Conclusion

Lost updates are not solved by making the save button faster or by wrapping a single unconditional write in a transaction. The server needs to know which state the client edited, and that claim must remain connected to the storage write.

A strong ETag, If-Match, an atomic version check, and a clear 412 response form a small but meaningful contract: stale work fails visibly instead of silently erasing newer work. Requiring that contract with 428 can close the unconditional path. What happens after a conflict remains an application decision, but preserving the conflict is the first step toward resolving it honestly.

References