---
title: "ETag / If-Match Optimistic Locking"
aliases: [etag-locking, optimistic-locking, if-match-patch, 412-precondition]
tags: [fastapi, react, http, concurrency, optimistic-locking, api-design]
sources:
  - "daily/2026-04-29.md"
created: 2026-04-29
updated: 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)

```python
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)

```typescript
// 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

- [[wiki/concepts/double-submit-cookie-csrf|double-submit-cookie-csrf]] — another HTTP security header pattern for stateless APIs
- [[wiki/tech-patterns/fastapi-python-docker|fastapi-python-docker]] — FastAPI patterns

## Sources

- [[daily/2026-04-29.md]] — session 19:06, VTT editor concurrent edit protection
