Courses › System Design › Question Breakdowns

Design Uber

Location ingestion, proximity search, consistent driver matching and live maps.

Design a ride-hailing service. Riders request rides, see fare estimates and get matched with nearby drivers. It's a question about high-frequency location data, proximity search and consistent matching under contention.

Requirements

Functional

  • Riders can enter a pickup and destination and get a fare estimate
  • Riders can request a ride and get matched with a nearby available driver
  • Drivers can accept or decline ride offers and navigate to the pickup

Out of scope: payments, ratings, scheduled rides, ride sharing (pooling).

Non-functional

  • Low-latency matching (< 1 minute to find a driver, or fail)
  • Strong consistency in matching — a driver can't be assigned two rides at once
  • Handle high throughput of location updates: ~10M drivers sending updates every ~5 seconds ≈ 2M updates/second at peak
  • Highly available

[!TIP] Size it from drivers, not riders 10M drivers pinging every 5 seconds is 2M writes/second. Ride requests are tiny by comparison: Uber has reported ~25M trips a day, which is ~300 requests/second on average. Each driver's current state (ID, position, heading, speed, status, timestamp) is ~64 bytes, so all drivers fit in ~640 MB of RAM. Memory isn't the problem; the write rate is.

Core Entities

  • Rider, Driver (status: available / offered / on trip), Ride (pickup, destination, fare, status), Location (driver, lat/lng, timestamp)

API

POST  /v1/fares         { pickup, destination }        → { fareId, estimate, eta }
POST  /v1/rides         { fareId }                     → { rideId, status: "matching" }
POST  /v1/drivers/location  { lat, lng }               → 204
PATCH /v1/rides/{rideId}    { action: "accept" | "decline" }

High-Level Design

rider = client "Rider App" @0,0
ride = service "Ride Service" @1,0
rdb = store "Rides DB" @2,0
match = service "Matching Service" @1,1
idx = cache "Location Index\n(in memory)" @2,1
loc = service "Location Service" @3,1
driver = client "Driver App" @4,1
notif = service "Notification\nService" @3,2
rider -> ride : request
ride -> rdb
ride -> match
match -> idx : nearby drivers
driver -> loc : every 3-5 s
loc -> idx
match -> notif : offer
notif --> driver
  • Fare estimate: the ride service calls a maps/routing service for distance and time, applies pricing, and stores the estimate.
  • Request: creates a ride in matching status and hands it to the matching service.
  • Matching: queries the location index for nearby available drivers, ranks them (by ETA) and sends an offer to the best one via push notification.
  • Accept: the driver accepts; the ride moves to matched, and the driver's status to on trip.

Deep Dives

1. Ingesting location updates

2M updates/second must not hit a relational database row by row. Write to an in-memory geospatial index:

  • Redis GEO (GEOADD, GEOSEARCH) — supports radius queries directly; shard by region.
  • Or bucket drivers by geohash cell in memory.

Only the latest location matters for matching, so the index stores the current position; a separate stream (Kafka) can archive location history for trip records and analytics.

[!NOTE] Why not reuse Yelp's index as-is? Yelp's QuadTree (see Design Yelp (Nearby Places)) is tuned for places that don't move, where rebuilding now and then is fine. Drivers move constantly, so use a fixed cell grid instead. Uber built and open-sourced H3 for this: hexagonal cells at chosen resolutions, where every neighbor is the same distance away. Keep two maps: driverId → (cell, position, status) and cell → set of driverIds. Most pings just overwrite the position. Only when a driver crosses into a new cell (every few minutes in city traffic) do the two sets change, so the expensive operation is rare.

2. Proximity search

"Available drivers within 3 km" becomes a geo query on the index, filtered by status. Geohash cells make this a lookup of the rider's cell plus its 8 neighbors. In dense cities, use smaller cells; in rural areas, expand the radius progressively.

3. One driver, one offer

Two matching workers must not offer the same driver two rides simultaneously. Lock the driver while an offer is outstanding:

  • Redis SET driver_lock:{id} {rideId} NX EX 10 — the TTL matches the offer timeout, so a driver who ignores the offer is automatically released.
  • Or a conditional status update on the driver row (available → offered).

If the driver declines or times out, the lock is released and the matching service offers the next driver.

Better: match in batches. Matching each request the moment it arrives is greedy: the closest driver goes to the first rider, even if a second rider a few seconds later was right next to that driver. Collect a region's requests for a couple of seconds, then solve them together as an assignment problem that minimizes total pickup time. Riders wait a moment longer to be matched but get picked up sooner. Either way, accepting is a conditional update on the ride (status = 'matching' → 'matched'), so only one driver can win.

4. Not losing ride requests during spikes

Put ride requests on a queue partitioned by region. Matching workers pull requests, and if one crashes mid-match, the request becomes visible again and another worker continues. This also smooths demand surges (concerts letting out).

5. Scaling by geography

Riders and drivers interact only within a city. Partition location data, matching workers and queues by region, which keeps every query local and lets regions scale independently.

6. Showing drivers moving on the rider's map

The two maps have very different requirements:

  • Before a ride, the cars around the pickup pin are reassurance, not data. The app asks for "cars near me" every few seconds and gets coarse positions from the cell index. Positions can be a few seconds stale, and are often deliberately blurred so riders can't track individual drivers.
  • During a ride, rider and driver need each other's exact position. Both apps join the trip's channel over a WebSocket, and each driver ping is relayed to that one rider. It's one-to-one, so there's no fan-out to manage, and the client smooths the car's movement between pings.

7. Failures and the tricky cases

  • A location index node dies: its state rebuilds itself. Every driver pings again within 5 seconds, so a replacement node is complete almost immediately, with no persistence needed. Rides and driver assignments live in the rides database, not in the index.
  • Flaky phone networks: drivers' apps buffer location pings while offline and send the latest when they reconnect. Server-side, a driver who stops pinging for ~30 seconds is marked unavailable.
  • Disconnects mid-trip: the trip is server-side state, so it continues. Fare and billing come from the server's record of the route (from the archived pings), not from the phone, and a reconnecting app re-syncs the trip's current state.

8. Ranking drivers

Straight-line distance misleads: a driver 300 m away on the other side of a river or a highway can be ten minutes away. Use the cell index to gather candidates cheaply (say the 20 nearest), then ask the routing service for each one's ETA to the pickup and rank by that. Driver rating, vehicle type and acceptance history are tie-breakers or filters.

What's expected at each level

  • Mid-level: a working request → match → accept flow and a geospatial approach to finding drivers.
  • Senior: high-throughput location ingestion, driver locking with timeouts, queuing ride requests.
  • Staff: regional partitioning, surge pricing inputs, matching optimization across many simultaneous requests, push vs. pull for live maps, failure scenarios mid-trip.