DEV Community

Jeff
Jeff

Posted on Originally published at powerduck.com

The 412 Precondition Failed: A Stale If-Match Header Rejected Your Update

Quick answer

A 412 Precondition Failed is not a generic server error. It means the client sent a conditional header such as If-Match or If-Unmodified-Since, and the resource no longer matches the version the client based its edit on. In practice this almost always points to a stale edit form: the user opened a record, someone else changed it, and the client submitted an update against an ETag that is now out of date. The fix is not to strip the header and retry, because that turns an optimistic concurrency guard into a blind last-write-wins overwrite. Read the current resource, reconcile the change, and resubmit with the fresh validator.

What the failing request looks like

The client first reads the record and the server returns an ETag, usually an opaque quoted value.

The client holds that ETag and sends it back in If-Match when saving. If another writer has changed the invoice in between, the stored ETag no longer matches and the server refuses the edit.

The important detail is that the server is doing exactly its job. It is comparing the validator the client presented against the current state and rejecting an edit that would silently clobber a change the client never saw.

Why teams misdiagnose it

A 412 is less common than a 409 or a 400, so it gets treated as a mysterious platform response. Two mistakes follow. The first is to remove the conditional header so the request "always succeeds," which defeats optimistic locking and lets the late writer overwrite the earlier one without any warning. The second is to retry the identical request on a loop, which keeps failing because the same stale ETag is presented each time. Neither addresses the root cause, which is that the client's view of the resource is behind the server's.

Reaction to a 412 What it actually does When acceptable
Strip If-Match and retry Blind overwrite; last write wins Almost never for shared records
Retry the same request unchanged Re-sends a stale validator; keeps failing Never
Re-GET, reconcile, resubmit with new ETag Preserves both writers' changes Default correct behavior
Surface a conflict to the user Lets a human decide when edits collide When both changed the same field

A debugging path that finds the real cause

Start by confirming it is genuinely a validator mismatch rather than a malformed header.

  1. Capture the ETag from the original GET and the exact value sent in If-Match. Watch for quoting and weak-validator bugs: a strong ETag is a quoted string, and a weak one is prefixed with W/. Reconstructing the value without the quotes or sending the raw prefix wrong will fail every time.
  2. Re-GET the resource and compare its current ETag with the one you sent. If they differ, another request changed it. If they are identical but you still get 412, suspect a header formatting or proxy normalization problem.
  3. Check whether an intermediary is stripping or rewriting the ETag. A misconfigured cache or proxy that drops the quoted value on the response leaves the client with no valid validator to return.
  4. Look at the server's write path. Confirm the comparison is against the current persisted version and that the ETag changes whenever the underlying fields used in the edit change.

For a quick command-line check, replay the sequence with two terminals and watch the validator change between the two writes. That makes the concurrency cause visible instead of abstract.

Handling the conflict in the client

Once the 412 is understood, the client should make the recovery smooth rather than dumping an error on the user. Fetch the current record, diff the local edit against the fresh server state, and resubmit with the new ETag when the changes touch different fields. When both writers edited the same field, present the two values and let the user choose, because no automatic rule can decide which amount is correct. The key is to keep the old and new ETags in the request state so the second PATCH carries the validator the server currently expects.

async function saveInvoice(id, patch, expectedETag) { const res = await fetch(/api/invoices/${id}, { method: "PATCH", headers: { "If-Match": expectedETag, "Content-Type": "application/json" }, body: JSON.stringify(patch), }); if (res.status === 412) { const fresh = await (await fetch(/api/invoices/${id})).json(); return { conflict: true, current: fresh }; } return res; }

FAQ

What is the difference between 412 and 409?

A 412 is specifically about a failed conditional header: the validator in If-Match or If-Unmodified-Since does not match. A 409 is a broader conflict with the current state, such as a duplicate key or an illegal status transition, and does not require a precondition header. If the failure is tied to an ETag you sent, 412 is the precise answer.

Can I use If-Unmodified-Since instead of an ETag?

You can, but it is a date-based validator with one-second granularity and is easier to get wrong across clocks and time zones. An ETag is content-based and generally more reliable for optimistic concurrency. Whichever you use, the 412 recovery flow is the same: re-read, reconcile, resubmit with the current validator.

Why does my weak ETag fail on some endpoints?

Weak validators (the W/ prefix) are meant for comparison but are not allowed for some strong preconditions, and servers differ in how strictly they enforce this. If a resource only ever produces weak ETags, check the server's documented behavior and avoid using them for writes that require a strong guarantee.

Should the server include the current ETag in the 412 body?

It is helpful, but the client should not treat it as permission to blindly overwrite. Returning the current ETag or a link to the latest version lets the client re-fetch efficiently, but the client still needs to reconcile its edit against the new state before resubmitting.

This is part of the The API Debugging Guide — start there for a structured way to diagnose HTTP failures instead of guessing at the status code.

Top comments (0)