obsidian/wiki/concepts/etag-optimistic-locking.md
2026-04-29 21:58:01 +01:00

3.5 KiB

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
daily/2026-04-29.md
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

Sources