Skip to main content

Design Uber

A ride-hailing service is a genuinely different class of problem from the Easy tier's single-service designs: it needs to track constantly-moving location data for two kinds of users at once, match them against each other in real time, and keep that matching correct under real concurrency — several riders must never be matched to the same driver. Following the framework from How to Answer a System Design Interview Question.

1. Requirements​

Functional:

  • Riders can request a ride and see nearby available drivers.
  • The system matches a rider to a nearby available driver and both parties can track the ride's live location.
  • A driver's location updates continuously while online.

Non-functional:

  • Location updates can tolerate eventual consistency — a driver's marker being a second or two stale on a map is harmless.
  • Ride matching (assigning a specific driver to a specific rider) needs strong consistency for that one decision — two riders must never be assigned the same driver at the same moment, which is a very different requirement from the location updates around it.
  • Low latency matters throughout — both location updates and match requests are on the critical path of a real-time experience.

Scale estimate: assume 1M active drivers broadcasting location every 4 seconds (~250,000 writes/sec) and a much smaller volume of actual ride requests. The location-update volume is the dominant load by a wide margin, and it's the piece that can tolerate the most relaxed consistency — a useful pairing that shapes the whole design.

2. API Design​

POST /drivers/{id}/location body: { lat, lng }
GET /riders/nearby-drivers query: { lat, lng, radius } returns: [{ driver_id, lat, lng }]
POST /rides body: { rider_id, pickup_location } returns: { ride_id, driver_id }

3. Data Model​

drivers
driver_id VARCHAR PRIMARY KEY
status ENUM(available, on_trip, offline)
lat, lng FLOAT -- most recent known position

rides
ride_id VARCHAR PRIMARY KEY
rider_id VARCHAR
driver_id VARCHAR
status ENUM(requested, matched, in_progress, completed)

Driver location is deliberately modeled as "most recent position only," not a full history table on the hot path — a driver's location history for analytics or billing is a separate, much lower-urgency write that can go through a batch or stream pipeline, not the real-time matching path.

4. High-Level Design​

System Design Lab

Location updates and ride matching are deliberately split into two services with very different consistency needs — this is a direct application of Microservices Architecture's core justification: each piece scales and is reasoned about independently, matching its own actual requirement instead of forcing one uniform consistency model onto both.

5. Deep Dive: finding nearby drivers fast​

"Find all available drivers within 2km" is the problem the rest of the design exists to support quickly. A naive scan comparing every driver's coordinates against the rider's is far too slow at this scale — the standard fix is geohashing: encoding latitude/longitude into a string where nearby locations share long common prefixes, so "find nearby drivers" becomes "find drivers whose geohash shares a prefix with mine," a fast, indexable lookup instead of a full scan. This is conceptually the same idea as Consistent Hashing — mapping a continuous space onto discrete, indexable buckets — applied to physical geography instead of a hash ring, and it's also exactly what lets driver location data be sharded geographically: drivers in Chicago and drivers in Tokyo never need to be compared against each other, so partitioning the location index by region (rather than randomly, as Database Sharding would by default) keeps every real query local to one shard.

6. Deep Dive: preventing double-booking under concurrent match requests​

The matching service's read from the geospatial index and its write assigning a driver are two separate steps, which opens a real race: two riders on opposite sides of a busy intersection can both query "nearby available drivers" at nearly the same instant, both see the same one driver as available, and both try to book them. Nothing in the high-level design so far stops that.

The fix is to never trust the read by the time the write happens. Instead of "check availability, then assign," the assignment itself is an atomic conditional update — a single database operation that only succeeds if the driver's status is still available at the moment it runs:

UPDATE drivers
SET status = 'on_trip'
WHERE driver_id = ? AND status = 'available';
-- 1 row affected → this request won the driver
-- 0 rows affected → someone else already took them

Whichever request's update actually matches the WHERE clause is the one that wins; the loser gets zero rows affected, not an error, and simply retries against the next-nearest available driver from its original query. This is the same compare-and-swap idea used to solve races in Distributed Locking generalized to a single-row database update — no external lock service is even required here, because the database's own row-level atomicity is enough to make "one winner" a guarantee rather than a hope. A dedicated distributed lock (e.g. a short-TTL Redis SETNX) becomes necessary only if driver availability doesn't live in a datastore that offers atomic conditional writes.

System Design Lab

7. Tradeoffs​

The core tension in this design is exactly the one named in the requirements: high-frequency, loosely-consistent location writes need to be cheap and fast, while the rare, high-stakes act of assigning a driver to a rider needs a strong guarantee that no driver is double-booked — solved above with an atomic conditional update at match time, without requiring every location update to pay that same coordination cost.

That split has a real edge case worth naming in an interview: a driver can go offline (app crash, tunnel, dead phone) in the gap between "matched" and "trip started," and the location-update stream simply stops — there's no explicit disconnect event to react to. The practical fix is a heartbeat timeout: if a driver's last location update is older than a few seconds past their expected interval, the matching service treats them as unavailable and re-matches the rider, rather than waiting for an explicit signal that a loosely-consistent, fire-and-forget update stream was never designed to reliably provide.

Geographic sharding vs. random sharding for driver location data: pros and cons​

Geographic sharding

  • Nearby-driver queries stay within one shard instead of fanning out across all of them
  • Load naturally follows population density, which matches where queries actually concentrate
  • Regional outages degrade gracefully — one region's shard issue doesn't affect others

Random sharding

  • A nearby-driver query has no locality guarantee — it may need to check every shard
  • A popular city's drivers can still land on the same shard purely by chance, causing hot spots
  • Gains none of geography's natural query locality that the access pattern actually has

Further Reading​

Share this lesson

Saved locally in your browser — visible in the sidebar as you go.