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.

AspectHTTP 428 — Precondition RequiredHTTP 412 — Precondition Failed
RFCRFC 6585, Section 3RFC 9110, Section 15.5.13
TriggerNo If-Match or If-Unmodified-Since header sentConditional 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 fixGET the resource first, include ETag in your next writeGET the resource again, get new ETag, decide to merge or overwrite
Lost update preventionEnforces the precondition requirementCatches the actual concurrent modification
CacheableNoNo

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

Related comparisons