| title |
aliases |
tags |
sources |
created |
updated |
| ETag / If-Match Optimistic Locking |
| etag-locking |
| optimistic-locking |
| if-match-patch |
| 412-precondition |
|
| fastapi |
| react |
| http |
| concurrency |
| optimistic-locking |
| api-design |
|
|
2026-04-29 |
2026-04-29 |
ETag / If-Match Optimistic Locking
Prevents two users from silently overwriting each other's edits without server-side locking. The backend generates an ETag from a content hash; the frontend sends it back on write; stale writes are rejected with 412.
Key Points
- Backend generates
ETag from content hash (e.g. md5(content) or document version)
- Frontend stores ETag in React state (not a ref) — so React Query invalidation triggers a re-fetch on conflict
- PATCH request includes
If-Match: {etag} header
- Backend checks: if ETag doesn't match current document version →
412 Precondition Failed
- Frontend
412 handler shows user-visible conflict message and re-fetches latest content
Details
Backend (FastAPI)
from hashlib import md5
from fastapi import Header, HTTPException
@router.get("/vtt/{segment_id}")
async def get_vtt(segment_id: str):
doc = await db.vtt_segments.find_one({"_id": segment_id})
content_hash = md5(doc["content"].encode()).hexdigest()
return JSONResponse(
content=doc,
headers={"ETag": f'"{content_hash}"'}
)
@router.patch("/vtt/{segment_id}")
async def update_vtt(
segment_id: str,
payload: VttUpdate,
if_match: str | None = Header(None, alias="If-Match"),
):
doc = await db.vtt_segments.find_one({"_id": segment_id})
current_hash = f'"{md5(doc["content"].encode()).hexdigest()}"'
if if_match and if_match != current_hash:
raise HTTPException(status_code=412, detail="Content modified by another user")
# ... proceed with update
Frontend (React + React Query)
// Store ETag in state, not ref — ensures re-render on update
const [etag, setEtag] = useState<string | null>(null);
// On fetch, capture the ETag
const { data } = useQuery(["vtt", segmentId], async () => {
const res = await fetch(`/api/vtt/${segmentId}`);
setEtag(res.headers.get("ETag"));
return res.json();
});
// On save, send If-Match header
const handleSave = async (content: string) => {
const res = await fetch(`/api/vtt/${segmentId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...(etag ? { "If-Match": etag } : {}),
},
body: JSON.stringify({ content }),
});
if (res.status === 412) {
alert("Someone else edited this segment. Refreshing to show latest version.");
queryClient.invalidateQueries(["vtt", segmentId]);
return;
}
};
Why State, Not Ref
If ETag is stored in a useRef, React Query invalidation won't trigger a re-render → the stale ETag persists → the next save attempt uses the old ETag again. Storing in useState ensures the component re-renders after invalidation and the fresh ETag is picked up.
Use Cases
- Collaborative editors (VTT subtitles, document editors)
- Any resource where two users might edit simultaneously (admin panels, CMS)
- Long-lived forms where network latency could cause version skew
Related Concepts
Sources