Ride hailing: offers, races and failures

Part of the free System Design course on LogicWiz, in the chapter: Design ride hailing (Uber). The interactive whiteboard exercises and simulations run in the app.

Week 16, Day 5: The driver who got two rides

"Two riders tapped Book in the same second. Both got the same driver. One of them stood at the kerb for twenty minutes."

Kabir, describing the bug every ride-hailing team writes once


The design stands. Kabir now asks the three questions that decide the interview.

Deep dive 1: offer to one driver, or to many?

Twenty free drivers are near the pickup. How do you offer the ride?

Weak: broadcast to all twenty, first tap wins. It looks fast. But nineteen drivers see an offer that disappears under their finger. Drivers learn to distrust the app. At 120 rides per second it is also 2,400 offers per second, and every tap races every other tap.

Good: one driver at a time. Rank by road ETA, offer to the best driver, wait up to 10 seconds, then move on. Count the worst case: 3 offers × 10 seconds = 30 seconds, exactly the match budget. So after three misses you say "no drivers" or try a wider ring.

Great: a short lease, and a middle ground. An offer is a lease, a claim that ends on its own after 10 seconds. While the lease runs, the driver is OFFERED and no other ride may offer to them. For crowded areas, offer to two drivers in turn with shorter waits, or two at once with a rule that the first accept wins and the other offer is withdrawn.

The trade-off: one at a time is slower in the worst case. Broadcast is faster but creates races and annoys drivers. Name both costs.

Deep dive 2: two riders, one driver

Riders 1 and 2 ask in the same second. Two matching servers both pick driver D.

Weak: check, then set. Matcher A reads "D is AVAILABLE". Matcher B reads the same. A writes "D is offered ride 1". B writes "D is offered ride 2". Both succeed, and D is double-booked. A lock in one server's memory does not help, because the other server has its own memory.

Good: a conditional update. Make the write check for you:

UPDATE drivers SET status = 'OFFERED', current_ride_id = 1
WHERE driver_id = D AND status = 'AVAILABLE'

The database lets only one update change a row at a time. The first changes one row. The second waits a moment, re-checks the condition, finds the status is no longer AVAILABLE and changes zero rows. That matcher moves to the next driver. Your code holds no lock that could expire.

Great: the accept is guarded too, and every step is idempotent. Accept runs one transaction. It moves the ride from MATCHING to ASSIGNED only if the ride is still MATCHING. It moves the driver from OFFERED to ON_TRIP only if current_ride_id is this ride. If the rider cancelled a second ago, the first update changes zero rows, the transaction rolls back, and the driver gets 409. If the lease expired and a sweeper (a background job that cleans up expired records) freed the driver for another ride, the second guard fails. A retried accept returns the same answer instead of failing or applying twice.

The trip state machine, and where it lives

flowchart LR
  M["MATCHING"] --> A["ASSIGNED"]
  A --> R["ARRIVED"]
  R --> S["STARTED"]
  S --> C["COMPLETED"]
  M --> N["NO_DRIVERS"]
  M --> X["CANCELLED"]
  A --> X
  R --> X

Only these arrows are allowed. Every change is a guarded update on the ride row, with a version number. Every change also appends a row to ride_events, the history that settles disputes. This lives in SQL because accept changes a ride and a driver together. If you shard the database, shard both by city: the two rows must sit on one shard for the transaction to work.

⚠️ Warning: Do not say "we use a distributed lock" by reflex. A conditional update is simpler, has no lock to expire, and is easier to defend.

Deep dive 3: the location service is down

Meera's instinct: no locations, no rides. Kabir disagrees. Positions are soft state, and the ride path has a 99.99% target.

Weak: ride requests fail with an error, or matching waits forever on the index.

Good: the index has replicas. A replica takes over, and fresh pings refill it within one 5-second cycle. Matching treats a missing answer as a retry, not an error.

Great: degrade in layers.

  1. If a whole city shard is lost, matching reads each driver's last position from the location stream, which still holds pings. That is slower, and as stale as the outage is long, so it suits short outages. Driver status still comes from the rides database.
  2. It widens the search, still ranks by ETA, and offers anyway. The driver's accept is a human check: they know where they are.
  3. Driver apps keep sending. After a reconnect the app sends only its newest position. Old pings are worthless for matching.
  4. The ride service never depends on the index to accept a request. A ride waits in MATCHING and matching retries every few seconds until the 30-second budget ends. Then the rider sees "no drivers".

How do you notice? Alert on ping freshness (the age of the newest ping per city), match rate and time to match.

Follow-ups and what interviewers listen for

ETA. Do not ask the maps service about all 30 nearby drivers. Shortlist about 5 by straight-line distance, then get road ETAs for those. Cache ETAs between pairs of cells for a minute or two. The trade-off is slightly stale numbers.

Surge pricing. Count open requests and free drivers per cell over the last minute. A small job turns the ratio into a multiplier every 30 seconds or so and stores one number per cell. A ride request reads that number and locks the quote for a couple of minutes. Matching never waits for pricing.

Mid answer: a separate ingest path, a cell index in memory, defined ride states and a conditional update. Senior answer: also sizes the write path, widens the search by rings, specifies the lease and timeouts, makes every step idempotent and degrades when the index is lost.

Now write the rule that stops a double assignment.

📌 Summary: Offer to one driver at a time with a 10-second lease. Stop double assignment with conditional updates in SQL, not check-then-set. When the location service fails, degrade and keep taking ride requests.

What Meera learns next

One driver, two riders, a conditional update. Next week the same fight is harder: one seat, a million buyers at 10:00. Ticket booking adds holds that expire, a waiting room and a payment window.

Check your understanding

  1. SwiftKart's pilot broadcasts each ride to the 20 nearest drivers, and the first tap wins. Drivers complain that offers vanish. What is the best change?
  2. Two matching servers read driver D as AVAILABLE at the same moment. Which change stops both from assigning D?
  3. A driver taps Accept one second after the rider cancelled. What should happen?
  4. One city's geo index is lost. Riders keep tapping Book. What should the ride service do?