Design ride hailing (Uber / Ola)
The interviewer asks
“Design a ride-hailing service like Uber or Ola. Riders request a ride, see a fare and ETA, get matched to a nearby driver, track the driver live, and pay at the end of the trip.”
What a strong answer looks like
A strong answer is built around two hot paths. The first is location: hundreds of thousands of online drivers send GPS updates every few seconds, which are written to an in-memory geospatial index (H3 cells or geohash) partitioned by city and cell, not to a database on every ping. The second is dispatch: when a rider requests, the dispatch service finds candidate drivers in nearby cells, ranks them by road ETA (not straight-line distance), and offers the trip to one driver at a time with a timeout, using an atomic lock so a driver can never get two trips. Around these sit a trip service with an explicit state machine in a strongly consistent store, a pricing service that issues a signed upfront fare quote, real-time push over WebSockets for tracking, and an async payment and receipt pipeline. The deep dives are the location index, matching, the trip state machine, and handling city-level hot spots and network loss in Tier-2 cities.
Concepts to mention
1 Requirements
Functional
- Rider enters pickup and drop, sees vehicle types with upfront fare and pickup ETA
- Rider requests a ride; the system matches a nearby available driver within seconds
- Driver receives the offer and accepts or declines within a time limit
- Both sides see live location: rider sees driver approaching and the trip on a map
- Trip lifecycle: arrived, OTP-verified start, end; fare computed; payment by cash, UPI, wallet or card
- Ratings, trip history and receipts
- Cancellations by rider or driver with cancellation fee rules
Non-functional
- Match a driver within ~5-10 s for most requests in served areas
- Driver location visible to the rider with under ~3 s delay
- Handle ~500k concurrent online drivers sending location every 4 s (~125k writes/s), peak ~200k/s
- ~10M trips/day, ~1,000 ride requests/s at peak (office hours, rain, events)
- A driver is never assigned two trips at once; a trip is never lost or charged twice
- 99.99% availability for request and dispatch; degrade gracefully per city
- Works on flaky mobile networks: short disconnects must not cancel a trip
Out of scope (say it out loud)
- Surge pricing algorithm in detail (separate design)
- Map rendering and turn-by-turn routing engine internals (separate design)
- Driver onboarding, background checks and payouts
- Pooling / shared rides (mention as a follow-up)
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/v1/fare-quotes | pickup, drop, vehicle types → per type: fare, ETA, quote_id (signed, valid ~5 min) |
POST/v1/trips | Request a ride with quote_id and payment method. Header Idempotency-Key. Returns trip_id in state MATCHING |
GET/v1/trips/{trip_id} | Trip state, driver details, ETA; used for recovery after reconnect |
POST/v1/trips/{trip_id}/cancel | Cancel by rider or driver with reason; applies fee rules |
POST/v1/driver/location | Batch of GPS points (lat, lng, accuracy, speed, heading, ts); usually over the driver's persistent connection |
WS/v1/stream | Persistent channel: offers to drivers, driver location and trip events to riders |
POST/v1/offers/{offer_id}/accept | Driver accepts the offer; succeeds only if the offer is still held for this driver |
POST/v1/trips/{trip_id}/start | Driver starts the trip after entering the rider's OTP |
4 Data model
driver_locationsIn-memory geo index (Redis GEO / custom service), sharded by city + H3 celldriver_id → latlngheadingstatus (available, offered, on_trip)vehicle_typeupdated_at; cell → set of driver_idsOverwritten on every ping; entries expire if no ping for ~30 s. Never written to a relational DB on each ping.
driver_stateRedis (with persistence) or the dispatch service's memorydriver_idstatuscurrent_trip_idoffer_lock (trip_id with TTL)last_seenAtomic SET NX with TTL as the offer lock, so a driver is held for one trip offer at a time.
tripsPostgres / CockroachDB / Spanner-like store, sharded by city or trip_idtrip_id (PK)rider_iddriver_idcity_idstatequote_idfarepickupdroprequested_attimestamps per stateversionStrong consistency and conditional updates for state transitions. Index on rider_id and driver_id for history.
trip_eventsKafka → S3trip_ideventatactorpayloadEvent stream for billing, analytics, ETA model training and support tools.
location_trailsKafka → S3 / Cassandratrip_id or driver_idtslatlngspeedUsed for fare (distance), disputes, safety and map-matching. Cassandra partitioned by (trip_id) if support tools need fast reads.
fare_quotesSigned token + Redisquote_idrider_idroutevehicle_typefaresurge_multiplierexpires_atsignatureStateless signed quote lets any node validate it; Redis copy for analytics and single-use enforcement.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Request and match a ride
- 1Rider asks for a quote; pricing gets the route and ETA from the routing engine, applies base fare, time, distance and the cell's surge multiplier, and returns a signed quote valid for 5 minutes.
- 2Rider requests with the quote_id. Trip service validates the signature and expiry, creates the trip in MATCHING (idempotent on the key), and calls dispatch.
- 3Dispatch takes the pickup's H3 cell and its rings (k=1, then k=2) and gets available drivers of that vehicle type from the geo index: say 25 candidates.
- 4It asks the routing engine for road ETAs from each candidate to the pickup (a one-to-many matrix call) and ranks by ETA, acceptance probability and driver fairness.
- 5For the best driver it sets offer_lock = trip_id with SET NX and a 15 s TTL. If the lock fails (driver is held by another trip), it moves to the next candidate.
- 6The offer goes to the driver over WebSocket (with a push notification fallback). The driver taps accept within 15 s.
- 7Accept goes to the trip service, which runs a conditional update: state MATCHING → DRIVER_ASSIGNED, driver_id = X, only if the trip is still MATCHING. The driver's status becomes on_trip.
- 8If the driver declines or times out, the lock expires or is released and dispatch offers to the next candidate; after N tries or 60-90 s, the rider is told no drivers are available.
- 9The rider receives the driver's details and live location as trip and location events are pushed through the WebSocket gateway.
During and after the trip
- 1Driver arrives (geo-fence check), rider shares a 4-digit OTP, driver enters it; trip moves to IN_PROGRESS.
- 2Driver app sends GPS points; the location service tags them with trip_id and streams them to Kafka for the trail and for the rider's live map.
- 3At drop, the driver ends the trip. Trip service computes the final fare from the quote (upfront fare) and adjusts only for rules like a changed destination or long waiting.
- 4Trip moves to COMPLETED and emits an event. Payment workers charge the UPI/card/wallet with idempotency key = trip_id, or record cash collected against the driver's ledger.
- 5Receipts and rating prompts are sent; the trail is archived to S3.
7 Deep dives
Location index for 125k writes per second
The live location of drivers is the most write-heavy data and the least durable: a driver's position 30 seconds ago is worthless. So keep it in memory and never write each ping to a disk-based database. Divide the map into cells. H3 (Uber's hexagonal grid) is a good choice because every hexagon has six equidistant neighbours, which makes 'search the ring around this cell' simple; geohash with neighbour lookups also works. At H3 resolution 8 a cell is about 0.7 km², and resolution 9 about 0.1 km². On each ping, the location service computes the cell; if it changed, it moves the driver from the old cell's set to the new one, and it always overwrites the driver's latest position. Shard by city, then by cell, so Bengaluru's load never affects Pune. A nearby query reads the pickup's cell and rings outward until it has enough candidates, then filters by status and vehicle type. Pings also go to Kafka for trails and analytics, decoupled from the index. Clients send every 4 s while active, less often when idle or stopped, and batch points after a network gap; the server discards out-of-order points older than the latest it has. Stale drivers drop out of the index via TTL.
Matching: road ETA, offers and no double assignment
Nearest by straight line is often wrong: a driver 500 m away across a flyover or a one-way road may be 8 minutes away. Rank candidates by road ETA from the routing engine (one-to-many matrix), then adjust for acceptance likelihood and fairness. Two dispatch styles exist. Greedy: match each request immediately to its best available driver; simple and fast. Batched: collect requests in a city zone for 1-2 seconds and solve an assignment problem across requests and drivers, minimising total pickup time; this gives better global results in busy areas, and Uber has described moving to batched matching. Mention both and choose batched for dense cities. Double assignment is the classic bug: two dispatch workers offering the same driver to two trips. Prevent it with an atomic lock per driver (Redis SET driver:X:offer trip_id NX PX 15000) and by making the accept a conditional update on the trip. The offer is a lease: if the driver does not answer before the TTL, the lock expires and the driver is free again. Keep offers sequential per driver but allow dispatch to prepare the next candidate while waiting, to cut matching time.
Trip state machine and consistency
Trips move through states: MATCHING → DRIVER_ASSIGNED → DRIVER_ARRIVED → IN_PROGRESS → COMPLETED, with CANCELLED_BY_RIDER, CANCELLED_BY_DRIVER and NO_DRIVER_FOUND branches. Store trips in a strongly consistent database and make each transition a conditional write: UPDATE trips SET state = 'IN_PROGRESS', version = version + 1 WHERE trip_id = ? AND state = 'DRIVER_ARRIVED'. This settles races like a rider cancelling at the exact moment the driver accepts: only one update wins, and the loser gets a clear error that the app can show. Every transition emits an event (via an outbox, so the event and the state change commit together) for payments, notifications and analytics. Clients are not the source of truth: after a reconnect, the app calls GET /trips/{id} and redraws from server state. Partition the trips table by city or by trip_id; queries by rider or driver for history use secondary indexes or a separate read model. Cross-region: a trip lives in the region that serves its city, which keeps writes local and fits Indian data localisation preferences.
Real-time tracking over unreliable mobile networks
Drivers in Tier-2 and Tier-3 cities and riders in basements lose connectivity often. Use persistent connections (WebSocket or MQTT) for both apps, with heartbeats every 10-20 s and quick reconnect with jitter. The WebSocket gateway is stateful: it maps user_id to the connection on a specific node, stored in a shared registry (Redis), so the trip service can route a message to the right gateway node. For the rider's live map, subscribe the rider to the assigned driver's location topic; the gateway forwards updates at most every 2-4 s and the app interpolates movement between points so the car moves smoothly. If a driver goes silent for 30-60 s during a trip, do not cancel: mark the location as stale, keep the trip, and let the driver app buffer GPS points and upload them on reconnect, which also keeps the distance and fare correct. Use SMS or push as a fallback for critical events like 'driver arrived' and offers. Keep message payloads small (binary or compact JSON) because data costs and bandwidth matter for drivers.
8 Trade-offs
Greedy vs batched matching
Greedy is simpler and gives the fastest single match but makes poor global choices when demand is high. Batching over a 1-2 s window improves total pickup ETA and driver utilisation in dense areas at the cost of a small delay and more complex code. Use greedy in sparse areas and batched in dense zones.
Redis geo index vs a custom in-memory grid service
Redis GEO (geohash-based sorted sets) is quick to build with and good up to large scale with sharding by city. A custom service holding an H3 grid in memory gives full control over ring searches, filtering and sharding, which large players choose. Start with Redis, partitioned by city, and move when needed.
Upfront fare vs metered fare
Upfront fares give riders certainty and are easier to dispute, but the company carries the risk of traffic and route changes. Metered fares are fair to drivers but uncertain for riders. Most apps now quote upfront and only recompute on clear changes (destination change, long waits).
9 Bottlenecks
Event hot spots
After a cricket match at Chinnaswamy, tens of thousands of requests come from a few cells. One shard holds those cells, and dispatch for that zone becomes hot. Split hot cells to a finer H3 resolution, run batched matching per zone, queue requests with an honest wait estimate, and rely on surge to balance demand.
Routing engine ETA calls
Each dispatch needs a one-to-many ETA matrix; at 10x requests this dominates cost. Pre-filter candidates by straight-line distance, cap the matrix size (say 20), cache ETAs between cells for a minute, and use a fast approximate ETA model for ranking with exact routing only for the final pick.
10 Failure handling
Location service shard down for a city
New matches in that city stop until a replica takes over; drivers reconnect and re-send their location, so the index rebuilds in seconds from fresh pings (no backup needed for such short-lived data). Ongoing trips are not affected because trip state lives in the trips database.
Driver app loses network mid-trip
The trip continues; the app buffers GPS points and uploads them on reconnect. If the driver cannot end the trip, support tools or the app can end it later with the buffered trail. The fare uses the trail, not the live stream.
Payment failure after trip
The trip stays COMPLETED with payment PENDING. Workers retry with the same idempotency key; if it fails, the amount becomes outstanding on the rider's account and is collected before or with the next ride.
11 What impresses
- Estimating location writes (500k drivers / 4 s) and concluding they belong in memory, not a database
- Comparing greedy and batched matching and when each fits
- Preventing double assignment with a lease lock and a conditional accept
- Handling the rider-cancel vs driver-accept race explicitly
- Designing for flaky networks: buffered GPS, server-side truth on reconnect, SMS fallback
- Partitioning everything by city to isolate failures and hot spots
12 Mistakes
- Storing every GPS ping in Postgres or MySQL and querying with lat/lng ranges
- Matching by straight-line distance only
- No mechanism to stop a driver getting two trips
- Polling the server every second for tracking
- Cancelling trips when a phone goes offline briefly
- One global dispatch service with no partitioning
13 Follow-ups
Lessons this design uses
Practice questions
- There is no partition right now, yet you still have to choose between latency and consistency. Explain with PACELC and pick consistency levels for a ride-hailing app on Cassandra.
- In a ride-hailing app, millions of drivers send their location every 4 seconds. How do you partition the live location data so that 'find drivers near this rider' is fast?
- The interviewer asks you to design Uber. It is a huge product. Walk me through how you narrow the scope and which requirements you would lock in.
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.