SYSTEM DESIGN · DESIGN PROBLEMS · LOCATION · HARD · 21 OF 41

Design Uber: Driver Locations, Matching Without Double Booking, Surge and Trips

A senior-level design for a ride-hailing app like Uber: requirements, the math for half a million location updates a second, rider and driver APIs, a sharded in-memory supply index on hexagon cells, nearest-driver search by ETA rather than distance, matching that never double books a driver, batched matching, surge pricing per cell, a trip state machine, cell-based isolation, and trade-offs at 10x.

Reported atUber

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 20

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Requirements. The rider opens the app and sees nearby cars. They enter a destination and get a price and pickup ETA, request a ride, get matched, track the driver, and pay automatically at the end. Drivers go online, stream their location, receive offers to accept or decline, and start and end trips. Shared rides, scheduled rides and food delivery are out of scope, though mention that pooling turns matching into a routing problem.

Qualities. Matching should take a few seconds. Assignment must be strongly consistent: a driver can never be on two trips, even under retries and races. Locations need freshness, not durability; dropping a ping is fine. A price quote should be honoured for a few minutes so the rider isn't surprised. Availability matters a lot, because an outage leaves people stranded at night. And like food delivery, isolate cities from each other.

Functional requirements
  • rider:
  • • see nearby cars on the map
  • • get a price quote + ETA
  • • request, get matched, track
  • • pay automatically at the end
  • driver:
  • • go online, stream location
  • • get offers, accept or decline
  • • start and end trips
  • out: pooling, scheduled rides
Non-functional requirements
  • match within ~5-10 s
  • a driver is never on two
  • trips: strong consistency
  • for assignment
  • locations: fresh, lossy ok
  • quote is honoured for a few
  • minutes
  • highly available; an outage
  • strands people at night
  • per-city isolation

Ask scale

Assume thirty million trips a day: about three hundred ride requests a second, a thousand at peak, plus several price quotes per trip. The big numbers come from movement. Two million drivers online at peak, pinging every four seconds, is half a million location writes a second. And ten million riders with the app open, refreshing nearby cars every five seconds, is two million reads a second. So the supply index, where the drivers are right now, is the hot path.

Memory and storage. Each live driver record is about sixty-four bytes, so two million drivers is only around a hundred twenty-eight megabytes. The problem isn't size, it's half a million updates a second plus millions of queries. Trips are about five kilobytes with their route, roughly a hundred fifty gigabytes a day. Raw pings are a couple of terabytes a day, which go to a stream and cold storage for analytics, fraud checks and fare disputes.

The numbers
trips/day             30M
  30M / 100,000 s    = 300/s
  peak x3            = ~1K/s
quotes ~5 per trip    = 5K/s peak

drivers online, peak  2M
  ping every 4 s     = 500K/s
riders with app open  10M
  nearby-cars poll 5 s
                     = 2M/s reads
-> supply index is the hot path
Memory and storage
live driver: id, lat, lng,
  heading, status, cell ~ 64 B
  2M x 64 B          = 128 MB

trip record ~ 5 KB with route
  30M x 5 KB         = 150 GB/day
  x 365              = ~55 TB/yr

raw pings 500K/s x 50 B
  = 25 MB/s = ~2 TB/day
  -> stream, cold storage

Define API

The API. A quote takes pickup, drop-off and product, and returns a price, an ETA, and an expiry. Requesting a ride references the quote, so the price is locked, and carries an idempotency key. Drivers post locations, often over a persistent connection to cut overhead. Drivers accept offers. Riders receive driver position and trip status over a socket, and drivers receive offers and trip updates over theirs.

Core API
POST /v1/quotes
  { pickup, dropoff, product }
  -> { quoteId, price, eta,
       expiresAt }
POST /v1/rides  { quoteId }
  Idempotency-Key: ..
POST /v1/drivers/me/location
  { lat, lng, heading, ts }
POST /v1/offers/{id}/accept
WS   rider: driver pos, status
WS   driver: offers, trip

Data model

Data model. Trips are the money records: a consistent, replicated store keyed by trip ID, sharded by city. A separate driver state row holds status, open, offered or on trip, the current trip ID and a version number; this row is how we prevent double booking. Quotes are short-lived with a time-to-live. And the live supply index lives in sharded memory, mapping each cell to the drivers currently in it.

Records
trip   (tripId, cityId, riderId,
        driverId, state, version,
        quoteId, fare, route)
driver_state (driverId PK,
        status, tripId, version)
        status: offline | open |
        offered | on_trip
quote  (quoteId, price, mult,
        expiresAt)  short TTL
supply (in RAM) cell -> drivers

High-level design

Start with the location path. Drivers stream pings to the location service through the edge gateway. Each ping updates the driver's cell and position in the in-memory supply index, and is also written to the event stream for anything that needs history. The rider's map of nearby cars is a read from the same index, slightly blurred and throttled, because riders only need an impression of supply, not exact positions.

