428 vs 412: Precondition Required vs Precondition Failed
428 means you forgot to send a conditional header. 412 means you sent one, but the condition was not satisfied because the resource changed.
| Aspect | HTTP 428 — Precondition Required | HTTP 412 — Precondition Failed |
|---|---|---|
| RFC | RFC 6585, Section 3 | RFC 9110, Section 15.5.13 |
| Trigger | No If-Match or If-Unmodified-Since header sent | Conditional header sent but condition not met |
| What the server is saying | “You must include an ETag check on this request” | “Your ETag is stale — the resource changed” |
| How to fix | GET the resource first, include ETag in your next write | GET the resource again, get new ETag, decide to merge or overwrite |
| Lost update prevention | Enforces the precondition requirement | Catches the actual concurrent modification |
| Cacheable | No | No |
The Sequence in Practice
An API that fully implements optimistic locking uses both codes together. When a new client integration tries to PUT without an ETag, it gets 428 and learns it must follow the read-then-write pattern. When an experienced client follows the pattern but another request updates the resource in the interim, it gets 412 and learns to retry with a fresh ETag.
# Step 1: GET the resource
GET /api/posts/77 HTTP/1.1
HTTP/1.1 200 OK
ETag: "v5-d4f8"
Content-Type: application/json
{"title": "Draft Post", "content": "..."}
# Step 2: PUT with If-Match
PUT /api/posts/77 HTTP/1.1
If-Match: "v5-d4f8"
Content-Type: application/json
{"title": "Published Post", "content": "..."}
# If another client updated the post in the meantime:
HTTP/1.1 412 Precondition Failed
ETag: "v6-a2c1"
# If we had tried without If-Match entirely:
HTTP/1.1 428 Precondition Required
Server Implementation
app.put('/api/posts/:id', async (req, res) => {
const post = await Post.findById(req.params.id);
if (!post) return res.sendStatus(404);
const currentEtag = `"v${post.version}-${post.updatedAt.getTime()}"`;
// 428: no If-Match sent at all
if (!req.headers['if-match']) {
return res.status(428).json({
error: 'precondition_required',
message: 'Include If-Match with the current ETag',
currentEtag
});
}
// 412: If-Match sent but stale
if (req.headers['if-match'] !== currentEtag) {
return res.status(412).set('ETag', currentEtag).json({
error: 'precondition_failed',
message: 'Resource was modified by another request',
currentEtag
});
}
// Update the resource
const updated = await post.update(req.body);
res.json(updated);
});
FAQ
When should I include the current ETag in a 412 response?
Always. The 412 response should include the current ETag so the client can immediately proceed to merge its changes against the new version without needing an additional GET request. This reduces round trips and improves the user experience in collaborative editing scenarios.
Does 428 apply to DELETE requests too?
Yes. A DELETE that could conflict with concurrent modifications should also require a conditional header. Deleting a resource that another client has already deleted or substantially modified may be a problem depending on your application’s semantics.
Is 409 Conflict ever better than 412 for concurrency?
409 is more appropriate for state conflicts that are not about ETag freshness — for example, trying to delete a resource that has dependent children. 412 is specifically for conditional header failures. Use 409 when the conflict is about business logic, 412 when it is about stale ETags.
Full Guides
HTTP 428 Precondition Required — full guide · All comparisons