00. Design a Ride-Hailing Service¶
~20 min read · Level: advanced · Part 1 of 4 (Overview → HLD → LLD → Q&A)
Problem¶
A ride-hailing service connects a rider who wants a car now with a nearby driver willing to take the trip. This is the product behind Uber and Lyft. A rider opens the app, drops a pin, and taps "request." Within seconds a driver's photo and plate appear, the car icon starts moving toward the pin, and an ETA counts down. Behind that one tap the system is tracking where every online driver is, right now, to within a few meters; deciding which of the nearby ones to offer the trip to; pricing it; and handing the trip off cleanly so the same driver is never promised to two riders at once.
The hard part is not any single feature — it is that the system is a live map of the physical world that never stops moving. Millions of cars report their position every few seconds, and every rider request has to be answered against that constantly-shifting state fast enough that the human staring at the screen does not give up and close the app. The two forces in tension are a firehose of location writes and a latency budget on the geographic query that reads them. Everything else — surge, ETA, trip lifecycle — hangs off that spine.
To keep the reasoning concrete, thread one scenario through the whole design: a rider requests a car in a dense downtown at 6pm on a weekday. It is the evening rush, so within a ~2 km radius of the pin there are roughly 500 online drivers, each reporting GPS every 4 seconds, and the rider expects to see a matched driver within a few seconds of tapping request. That one request — 500 candidates, a moving target, a few-second budget, during the exact window when demand is highest and every other rider downtown is requesting too — will test every decision below.
Functional requirements¶
- Track driver location: ingest a position update from every online driver every few seconds and keep a queryable, near-real-time index of who is where.
- Request a ride: given a rider's pickup point, find nearby available drivers and match one within seconds.
- Dispatch and confirm: offer the trip to the chosen driver, wait for accept, fall through to the next candidate on decline or timeout, and never assign one driver to two trips.
- ETA: estimate time-to-pickup and time-to-destination using the road network, not straight-line distance.
- Surge pricing: raise the fare multiplier in areas and moments where demand outstrips available supply.
- Trip lifecycle: track a trip through requested → matched → en route → arrived → in progress → completed, and persist it durably for receipts and payment.
De-scoped for this round, and worth naming so the interviewer knows it is a choice: payment capture and fraud (a separate system — see the payments case study), driver onboarding and background checks, in-app chat, ratings, pooled/shared rides (which change the matching problem substantially), and the maps/routing engine itself, which we treat as a service we call rather than build.
Non-functional requirements¶
The dominant constraint is low-latency geospatial matching under a continuous location-write firehose. The system must answer "who is near this pin, right now?" in tens of milliseconds while simultaneously absorbing over a million position writes per second. That single sentence — cheap reads over data that is being overwritten constantly — shapes every storage and sharding decision that follows.
- Match latency: from request to a driver being offered the trip, single-digit seconds end to end, of which the geo-query-and-rank step is a tens-of-milliseconds budget.
- Write throughput: the location index must sustain the full firehose (over 1M updates/second, derived below) without the read path degrading.
- Consistency of driver state: a driver's assignment must be strongly consistent — exactly one trip at a time — even though their location is only eventually consistent and allowed to be a few seconds stale.
- Availability: matching must stay up regionally; a rider who cannot get a car is a lost trip and often a lost customer. Degrade gracefully (wider search, slower matches) rather than fail.
- Freshness vs cost: location data is worthless if stale but ruinously expensive if every driver reports too often. The update interval is a dial with a direct dollar cost.
Scale estimation¶
Assume a large service with 5 million drivers online at peak, each sending GPS every 4 seconds.
Location writes are the headline number: 5,000,000 / 4 s = 1.25 million updates/second, sustained, all day, rising and falling with the driver population. This is the load that defines the write path — not the ride requests, which are comparatively rare.
Ride requests: assume 20 million completed trips/day. That averages 20,000,000 / 86,400 s ≈ 230 requests/second, and with an evening peak factor of ~4× call it ~1,000 match requests/second at peak. Note the asymmetry that drives the whole design — location writes outnumber match requests by more than a thousand to one (1.25M : 1k). We are building a system that writes constantly and reads (matches) comparatively rarely, but each read is expensive and latency-critical.
Storage splits cleanly by durability. Live location state is tiny and ephemeral: 5M drivers × ~100 bytes (id, lat, lng, heading, status, timestamp) ≈ 500 MB, which fits in memory on a single large node, though we shard it for write throughput, not size. It does not need to be durable — a location lost in a crash is refreshed by the next update 4 seconds later. Trips are durable: 20M/day × ~1 KB ≈ 20 GB/day, ~7 TB/year, a modest sharded-database load. Raw location history, if kept, would be 1.25M/s × 100 B × 86,400 ≈ 10 TB/day — which is why we do not persist the raw firehose; it is downsampled or dropped at ingest and only aggregates reach analytics.
Inbound bandwidth from the firehose is real: 1.25M/s × ~100 B ≈ 125 MB/second of location updates arriving continuously, before protocol overhead. That is a genuine ingest-tier sizing input, not a rounding error.
For the threaded scenario, the arithmetic that matters is local: ~500 drivers within 2 km, each writing every 4 s, is 500 / 4 = 125 location writes/second landing in that one downtown area — and that area is one shard of the geo index, so the hot-cell problem is concrete: a single geographic shard absorbing a burst of both writes and match queries during rush hour.
API sketch¶
POST /v1/locations # driver → high-volume firehose
body: { "driver_id":…, "lat":…, "lng":…, "heading":…, "status":"available", "ts":… }
204: (no body; fire-and-forget, best-effort)
POST /v1/rides # rider requests a trip
body: { "rider_id":…, "pickup":{lat,lng}, "dropoff":{lat,lng}, "product":"standard" }
202: { "ride_id":…, "status":"matching", "eta_pickup_s":…, "fare_estimate":…, "surge":1.8 }
GET /v1/rides/{ride_id} # rider polls / streams trip state
200: { "status":"matched", "driver":{…}, "eta_pickup_s":…, "car_location":{lat,lng} }
POST /v1/rides/{ride_id}/offer/respond # driver accepts or declines an offer
body: { "driver_id":…, "decision":"accept" }
200 / 409 (offer expired or already taken)
POST /v1/rides/{ride_id}/cancel # rider or driver cancels
204:
Solutioning¶
Start from the asymmetry — a million writes per second, a thousand reads — and the shape of the system falls out. The location firehose must never touch a durable database; it lands in an in-memory geospatial index partitioned by geographic cell, where a write is an update to one cell's bucket and a read is "give me everyone in these cells." The reframing that unlocks the design: driver location is not a database record to persist; it is a fast-decaying signal to index. Nobody cares where a driver was 10 seconds ago, so we never write it to disk. This is why the 500 MB of live state can sit in RAM and be sharded for write concurrency rather than for capacity, and why losing a shard is a self-healing event — the next round of updates 4 seconds later rebuilds it.
The first defining tradeoff is match latency versus match optimality. The globally optimal assignment (the driver who minimizes total system wait across all pending requests) requires solving an assignment problem over the whole city and would blow the latency budget. So we do not search the whole city; we query a small radius, gather the ~500 candidates near the pin, prune to the closest couple dozen by cheap straight-line distance, and compute a real road-network ETA only for those before offering to the best one. The memory hook: matching is not a global optimization problem; it is a bounded local search. We accept a slightly-worse-than-perfect match that arrives in two seconds over a perfect match that arrives in twenty.
The second tradeoff is location-update frequency versus cost, and it moves real money. At every-4-seconds we sustain 1.25M writes/second; halving the interval to every 2 seconds doubles the firehose to 2.5M writes/second — twice the ingest fleet, twice the bandwidth bill — for a car that has moved maybe 25 meters at city speed. Pushing the other way, every-8-seconds halves the cost to 625k/second but lets the on-screen car jump and stales the match candidate's position enough to hurt ETA. The resolution is adaptive frequency: report often when moving fast or when carrying a passenger the rider is watching, and back off to every 8–10 seconds when parked and idle, which cuts the firehose substantially because a large share of "online" drivers are stationary between trips.
The third tradeoff is consistency of driver state. Location is eventually consistent and nobody is harmed by a two-second-stale pin, but assignment must be strongly consistent — the one guarantee the system cannot violate is offering the same driver to two riders and having both accept. So we split the model deliberately: location lives in the fast, sloppy, in-memory index, while the driver's assignment state is guarded by a single-writer atomic claim (a conditional update / lock) at dispatch time. Two data planes, two consistency models, joined only at the moment of the offer.
The result is a system with three planes that scale independently: a write-heavy location-ingest plane feeding a sharded in-memory geo index; a latency-critical matching plane that does bounded local search and atomic driver claims; and a durable trip-lifecycle plane that records what actually happened for payment and history. Surge and ETA are read-side services layered on the geo index. The following files take each plane down to components (HLD) and then to the geospatial index, the matching algorithm, ETA, and surge in detail (LLD).