Preventing Double-Booking in Ride Dispatch
Two trips searching at once can reach the same driver. The fix is smaller than most teams expect, and reaching for a distributed lock is usually the wrong instinct.

Two riders request trips within the same second, in the same neighbourhood. Both searches run. Both candidate lists rank the same driver first. Both dispatch processes decide to send an offer.
Without a safeguard, one of two things happens. The driver sees two ride requests appear at once and has no idea which is real. Or worse, both trips record that they have secured the driver, and the inconsistency only surfaces when the driver arrives for one rider while the other is still expecting them.
This is a straightforward race condition. The interesting part is how much machinery teams tend to throw at it.
The instinct to reach for a lock
The reflex is a distributed lock: acquire a lock on the driver, offer, release. It works, and for a team that already runs the infrastructure it can be a reasonable choice.
But it brings the full weight of distributed locking with it. Locks need expiry, or a crashed process holds a driver hostage. Expiry needs tuning, and if it is shorter than the offer window the lock releases while the offer is still live — reintroducing exactly the race it was meant to prevent. The lock service becomes a dependency on the hot path of dispatch, so its availability becomes dispatch's availability.
That is a lot of operational surface for protecting one field on one record.
What the problem actually is
Strip it back: preventing a driver from being offered two trips at once means ensuring only one process can move that driver from available to offered.
That is a single-field, single-record, compare-and-set problem. It does not span services or records, and there is no multi-step operation to keep atomic. Almost every datastore already provides a primitive for it.
Optimistic concurrency on driver state
The approach that fits is a conditional update: change the driver's status to offered only if it is currently available, with the check and the write happening as one atomic operation.
Two processes attempt it. One succeeds and proceeds with the offer. The other sees zero rows affected, immediately understands the driver is no longer available, and moves to its next candidate without waiting for anything.
The properties that make this work well:
- No blocking. The losing process fails instantly and does something useful, rather than queuing behind a lock.
- No new dependency. It uses the store already holding driver state.
- No expiry to tune. Releasing the driver is a normal state transition on decline, timeout, or completion — the same transitions the dispatch flow already has to handle.
- Failure is safe. If the process crashes after acquiring, the driver is left in offered, and the offer timeout that already exists returns them to available. No separate recovery path.
That last point matters. The timeout handling is not optional — it is needed for ordinary unanswered offers regardless. Reusing it as the recovery mechanism means the concurrency fix adds no new failure mode.
Where it stops being enough
Optimistic concurrency is not universally correct, and the boundary is worth knowing.
It works because the contended resource is one field on one record. When an operation must be atomic across multiple records or services — reserving a driver and debiting a rider wallet and allocating a vehicle, all or nothing — a conditional update on one field no longer covers it, and you are into transactions or a saga with explicit compensation.
It also assumes contention is rare. Optimistic control is efficient when conflicts are occasional, because the cost of losing is a retry. In a market where supply is so thin that the same driver is genuinely contended on most requests, the retry rate rises, and the deeper problem is supply rather than concurrency — which is something dispatch metrics should be surfacing well before it shows up as a technical symptom.
The wider correctness question
Solving the offer race does not by itself make dispatch correct. It sits alongside the rest of the state design: keeping trip and driver state separate so a driver-side conflict does not require trip-level reasoning, making transitions idempotent so a retried offer does not double-apply, and rejecting stale transitions so a delayed acceptance cannot claim a driver who has already been reassigned.
That last one is the subtle case. A driver disconnects mid-offer, the system reassigns, and then the original acceptance arrives from a phone that just regained signal. The conditional update prevented two simultaneous offers, but nothing stops a late acceptance for an offer that is no longer valid unless transitions are guarded on the offer's identity as well as the driver's status.
The general shape of the lesson
The engineering instinct on hearing "race condition" is to reach for the strongest available primitive. Frequently the contended resource is far smaller than the reflex assumes, and the smaller mechanism is not merely adequate but genuinely better — fewer moving parts, no extra dependency on the hot path, and recovery that reuses handling the system needed anyway.
Worth reaching for the bigger tool when the problem is actually bigger. Worth checking first whether it is.
We work on the dispatch and matching layer of ride-hailing platforms, including the concurrency and state design underneath it. If you are seeing duplicate offers or drivers stuck in intermediate states, that is a conversation we are glad to have.




