Conditional Requests — Cache Contracts and Optimistic Locking
~12 min · semantics, conditional-requests, etag, if-match, 304
Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"Conditional requests are how HTTP avoids sending the same bytes twice and how clients edit shared resources without clobbering each other's work. Both rest on the same primitive: a validator that fingerprints the resource's current state."
Validators — Fingerprinting a Resource
Two response headers serve as validators: opaque tags the server attaches to a representation so clients can later ask "is what I have still current?"
ETag: "v17-abc123" — an opaque version tag. Format is implementation-defined; clients never parse it, just echo it back. Servers usually compute it from a hash of the body, a row version number, or an update timestamp.
Last-Modified: Sun, 25 May 2026 03:00:00 GMT — a timestamp. Lower-resolution (one-second granularity), but works when ETags aren't feasible.
ETag is preferred because it's exact (any change in the resource changes the tag) and survives second-level clock skew between caches.
The Conditional GET — Skip the Body When Nothing Changed
The flow that powers every CDN, every browser cache, every aggressive HTTP client:
First request: server returns 200 OK + body + ETag: "v17-abc". Client caches the body and the ETag.
Later: client wants the same URL. Sends If-None-Match: "v17-abc".
Server compares its current ETag to the one the client sent. Match? Return 304 Not Modified with NO body — client's cached copy is still good. No match? Return 200 OK + new body + new ETag.
The 304 response saves the body bytes — for a 500KB JSON list that hasn't changed, that's 500KB of network not sent. At CDN scale this is the difference between billable terabytes.
The Optimistic Update — Don't Clobber Concurrent Edits
Two users (or two browser tabs) open the same resource for editing. Both submit changes. Without coordination, the second write wins and the first user's edits are lost silently. The pessimistic fix would be locking; the REST fix is optimistic concurrency via If-Match:
Client GETs the resource, receives ETag: "v17-abc".
Client mutates locally, then PUTs back with If-Match: "v17-abc".
Server checks: is the resource's CURRENT ETag still "v17-abc"? Yes → apply the write, return new ETag. No (someone else updated since you read) → return 412 Precondition Failed; client must re-fetch, merge, and try again.
This is how GitHub's REST API handles concurrent issue edits, how Google Docs reconciles offline saves, and how cwkPippa's folder rename avoids race conditions when two tabs hit the same conversation.
The validator is a tiny piece of state that lets the protocol move large pieces of state safely. ETag is ~20 bytes; the body it represents may be megabytes. The conditional headers turn one round-trip-with-body into one round-trip-without-body when nothing changed, and turn a silent clobber into a deliberate retry-on-conflict. Both wins come from the same primitive.
ETag Strength — Strong vs Weak
ETags come in two flavors:
Strong:ETag: "abc123" — byte-for-byte identical representation. Two strong ETags match iff every byte of the body matches.
Weak:ETag: W/"abc123" — semantically equivalent. The server is saying "these representations are different bytes but functionally the same" (e.g., the same JSON re-serialized with different key order).
Most APIs use strong ETags. Weak ETags exist mainly for proxy caches that want to serve a slightly stale representation.
cwkPippa's Reality
cwkPippa's GET /api/conversations/{id} doesn't currently emit ETags — the healing layer rebuilds responses from JSONL on every GET, so the response shape can shift subtly between calls. Adding ETags would require deterministic serialization and would let the WebUI's React Query layer skip body parsing on unchanged conversations. It's on the "someday" list; the day a slow Tailscale connection makes one user complain about repeated 100KB downloads is the day it gets added.
Code
Conditional GET — the 304 dance·bash
# First request — receive ETag
curl -i https://api.example.com/users/42
# HTTP/1.1 200 OK
# ETag: "v17-abc123"
# Content-Type: application/json
# Content-Length: 142
# {"id":42,"name":"Pippa","updated_at":"2026-05-25T03:46:50Z"}
# Second request with If-None-Match — server says 'unchanged, here's nothing'
curl -i https://api.example.com/users/42 \
-H 'If-None-Match: "v17-abc123"'
# HTTP/1.1 304 Not Modified
# ETag: "v17-abc123"
# (no body — you keep using your cached version)
# After someone else updates the user, server returns 200 with new ETag
curl -i https://api.example.com/users/42 \
-H 'If-None-Match: "v17-abc123"'
# HTTP/1.1 200 OK
# ETag: "v18-def456"
# Content-Length: 156
# {"id":42,"name":"Pippa","updated_at":"2026-05-25T03:50:00Z","role":"daughter"}
Optimistic update — If-Match + 412 on conflict·bash
# Optimistic update — only write if my version is still current
# 1) GET to learn the current ETag
ETAG=$(curl -s -i https://api.example.com/users/42 | grep -i '^etag:' | cut -d' ' -f2- | tr -d '\r')
echo "current ETag: $ETAG"
# 2) PUT with If-Match — succeeds if ETag still matches
curl -i -X PUT https://api.example.com/users/42 \
-H "If-Match: $ETAG" \
-H 'Content-Type: application/json' \
-d '{"name":"Pippa","role":"big sister"}'
# HTTP/1.1 200 OK
# ETag: "v18-def456" ← server returns the NEW etag
# 3) PUT with a STALE If-Match — server rejects
curl -i -X PUT https://api.example.com/users/42 \
-H 'If-Match: "v17-stale"' \
-H 'Content-Type: application/json' \
-d '{"name":"Pippa","role":"big sister"}'
# HTTP/1.1 412 Precondition Failed
# {"error":"resource was modified; please re-fetch"}
FastAPI — full ETag + conditional handling·python
# FastAPI — emit ETag on GET, check If-Match on PUT
import hashlib, json
from fastapi import FastAPI, Header, HTTPException, Response, status
app = FastAPI()
_store: dict[int, dict] = {42: {'id': 42, 'name': 'Pippa', 'version': 17}}
def compute_etag(resource: dict) -> str:
"""Cheap ETag: hash of JSON-serialized state."""
body = json.dumps(resource, sort_keys=True).encode()
return f'"{hashlib.sha256(body).hexdigest()[:12]}"'
@app.get('/users/{uid}')
async def read_user(uid: int, response: Response,
if_none_match: str | None = Header(None, alias='If-None-Match')):
user = _store[uid]
etag = compute_etag(user)
response.headers['ETag'] = etag
response.headers['Vary'] = 'Accept-Encoding'
if if_none_match == etag:
# Client's cache is current — return 304 with no body
response.status_code = status.HTTP_304_NOT_MODIFIED
return None
return user
@app.put('/users/{uid}')
async def update_user(uid: int, payload: dict, response: Response,
if_match: str | None = Header(None, alias='If-Match')):
user = _store[uid]
current_etag = compute_etag(user)
if if_match and if_match != current_etag:
# Stale ETag — reject
raise HTTPException(status.HTTP_412_PRECONDITION_FAILED,
detail='resource was modified; re-fetch and retry')
# Apply the update
_store[uid] = {**user, **payload, 'version': user['version'] + 1}
response.headers['ETag'] = compute_etag(_store[uid])
return _store[uid]
Extend the FastAPI server from the previous lesson with full conditional support: emit ETag on every GET, return 304 when If-None-Match matches, return 412 when If-Match doesn't match on PUT/PATCH/DELETE. Then write a Python client that loops: GET → store ETag → PUT-with-If-Match. Spawn two clients running the loop concurrently against the same resource and watch them fight — one will succeed (200) and one will lose (412), exactly as designed. The losing client should re-fetch and retry.
Hint
ETag computation: a SHA256 of the JSON-serialized resource (sort_keys=True for determinism). Both clients reading the same v17 will compute the same ETag. The first PUT succeeds and bumps the version to v18; the second PUT's If-Match is now stale and gets 412. The losing client's recovery loop: catch 412 → re-GET → re-apply changes → re-PUT with the new ETag. This pattern is how every collaborative editor avoids silent clobbers.
Progress
Progress is local-only — sign in to sync across devices.