Design a Ride-Sharing Service
Design an Uber/Lyft-style platform that ingests millions of driver location updates, finds nearby drivers with a geospatial index, matches riders to drivers, and tracks each trip through a reliable state machine.
Requirements
A rider opens the app, sees nearby cars, requests a ride, gets matched with a driver within seconds, watches the car approach on a map, rides, and pays automatically at the end.
Functional
- Drivers go online/offline and stream their location every few seconds.
- Riders see nearby available drivers and an ETA / fare estimate.
- Riders request a ride; the system matches and dispatches a driver who can accept or decline.
- Both parties see live location during pickup and the trip.
- Trip lifecycle: requested → matched → arriving → in progress → completed / cancelled; fare computed and charged.
- Ratings and trip history.
Non-functional
- Low latency matching: a driver offer within a few seconds of the request.
- Huge write volume from location updates; each update must be cheap.
- High availability: if dispatch is down, the business is down.
- Consistency where it matters: a driver must never be assigned to two trips at once; trip and payment state must be durable.
- Location freshness is eventually consistent: a few seconds old is fine.
Ask whether pooled rides, surge pricing, scheduled rides and multi-region are in scope. Agree to design single-rider trips in one city first, then mention how surge and pooling plug in.
Capacity Estimates
Assume 100 M monthly riders, 20 M rides per day, 5 M drivers of whom 1 M are online at peak, location update every 4 seconds.
| Metric | Math | Value |
|---|---|---|
| Location updates/s (peak) | 1 M drivers / 4 s | ~250 K writes/s |
| Location payload | driverId, lat, lng, heading, speed, ts | ~100 B |
| Location ingest bandwidth | 250 K × 100 B | ~25 MB/s |
| Live location state | 1 M × ~200 B | ~200 MB (fits in memory) |
| Ride requests/s | 20 M / 86,400 | ~230 /s (peak ~1,500 /s) |
| Trip records/year | 20 M × 365 × 2 KB | ~15 TB/year |
| Location history (for fares, safety) | 250 K/s × 100 B × 86,400 | ~2 TB/day raw |
The workload is lopsided: a firehose of tiny, short-lived location writes, plus a much smaller stream of business-critical trip writes. Treat them as two different systems.
API Design
// Driver app (persistent WebSocket or frequent HTTP)
POST /v1/drivers/{id}/status { "status": "ONLINE" | "OFFLINE" }
WS location push: { "lat": 37.77, "lng": -122.41, "heading": 90, "ts": 1761830000 }
WS server -> driver: { "type": "OFFER", "tripId": "t_55", "pickup": {...}, "expiresIn": 15 }
POST /v1/trips/{tripId}/accept | /decline | /arrived | /start | /complete
// Rider app
GET /v1/drivers/nearby?lat=..&lng=.. -> car icons for the map
POST /v1/estimates { "pickup": {...}, "dropoff": {...}, "product": "standard" }
POST /v1/trips
Headers: Idempotency-Key: ...
Body: { "pickup": {...}, "dropoff": {...}, "product": "standard", "quoteId": "q_9" }
201: { "tripId": "t_55", "status": "REQUESTED" }
GET /v1/trips/{tripId} -> status, driver, live location
POST /v1/trips/{tripId}/cancel
- Persistent WebSocket connections make server-to-driver offers and live updates cheap; the HTTP endpoints are fallbacks.
- Trip-mutating endpoints are idempotent (keyed by trip ID + action) because mobile networks retry a lot.
Data Model
CREATE TABLE trips ( trip_id VARCHAR(32) PRIMARY KEY, rider_id BIGINT NOT NULL, driver_id BIGINT, status VARCHAR(16) NOT NULL, -- REQUESTED, MATCHED, ARRIVING, IN_PROGRESS, COMPLETED, CANCELLED pickup_lat DOUBLE, pickup_lng DOUBLE, dropoff_lat DOUBLE, dropoff_lng DOUBLE, city_id INT NOT NULL, fare_minor BIGINT, version INT NOT NULL, requested_at TIMESTAMP, completed_at TIMESTAMP ); CREATE TABLE drivers ( driver_id BIGINT PRIMARY KEY, vehicle_type VARCHAR(16), rating DECIMAL(3,2), city_id INT );
| Store | Contents | Why |
|---|---|---|
| In-memory geo index (Redis GEO or custom service) | driverId → current cell/position, availability | 250 K writes/s, TTL-based, no durability needed |
| Driver state KV | driverId → status (AVAILABLE / OFFERED / ON_TRIP), current tripId | atomic compare-and-set for assignment |
| Trips DB (sharded SQL / Cassandra / Spanner-like) | trip records and state | durable, sharded by city_id or trip_id |
| Kafka + object store / time-series | raw location stream and history | fares, ETA models, safety, analytics |
High-Level Design
flowchart LR DApp[Driver App] <-->|WebSocket| GWD[Connection Gateway] RApp[Rider App] <-->|WebSocket / HTTP| GWR[API Gateway] GWD --> LOC[Location Service] LOC --> GEO[(Geo Index in memory)] LOC --> K[[Kafka location stream]] K --> HIST[(Location History)] GWR --> TRIP[Trip Service] TRIP --> TDB[(Trips DB)] TRIP --> DISP[Dispatch / Matching Service] DISP --> GEO DISP --> ETA[ETA / Routing Service] DISP --> DS[(Driver State KV)] DISP -->|offer| GWD TRIP --> PRICE[Pricing Service] TRIP --> PAY[Payment Service]
- Drivers stream locations to the Location Service, which updates the in-memory geo index and publishes to Kafka.
- A rider requests a trip; the Trip Service persists it as
REQUESTEDand asks Dispatch for a driver. - Dispatch queries the geo index for nearby available drivers, ranks them by road ETA, atomically reserves the best one, and sends an offer through the driver's WebSocket gateway.
- On accept, the trip moves to
MATCHED; the rider subscribes to that driver's location updates. - On completion, Pricing computes the fare from the recorded route and Payment charges the rider.
Deep Dives
Location ingestion
At ~250 K updates/s, writing every ping to a durable database is wasteful: only the latest position matters for matching.
- Drivers hold a WebSocket to a connection gateway fleet; the gateway forwards pings to the Location Service partitioned by
driverId(or by city). - The Location Service overwrites the driver's entry in the geo index with a TTL (e.g. 30 s). A driver whose app dies simply ages out.
- Every ping is also appended to Kafka for downstream consumers (trip route recording, ETA model training, fraud detection), decoupling the hot path from storage.
- Clients adapt the update rate: more often during a trip, less often when idle or stationary, to save battery and bandwidth.
Geospatial indexing with geohash
"Find available drivers within 2 km" cannot scan a million rows. Space is divided into cells so a lookup checks only a few buckets.
- Geohash encodes (lat, lng) into a base32 string by recursively halving longitude and latitude ranges. Each extra character narrows the cell; nearby points usually share a prefix. Precision 6 gives cells of roughly 1.2 km × 0.6 km.
- Index drivers as
cell → set of driverIds. To query, compute the rider's cell plus its 8 neighbours (points near a border share no prefix with their neighbour across it), gather candidates, then filter by true distance. - If too few drivers are found, widen to a coarser precision or a larger ring.
- Redis
GEOADD/GEOSEARCHimplements this pattern (a geohash stored as a sorted-set score), which is a practical choice at moderate scale.
| Index | Strengths | Weaknesses |
|---|---|---|
| Geohash grid | Simple strings, easy to shard and cache | Uneven cell sizes, edge cases at borders |
| Quadtree | Adapts to density (splits busy downtown cells) | In-memory tree, harder to update at high write rates |
| Hexagonal grid (e.g. H3) | Uniform neighbour distances, good for surge areas | More complex library, still need neighbour rings |
Straight-line distance is only a filter. The ranking should use road ETA from a routing service: a driver 500 m away across a river can be 15 minutes out, while one 1.5 km away on the same road arrives in 3.
Matching and dispatch
- Fetch nearby available candidates from the geo index (say the 10 closest by distance).
- Ask the ETA service for road ETAs in batch and rank (ETA, driver rating, acceptance rate, vehicle type).
- Atomically reserve the top driver in the driver state store:
compare-and-set status AVAILABLE → OFFERED(tripId)with a short TTL. If it fails, someone else grabbed them; try the next. - Send the offer; the driver has ~15 s to accept. On decline or timeout, release the driver back to
AVAILABLEand offer the next candidate. - On accept, set driver
ON_TRIPand tripMATCHEDin the trips DB with a versioned conditional update.
At high demand, greedy one-at-a-time matching is suboptimal. Batching requests over a 1–2 second window and solving an assignment problem across all riders and drivers in an area reduces total pickup time.
The double-dispatch bug, where one driver receives two trips, comes from two matcher instances reading "available" and both assigning. The compare-and-set reservation (or a single matcher owner per geographic shard) is what prevents it; the geo index alone is only a read-optimized hint.
Trip state machine
REQUESTED -> MATCHED -> DRIVER_ARRIVING -> IN_PROGRESS -> COMPLETED
\ \ \
-> CANCELLED (rider/driver/no driver found, with fee rules)
- Each transition is a conditional update on
(trip_id, status, version), so duplicate or late events cannot move a trip backwards. - Every transition emits an event (outbox → Kafka) consumed by notifications, pricing, payments and analytics.
- Timeouts drive progress: no driver after N attempts →
CANCELLED (no supply); driver not moving toward pickup → reassign. - Fare is computed at
COMPLETEDfrom the recorded route (distance and time from the location stream) plus surge multiplier locked at request time, then handed to the payment system with the trip ID as the idempotency key.
Live tracking
Once matched, the rider needs the driver's position every few seconds. The Location Service publishes each update for on-trip drivers to a pub/sub channel keyed by trip ID; the rider's gateway subscribes and pushes over WebSocket. Clients interpolate smoothly between points so the car does not jump.
Scaling & Reliability
- Shard by geography: each city (or region of cells) is owned by a set of location and dispatch nodes; most interactions are local, so shards rarely talk to each other. Consistent hashing over cell IDs (Uber's Ringpop is an example of this idea) rebalances when nodes fail.
- In-memory state is rebuildable: if a geo index node dies, drivers' next pings (within seconds) repopulate it. Replicate for faster recovery, but durability is not required.
- Trips DB is durable and replicated; partition by
trip_idwith a secondary index on rider and driver for history. - Gateway fleet holds millions of WebSocket connections; use a connection registry (driverId → gateway node) to route offers.
- Surge pricing runs as a stream job over the request and supply events per hex cell, publishing multipliers every minute.
- Degradation: if ETA service is slow, fall back to straight-line estimates; if payments are down, complete trips and charge later.
- Multi-region: keep each city in its home region; fail over the city to a standby region, with drivers reconnecting and repopulating state.
Trade-offs
| Decision | Option A | Option B | Typical choice |
|---|---|---|---|
| Location storage | Durable DB per ping | In-memory index + Kafka log | In-memory + log |
| Spatial index | Geohash / Redis GEO (simple) | Quadtree or H3 (density-aware) | Geohash to start, H3 at scale |
| Driver transport | HTTP polling | Persistent WebSocket | WebSocket with HTTP fallback |
| Matching | Greedy nearest | Batched global assignment | Greedy at low load, batched at high demand |
| Driver assignment safety | Trust geo index | CAS on driver state | CAS |
| Sharding | By user ID | By geography | By geography |
Interview Tips
- Do the location-update math first; 250 K writes/s justifies an in-memory index.
- Explain geohash precision and the neighbour-cell lookup; know at least one alternative (quadtree or H3).
- Separate the "find candidates" step (geo index) from "rank" (ETA) and "reserve" (atomic CAS).
- Draw the trip state machine and say how idempotent transitions handle retries.
- Mention driver offer timeouts, declines and reassignment.
- Close with geographic sharding and how in-memory state is rebuilt after failures.