REST APIs in Depth · Correctness: Errors, Validation, Concurrency and Caching
Conditional Requests, ETags and Optimistic Concurrency
Prevent lost updates and save bandwidth with validators.
An ETag is a validator identifying a specific representation version. Clients send If-None-Match to revalidate cached data (server returns 304 Not Modified), and If-Match on writes so the server applies the update only if the resource hasn't changed (412 Precondition Failed otherwise). This is optimistic concurrency over HTTP — no locks, safe for long user edits. Some APIs require If-Match on updates (428 Precondition Required when missing).
Conditional request headers
| Header | Used with | Server response if condition fails |
|---|---|---|
| If-None-Match | GET/HEAD (cache revalidation) | 304 Not Modified |
| If-Match | PUT/PATCH/DELETE (concurrency) | 412 Precondition Failed |
| If-Modified-Since | GET (date-based revalidation) | 304 |
| (missing If-Match) | When required by policy | 428 Precondition Required |
@app.put("/documents/{doc_id}")
async def update_doc(doc_id: str, body: DocIn, if_match: str = Header(...), db=Depends(get_db)):
doc = await docs.get(db, doc_id) or raise_404()
if if_match != f'"{doc.version}"':
raise HTTPException(412, "Document changed; reload and retry")
doc = await docs.update(db, doc, body) # increments version
return JSONResponse(doc.dict(), headers={"ETag": f'"{doc.version}"'})
app.put("/documents/:id", async (req, res) => {
const ifMatch = req.get("If-Match");
if (!ifMatch) return res.status(428).json({ title: "If-Match required" });
const { rowCount, rows } = await pool.query(
"UPDATE documents SET body = $2, version = version + 1 WHERE id = $1 AND version = $3 RETURNING *",
[req.params.id, req.body.body, Number(ifMatch.replaceAll('"', ""))]);
if (rowCount === 0) return res.status(412).json({ title: "Precondition Failed" });
res.set("ETag", `"${rows[0].version}"`).json(rows[0]);
});
@PutMapping("/documents/{id}")
ResponseEntity<DocResponse> update(@PathVariable String id, @RequestHeader("If-Match") String ifMatch,
@RequestBody DocRequest body) {
Document doc = docs.findById(id).orElseThrow();
if (!ifMatch.equals("\"" + doc.getVersion() + "\"")) {
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
}
Document saved = docs.save(doc.apply(body)); // @Version also guards concurrent writers
return ResponseEntity.ok().eTag("\"" + saved.getVersion() + "\"").body(DocResponse.from(saved));
}
Common pitfalls
- Weak ETags (
W/) used for If-Match (strong comparison required for writes). - Generating ETags by hashing huge bodies on every request (use version columns).
- Last-write-wins updates that silently lose edits.
Key points
- ETag = representation version
- If-None-Match → 304; If-Match → 412
- Version columns make ETags cheap
Further reading: RFC 9110: Conditional requests
Practice questions
How does If-Match prevent the lost update problem?
Answer
The update only succeeds if the resource's current ETag equals the one the client read; if someone else changed it in between, the server returns 412 and the client must reload and merge.
What's the difference between strong and weak ETags?
Answer
Strong ETags change whenever the byte representation changes and are required for If-Match; weak ETags (W/"…") indicate semantic equivalence and are only suitable for cache revalidation.
Open in the app to mark lessons complete, take notes and track your progress.