hard Dispatch & Assignment: the double-booking race
Mechanism: a claim on a shared resource must be atomic
Matching one scarce resource — a driver, a theater seat, the last unit of inventory — to competing requesters is only safe if the claim (read “is it free?”, then write “mine now”) happens as one indivisible step. The instant a system reads availability and writes the assignment as two separate operations, two concurrent requesters can both read “free” before either write lands — and both win.
This is the exact same read-modify-write race as counter++ under concurrent threads, just wearing a business costume: instead of a lost increment you get a double-booked driver or an oversold seat.
Traced: two riders race driver D42
Two nearby ride requests both dispatch against the same idle driver within milliseconds of each other:
| step | Dispatcher A (Rider R1) | Dispatcher B (Rider R2) |
|---|---|---|
| 1 | SELECT status WHERE id=D42 → available | |
| 2 | SELECT status WHERE id=D42 → available | |
| 3 | UPDATE ... SET status='assigned', rider='R1' | |
| 4 | UPDATE ... SET status='assigned', rider='R2' | |
| 5 | both writes succeed — D42 is now assigned to both R1 and R2 | |
Neither dispatcher's UPDATE checked what the other had done — each unconditional write just overwrote whatever was there. The SELECT told both “free” before either write landed, so both apps already showed their rider a confirmed driver before the collision is even detectable.
Fix 1: optimistic concurrency — compare-and-set
Make the write itself the check: condition the UPDATE on the value you assumed was true, and let the database's row-level atomicity be the referee instead of a prior SELECT.
UPDATE drivers
SET status = 'assigned', rider_id = :rider
WHERE id = :driver AND status = 'available';
-- trust the affected-row count, never a separate SELECT:
-- 1 row updated -> you won the claim, proceed
-- 0 rows updated -> someone else already claimed it; go re-matchTraced against the same race: Dispatcher A's UPDATE runs first and matches 1 row (status was still 'available') — A wins. Dispatcher B's identical UPDATE, whenever it runs, now matches 0 rows because status has already flipped to 'assigned' — B's dispatcher sees the 0-row result and re-matches Rider R2 to a different driver. Neither side coordinated with the other directly; the database's single-row write serialized them for free.
This is lock-free: nothing is held, so a slow or crashed client can never block someone else's claim. The cost is a retry loop under contention — many dispatchers racing the same popular driver (or the last seat at a hot show) all resend the same conditional UPDATE until one lands.
Fix 2: pessimistic locking — SELECT ... FOR UPDATE
Instead of racing writes, take the row lock at claim time inside a transaction. The second transaction's SELECT ... FOR UPDATE blocks until the first commits or rolls back, then sees the up-to-date status and skips a driver that is already taken. This serializes claims by construction — no retry logic needed — but the lock is held for as long as the surrounding transaction runs. If that transaction does anything slow (an external payment call, a wait on a decision), every other bidder for that same row queues behind it, turning a business-logic delay into database contention.
Fix 3: reservation with a TTL — don't hold a lock across a human
Neither CAS nor a row lock is safe when “confirm” depends on a slow, uncertain outside actor — a driver deciding whether to accept a ride, which can take anywhere from two to thirty seconds. Holding a database transaction (and its lock) open for that long would stall every other claim on the row. The fix decouples the offer from the decision into two short atomic steps:
- Hold — a short, self-contained write marks the resource
status='offered'withexpires_at = now + 15s. This transaction commits immediately; no lock is held while waiting on the driver. - Resolve — the driver's accept fires one more atomic CAS,
UPDATE ... SET status='assigned' WHERE id=:driver AND status='offered'; a decline does the mirror-image CAS back to'available'; and a background sweep does the same reset for any hold whoseexpires_athas passed with no response.
This is why reservation-with-TTL is standard in ride-dispatch and event-ticketing systems: it buys first-accept-wins semantics when the same offer is broadcast to several candidates at once (top-3 nearby drivers, or several buyers eyeing the last seat) — whichever accept's CAS lands first flips 'offered' → 'assigned' and every later accept simply matches 0 rows and is told “this offer is gone.”
Fix 4: distributed lock when the resource spans services
If “driver D42” isn't a single row in one transactional database — its live state might be sharded across a location service and a separate dispatch service — no single UPDATE or transaction can enforce the claim end to end. Take a short-lived distributed mutex first, e.g. SET driver:D42:lock <token> NX PX 5000 in Redis, perform the cross-service claim while holding it, then release. Keep the lease short and attach a unique fencing token so that a holder which pauses (GC stall, network partition) and wakes up after its lease has already expired cannot still write — whichever service enforces the resource rejects the stale token.
Pitfalls
- Holding a DB row lock across a network call or a human decision — every other claimant queues behind a wait that has nothing to do with the database itself.
- A hold with no TTL — if the dispatch process crashes right after marking a resource
'offered', it is stuck offered forever and nobody can claim it. Always pair a hold with an expiry and a background sweep. - Treating decline/timeout as an afterthought — the state machine needs an explicit edge back to
available(decline or TTL expiry), not just the happy accept path, or resources silently leak out of rotation. - A non-idempotent confirm — a retried “accept” message (duplicate delivery) must not double-confirm or re-charge. Key the confirm off the same conditional CAS so a redelivered accept just matches 0 rows and no-ops.
- A distributed lock with no fencing token — a holder that believes it still owns the lock can, after resuming from a pause, write past its actual lease expiry and collide with whoever holds the lock now (the classic Redlock / GC-pause hazard).
The judgment layer
Pessimistic lock vs optimistic CAS. Reach for SELECT ... FOR UPDATE when the work done under the lock is short (a few milliseconds of pure database logic) and correctness-by-construction matters more than raw throughput: the code is simple and needs no retry loop, but every transaction on that row queues behind whichever one holds the lock, so a hot row under a slow query anywhere in the transaction stalls everyone. Reach for optimistic CAS when the claim is a single fast conditional write and contention is moderate: it is lock-free — nothing blocks, nothing to time out — and scales better under normal load, at the cost of a retry loop. Under extreme contention on one row (a viral event, one celebrity's seat), many clients hammering the same CAS can waste more work in retries than a queue-based lock would.
Reservation/TTL vs immediate CAS. Use a reservation hold specifically when “confirm” depends on a slow, external, possibly-declining actor — a human accepting a ride, an inventory hold waiting on payment authorization. It buys first-accept-wins semantics across several simultaneous offers and never blocks a database transaction on a human, at the cost of running a real state machine (offered / expiring / confirmed) plus a background sweep, and accepting a window where the resource is neither available nor confirmed — worse peak utilization than an instant CAS, which has no such window because the decision is immediate.
Distributed lock vs single-DB atomicity. Only reach for a Redis-style distributed lock when the resource genuinely spans services or datastores and no single transaction can express the claim. If the resource lives in one database, prefer the database's own atomicity (CAS or FOR UPDATE) — it is simpler, already durable, and needs no fencing-token scheme. A distributed lock is an extra moving part (a system that can itself fail, drift, or pause) and, per Kleppmann's critique of Redlock, a naive implementation is not safe against process pauses or clock skew without fencing tokens.
Takeaways
- A claim on a shared resource is a race unless read-then-write collapses into one atomic step —
SELECT-then-UPDATEis exactly the check-then-act bug that causes double-dispatch and oversell. - Optimistic CAS (
UPDATE ... WHERE status='available') turns the affected-row count into the referee: 0 rows means you lost the race and must re-match; it is lock-free but needs a retry path. - When confirmation waits on a slow human, don't hold a database lock across that wait — HOLD with a TTL, then CAS-confirm on accept or expire back to available on decline/timeout.
- A distributed lock is for resources that span services, not a substitute for DB atomicity — and it needs a fencing token to be safe against a resumed, paused holder.
Synthesized from Uber/Lyft dispatch-system engineering write-ups, ticketing and inventory oversell postmortems, Kleppmann's Designing Data-Intensive Applications (Ch. 7 & 9 — atomic writes, distributed locks, and the Redlock critique), and standard optimistic-vs-pessimistic concurrency-control literature. Re-authored/Deepened for this guide.
🤖 Don't fully get this? Learn it with Claude
Stuck on Dispatch & Assignment: the double-booking race? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Dispatch & Assignment: the double-booking race** (System Design) and want to truly understand it. Explain Dispatch & Assignment: the double-booking race from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Dispatch & Assignment: the double-booking race** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Dispatch & Assignment: the double-booking race** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Dispatch & Assignment: the double-booking race** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.