The request path. The rider gets a quote from pricing. On request, the trip service creates a trip in the requested state and asks matching for a driver. Matching pulls open drivers from cells around the pickup, asks routing for real road ETAs, picks the best and sends an offer. When the driver accepts, the trip and driver state update atomically. After the trip ends, payments charges the card.

Deep dive: supply index

Deep dive one: the supply index. Divide the map into hexagon cells; Uber open-sourced a system for this called H3. Hexagons are nice because every neighbour is the same distance away, so rings of cells approximate a circle well. At a mid resolution a cell is under a square kilometre. Shard the index by cell, grouped by city, and when a driver crosses a boundary, remove them from the old cell and add them to the new one. Give entries a fifteen-second expiry, so a driver whose phone dies quietly disappears.

How do you handle half a million writes a second? Keep disk off the hot path. Ingest nodes are stateless and route each ping to the shard owning that cell. The shard updates memory in place and drops any ping older than the one it already has. Each shard has a standby replica, but even if both fail, the index refills from fresh pings within one ping cycle, about four seconds. That's the beauty of state that is constantly re-sent: recovery is free.

Supply index
  • cells: H3 hexagons (open
  • source from Uber), res ~8
  • ~0.7 km2 per cell
  • hexagons: 6 equal neighbours,
  • rings are near circles
  • shard by cell (city first):
  • map cell -> node, consistent
  • hashing
  • driver moves cells: remove
  • from old, add to new
  • TTL 15 s: silent = gone
Handling 500K writes/s
  • ingest is stateless; routes
  • each ping to the shard that
  • owns the driver's cell
  • in-place update, no disk
  • drop out-of-order pings
  • (older ts than stored)
  • replicate shard to a standby;
  • if both die, the index
  • refills in one ping cycle
  • raw pings: stream, batched

Deep dive: nearest car

Deep dive two: finding the nearest car. Start at the pickup cell and expand ring by ring until you have around twenty open drivers. Filter by product type, and prefer drivers heading toward the rider. Then the key point: rank by road ETA, not straight-line distance. A driver two hundred metres away across a river, or on the wrong side of a highway, can be ten minutes away. The routing service computes ETAs on a road graph with live traffic, and caches results between cell pairs for a short time.

Finding candidates
  • 1 pickup cell, then rings
  • k = 1, 2, 3 until ~20 open
  • drivers found
  • 2 filter: product, rating,
  • heading (not driving away)
  • 3 ETA via road graph for each
  • (river, one-way, highway)
  • 4 rank by ETA, not distance
  • cache ETAs per cell pair
  • for ~30 s

Deep dive: assignment

Deep dive three: never double book. Two matchers working on two riders might both pick the same driver. So before sending an offer, matching does a conditional update on the driver's row: set status to offered, but only if it's still open and the version hasn't changed. Zero rows changed means someone else got there first, so try the next candidate. Acceptance is one transaction: driver to on trip, trip to accepted, only if the offer is still valid. An ignored offer expires after about fifteen seconds.

A senior-level point: greedy versus batched matching. Greedy gives each request its best driver immediately. But imagine two riders and two drivers: greedy can give the first rider a driver who was the only good option for the second, leaving the second rider a long wait. Batching collects requests in an area for a couple of seconds and solves an assignment problem that minimises total pickup time. In busy areas that small delay pays for itself.

Atomic assignment
offer: UPDATE driver_state
  SET status=offered, offer=77,
      version=version+1
  WHERE id=D AND status=open
  AND version=v   -- 0 rows: skip

accept (one transaction):
  driver_state -> on_trip(T)
  trip T -> ACCEPTED, driver D
  only if offer 77 still valid
offer timeout 15 s -> open
Greedy vs batched matching
  • greedy: each request takes
  • its best driver right now
  • simple, but can be unfair
  • batched: collect requests for
  • ~2 s per area, solve an
  • assignment that minimises
  • total pickup time
  • batched wins in busy areas:
  • fewer long pickups overall

Deep dive: surge

Deep dive four: surge pricing. A stream job computes, for each cell about every minute, demand from quotes and requests and supply from open drivers. The ratio maps to a price multiplier. Smooth it over time and across neighbouring cells so prices don't flicker or jump at a street boundary, and apply caps and rules such as during emergencies. The quote records the multiplier, and a request within its expiry honours that price, even if surge changes a second later.

Surge pricing
  • per cell, every ~1 minute:
  • demand = quotes + requests
  • supply = open drivers
  • multiplier from the ratio,
  • smoothed over time and
  • across neighbour cells
  • caps and rules (emergencies)
  • quote stores the multiplier;
  • request honours it until
  • expiresAt (a few minutes)

Deep dive: trip states

