Knowledge Guide
HomeSystem DesignSystem Design Problems

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:

stepDispatcher A (Rider R1)Dispatcher B (Rider R2)
1SELECT status WHERE id=D42available
2SELECT status WHERE id=D42available
3UPDATE ... SET status='assigned', rider='R1'
4UPDATE ... SET status='assigned', rider='R2'
5both 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.

Top panel: Dispatcher A and Dispatcher B both SELECT driver D42 as available, then both run an unconditional UPDATE, and both succeed -- double-dispatch. Bottom panel: both UPDATEs are conditioned on status='available'; A's matches 1 row and wins, B's matches 0 rows and loses the race cleanly.
Top panel: Dispatcher A and Dispatcher B both SELECT driver D42 as available, then both run an unconditional UPDATE, and both succeed -- double-dispatch. Bottom panel: both UPDATEs are conditioned on status='available'; A's matches 1 row and wins, B's matches 0 rows and loses the race cleanly.

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-match

Traced 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:

  1. Hold — a short, self-contained write marks the resource status='offered' with expires_at = now + 15s. This transaction commits immediately; no lock is held while waiting on the driver.
  2. 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 whose expires_at has passed with no response.
State machine: AVAILABLE transitions to OFFERED/HELD (with a TTL) when a driver is offered; OFFERED transitions to CONFIRMED if the driver accepts before the TTL via a CAS update; OFFERED transitions back to AVAILABLE on decline or TTL expiry. Annotation: broadcasting the offer to multiple drivers means the first CAS-confirm wins and the rest match 0 rows.
State machine: AVAILABLE transitions to OFFERED/HELD (with a TTL) when a driver is offered; OFFERED transitions to CONFIRMED if the driver accepts before the TTL via a CAS update; OFFERED transitions back to AVAILABLE on decline or TTL expiry. Annotation: broadcasting the offer to multiple drivers means the first CAS-confirm wins and the rest match 0 rows.

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

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


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.

🔨 Practice this hands-on — Design Flash-Sale Inventory (oversell under contention) →🔨 Practice this hands-on — Design Food-Delivery Dispatch →
Attempt it from an empty file, break it to feel the failure, then defend it under pushback.
🤖 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.

🎨 Explain it visually

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.
🤔 Walk me through it (interactive)

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.
🧪 Quiz me & fix my gaps

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.
🧠 Make it stick

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.

📝 My notes