HTTP 428 is Precondition Required: the origin server requires this request to be conditional (RFC 6585 section 3). Its job is preventing lost updates, where a client GETs a resource, changes it and PUTs it back while someone else changed it in between. By refusing writes that don’t say which version they’re based on, the server makes sure every client edits the current copy. The response should explain how to resubmit, and caches must not store it.
The exchange
PUT /docs/42 HTTP/1.1
Content-Type: application/json
{"title": "Q3 plan"}
HTTP/1.1 428 Precondition Required
Content-Type: application/problem+json
{"title": "Precondition required", "status": 428, "detail": "Send If-Match with the ETag from your last GET."}
The sequence the server wants:
GET /docs/42 HTTP/1.1
HTTP/1.1 200 OK
ETag: "v7"
PUT /docs/42 HTTP/1.1
If-Match: "v7"
Content-Type: application/json
{"title": "Q3 plan"}
HTTP/1.1 200 OK
ETag: "v8"
If someone else saved in between, the conditional PUT gets 412 Precondition Failed instead, and the client reloads before trying again.
428 or 412
no If-Match or If-Unmodified-Since at all 428 Precondition Required
If-Match sent, but the ETag has moved on 412 Precondition Failed
Gotchas
- Prefer ETags to dates. RFC 6585 doesn’t say which precondition to require, but
If-Unmodified-Sincehas one-second resolution, so two saves in the same second look alike;If-Matchwith an ETag doesn’t have that problem. - Strong ETags only.
If-Matchuses strong comparison, so a weak ETag likeW/"v7"never matches and every write fails with 412. - Make the check and the write one step, such as
UPDATE docs SET ... WHERE id = 42 AND version = 7, or two clients can pass the check at once. - Older clients that don’t know 428 treat it as a plain 400, as RFC 9110 says to do with any unknown 4xx. Say in the body what to send.
- Caching. RFC 6585 forbids caches from storing a 428.
In your stack
app.put('/docs/:id', async (req, res) => { // Express
const ifMatch = req.get('If-Match');
if (!ifMatch) {
return res.status(428).json({ detail: 'Send If-Match with the ETag from your last GET.' });
}
const version = Number(ifMatch.replace(/^"v|"$/g, '')); // a list or * needs a real parser
const saved = await docs.saveIfVersion(req.params.id, req.body, version);
if (!saved) {
return res.status(412).json({ detail: 'The document changed; fetch it again.' });
}
res.set('ETag', `"v${saved.version}"`).json(saved);
});
Here saveIfVersion stands for a single conditional update that returns nothing when the stored version has moved on.
Search 412 428 in the tool below to compare the two.