The trip lifecycle. Requested, accepted, arrived, in progress, completed, paid, with cancellation rules that depend on the state and how long it's been. Each change is written with an outbox entry in one transaction, and the event stream carries it to everyone who cares. Payments consumes the completed event and charges the card using the trip ID as an idempotency key, so a replayed event never charges twice. The final fare uses the route reconstructed from the driver's pings.

Trip state machine
REQUESTED -> ACCEPTED
  -> ARRIVED -> IN_PROGRESS
  -> COMPLETED -> PAID
cancel: by rider or driver,
  fee rules by state + time

each change: state + outbox in
  one transaction
payments consumes COMPLETED,
  charges with tripId as key
fare = route from pings

Trade-offs

Failures. A lost supply shard refills from pings in seconds. If matching is down, requests wait and riders see a still-looking message, and the conditional updates mean recovery can't double book anyone. If the trip database fails over to a replica, trips already in progress continue, because both phones know the state and the driver app buffers pings to upload later. Monitor time to match, ETA error against actual pickup time, and offer acceptance rate.

Trade-offs. The supply index is fast but only approximately accurate at any instant, which is fine because ETAs absorb a few seconds of error. Batched matching adds a two-second delay for better outcomes overall. Smoothing surge slows its reaction but avoids price shocks. At ten times the scale, use finer cells in dense cities, more shards, fully regional stacks, and move completed trips to an archive so the hot trip store stays small.

Failures and monitoring
  • supply shard lost: refills in
  • one ping cycle
  • matching down: requests wait
  • in queue, riders see "still
  • looking"; no double booking
  • trip DB region down: failover
  • to replica; trips in flight
  • continue on the phones
  • driver app offline: buffer
  • pings, upload on reconnect
  • watch: time to match, ETA err
Trade-offs and 10x
  • in-memory supply: speed, but
  • only eventually accurate
  • batched matching: 2 s delay
  • for better global pickups
  • surge smoothing: slower
  • reaction, fewer price shocks
  • 10x: finer cells in dense
  • cities, more shards, regional
  • stacks, async trip archive

Say it like this

The summary. Half a million pings a second go into a sharded in-memory index of hexagon cells, refilled from pings on failure. Candidates come from a ring search around the pickup, ranked by road ETA rather than distance. Assignment uses a conditional update on the driver's state, so no driver is ever double booked, and acceptance is a single transaction. Batch matching in busy areas. Surge is demand over supply per cell, locked into the quote. Trips are a state machine with outbox events driving payment.

The 60-second answer
  • 500K pings/s -> sharded
  • in-memory hex-cell index
  • candidates: k-ring search,
  • rank by road ETA
  • offer: conditional update on
  • driver_state (open->offered)
  • accept: one transaction
  • batch matching in busy areas
  • surge: demand/supply per cell
  • trip: state machine + outbox

Interview questions

How do you store and query millions of moving drivers?

Keep the latest position of each online driver in an in-memory index keyed by hexagon or geohash cell, sharded by cell within each city. Pings update entries in place with a short expiry, and queries scan the pickup cell and expanding rings of neighbours.

How do you make sure a driver is never assigned to two riders?

Gate every offer with a conditional update on the driver’s state row, moving open to offered only if the version is unchanged, and make acceptance a single transaction that sets both driver and trip state. A failed condition means someone else won, so the matcher moves to the next candidate.

Why rank drivers by ETA instead of distance?

Roads, rivers, one-way streets and traffic make straight-line distance a poor predictor of pickup time. A routing service computes road ETAs for the shortlisted candidates and the matcher ranks by those.

Why use hexagons like H3 instead of squares?

Every neighbour of a hexagon is the same distance from its centre, so rings of cells approximate a circle and distance estimates are more uniform. Square grids have diagonal neighbours that are farther away than edge neighbours.

What happens if a shard of the location index is lost?

Drivers keep sending pings every few seconds, so a replacement shard is fully populated again within one ping cycle. A standby replica covers the gap, and nothing durable depends on that memory.

How does surge pricing work in your design?

A stream job computes demand and supply per cell every minute or so, maps the ratio to a multiplier, and smooths it over time and neighbouring cells. Quotes record the multiplier and are honoured until they expire.

What is batched matching and when is it better?

Instead of greedily assigning each request the moment it arrives, collect requests in an area for a second or two and solve an assignment that minimises total pickup time. It produces better overall outcomes in dense areas at the cost of a tiny delay.

How do you avoid charging a rider twice?

Payment is triggered by the trip-completed event and uses the trip ID as the idempotency key with the payment provider. A replayed event or retried call finds the existing charge and does nothing.

What would you change at ten times the scale?

Use finer cells in dense cities and more shards for the supply index, run fully regional stacks per group of cities, and archive completed trips out of the hot store. Matching and pricing stay per city, so they scale by adding cells rather than by redesign.