Design a ride-hailing app
Open in the interactive app: practice, quiz, mock interview →In one minute
- The core is a real-time spatial index that absorbs a firehose of driver location updates and answers "nearest available drivers to this point" in milliseconds. It is a write-heavy, memory-resident, ephemeral data set, so do not put it in your primary database.
- Shard the index by geography (cells of a grid, geohash or H3), not by driver ID, so a nearest-driver query touches one or a few shards. Handle dense cities by making cells smaller or splitting hot cells.
- Matching is a state machine with a hold: offer a trip to one driver at a time (or a small batch), hold that driver so no other rider can grab them, and time out quickly. The hard correctness bug is double-dispatch.
- Trips and payments are durable and strongly consistent; locations are lossy and eventually consistent. Keeping those two worlds apart is the main architectural decision.
- Push updates to phones over long-lived connections. Polling at this scale wastes radio, battery and server capacity.
Requirements
Functional:
- Riders see nearby cars on a map and request a ride from A to B with a price estimate.
- The system matches the rider to a nearby available driver who accepts within a few seconds.
- Both parties see each other's live location during pickup and the trip.
- Trip completes, fare is computed, and payment is charged.
Non-functional:
- Match latency: a driver offer within about 1 to 2 seconds of request in normal conditions.
- Location freshness: driver positions in the index are no more than about 5 to 10 seconds old.
- A driver is never assigned to two trips at once.
- High availability per city; a failure in one city or region should not stop others.
Out of scope: surge pricing models, ETA and routing engines (treated as services), fraud, driver onboarding, pooled rides, and food delivery.
Back-of-envelope
Use round planning numbers rather than Uber's published figures: 5 million drivers online at peak worldwide and 30 million trips per day.
Location updates: each online driver's app sends a position every 4 seconds. 5M / 4 = 1.25 million updates per second at peak. Each update is about 100 bytes on the wire after framing (driver ID, lat, lng, heading, speed, timestamp, status), so ingress is about 125 MB/s, or roughly 1 Gbit/s. That is modest bandwidth but a very high message rate, so per-message CPU matters more than bytes.
Trip requests: 30M per day / 86,400 is about 350 per second on average. Rush hour and weekend nights concentrate demand, so plan for 5x peaks, about 1,750 requests per second, plus 10x that in "show nearby cars" map queries from riders with the app open: say 20,000 nearest-driver queries per second.
Index memory: 5M drivers x about 64 bytes per entry (ID, coordinates, cell, status, timestamp) is 320 MB. The whole live index fits in memory on a single machine; we shard it for throughput and fault isolation, not for capacity.
Location history (for trip receipts, disputes and analytics): 1.25M updates per second x 100 bytes x 86,400 seconds is about 10.8 TB per day if everything is kept. Keep only on-trip points at full resolution and downsample the rest: perhaps 2 to 3 TB per day, roughly 1 PB per year, and 5 PB over five years in compressed columnar storage. These are rough, and compression ratios on GPS tracks can be large.
Trips: 30M per day x about 2 KB per trip record = 60 GB per day, around 110 TB over five years. Small compared to location data, but it is the data that must never be lost.
API
// Driver app, over a persistent connection (WebSocket or gRPC stream)
DriverUpdate { driver_id, lat, lng, heading, speed, status, client_ts }
<- OfferTrip { trip_id, pickup, dropoff_hint, expires_at }
-> RespondOffer { trip_id, accept: bool }
// Rider app
POST /v1/trips body: { pickup, dropoff, product, idempotency_key }
-> 202 { trip_id, state: "MATCHING" }
GET /v1/nearby?lat&lng&product -> { drivers: [ { id, lat, lng, heading } ] }
<- TripEvent { trip_id, state, driver_location, eta } (pushed)
POST /v1/trips/{id}/cancel -> 200 { state }Data model
- Live driver index (in memory, not a database): maps
cell_id -> set(driver_id)anddriver_id -> {lat, lng, cell_id, status, last_ts}. Partitioned bycell_id(city or region shards own sets of cells). The cell key is chosen because the query starts from a location; the reverse map is needed to move a driver between cells on update. - trips: primary key
trip_id, columnsrider_id,driver_id,state,pickup,dropoff,fare,created_at,version. Partition bytrip_id, with a secondary index onrider_idanddriver_idfor history screens. Stored in a strongly consistent replicated database because trip state drives money. When Uber rebuilt its fulfillment platform (described in 2021) it chose Spanner for exactly this reason: the earlier design traded consistency for availability and resolved split-brain conflicts with last-write-wins (Uber Engineering, 2021). - driver_state:
driver_id -> {status: offline | available | offered | on_trip, trip_id, version}. This is the record that prevents double dispatch, updated with conditional writes (compare-and-set onversion). It lives with the dispatch shard in memory with a durable log, or in a store with conditional updates. - location_history: append-only, partitioned by
(driver_id, day)and clustered by timestamp, in a wide-column or object store. Only queried by trip ID ranges after the fact.
High-level design
flowchart LR D["Driver app"] --> GW["Connection gateway"] R["Rider app"] --> API["API gateway"] GW --> LOC["Location service"] LOC --> IDX["Geo index shards"] LOC --> LOG["Location log"] LOG --> HIST["History store"] API --> TRIP["Trip service"] TRIP --> TDB["Trips DB"] TRIP --> DISP["Dispatch"] DISP --> IDX DISP --> ETA["ETA service"] DISP --> GW TRIP --> PAY["Payments"] GW --> R
Write path for locations: the driver app keeps one long-lived connection to a gateway node. Each update goes to the location service, which validates it (drops out-of-order or impossible jumps), routes it to the index shard that owns the driver's new cell, and, if the cell changed, removes the driver from the old cell's set. The same update is appended asynchronously to a location log for history and analytics; that path can lag or drop without affecting matching.
Read path for a trip request: the trip service creates a trip in MATCHING with the rider's idempotency key, then asks dispatch for a driver. Dispatch queries the index shard for the pickup cell and its neighbours, gets the K nearest available drivers by straight-line distance, re-ranks the top few by road ETA from the ETA service, and attempts to hold the best one with a conditional update on driver_state (available to offered). If the hold succeeds, it sends an offer over the driver's connection with a 10 to 15 second expiry. Accept moves the trip to DRIVER_ASSIGNED and the driver to on_trip; decline or timeout releases the hold and dispatch tries the next candidate.
Deep dives
The spatial index: geohash, quadtree or hexagons
A geohash interleaves latitude and longitude bits into a string, so nearby points usually share a prefix. It is easy to compute and store, but cells near a boundary can be close in space and far apart in prefix, so you always search the cell plus its 8 neighbours. A quadtree adapts to density (split a node when it holds too many drivers), which is attractive for cities with a dense core, but every split and merge under a 1.25M-per-second update stream is coordination you have to get right. A hexagonal grid such as Uber's open-source H3 gives cells whose neighbours are all at the same distance from the centre, which makes ring searches and surge zones cleaner, at the cost of a less familiar library. H3 has 16 resolutions, each cell about one seventh the area of its parent, but a hexagon cannot be split exactly into seven smaller hexagons, so a child is only approximately inside its parent (Uber Engineering, 2018). Rolling cells up to a coarser level is therefore lossy in a way geohash and S2 prefixes are not.
This is not hypothetical history. Uber's dispatch system as described in 2015 sharded its supply index on Google S2 level-12 cells (roughly 3.3 to 6.4 km² each), wrote each driver location to its cell's shard plus a few replicas, and answered a pickup query by computing the cells that cover a circle around the rider (High Scalability, from Uber's talk).
The choice for the interview: a fixed-resolution grid of cells (geohash or H3) with ring expansion. Pick a cell size of roughly 1 to 2 km so a typical query touches the centre cell plus one ring (H3 resolution 8 averages 0.74 km² with edges of about 0.53 km; resolution 7 averages 5.2 km² (H3 cell statistics)), and expand rings outward until K drivers are found and the next ring is provably farther than the K-th result. Dense areas are handled by using a finer resolution there, not by dynamic tree splits.
Matching without double dispatch
Two riders a block apart can both see the same nearest driver. If two dispatch workers each send that driver an offer, one rider gets a phantom match. The fix is that every offer is preceded by an atomic hold on the driver's state: UPDATE driver_state SET status='offered', trip_id=? WHERE driver_id=? AND status='available', or the in-memory equivalent on the shard that owns the driver. Only the winner sends the offer. Holds carry an expiry so a crashed dispatcher does not strand a driver. Expiry creates its own race: a dispatcher that stalls past the deadline (a GC pause, a slow network) may still send its offer after another worker has re-held the driver. Give every hold a monotonically increasing version and make the accept path check it, the fencing-token idea (Kleppmann), so a stale offer cannot be accepted.
An alternative is to route all dispatch for a geographic region to a single-threaded owner (one actor per city or per cell group), which serialises decisions without locks and lets you do batch matching: collect requests for one or two seconds and solve a small assignment problem that minimises total pickup time rather than greedily serving each rider. Batching gives better global outcomes in dense areas at the cost of a short wait. A staff answer mentions both and picks per-region serialisation with a short batching window in busy cities and greedy matching elsewhere.
Ingesting 1.25 million updates per second
The update path must be cheap per message. Keep connections on gateway nodes that only frame and forward, batch updates per destination shard every few hundred milliseconds, and make the index update an in-memory hash-set move. Drop stale updates by comparing client_ts to the stored timestamp, since mobile networks reorder and replay. Expire drivers whose last update is older than a TTL (say 30 seconds) so a phone that lost signal does not keep receiving offers. Persistence of the live index is unnecessary: on shard restart, drivers repopulate it within one update interval. Uber's driver apps send a location every 4 seconds, and the geo index was designed for about a million writes per second (High Scalability). Shard ownership among index nodes can be handled with a gossip-based consistent-hash ring; Uber's open-source Ringpop was first used for exactly this geospatial service (Uber Engineering, 2016).
Down the stack
Layer 1The contract
A nearest-driver query returns up to K available drivers within radius R, each with a position no older than the TTL, sorted by distance, in a few milliseconds of server time. It may miss a driver who moved in the last few seconds. A hold on a driver is linearizable per driver: at most one active offer exists for a driver at any time. Trip state transitions are durable and ordered; a client retrying a request with the same idempotency key gets the same trip.
Layer 2The mechanism
Each shard holds a hash map from cell ID to a set of driver IDs and a second map from driver ID to its record. An update computes the new cell (a few floating-point operations and bit interleaving for a geohash, or the H3 index function), compares it with the stored cell, and does at most one set removal and one insertion. A query computes the centre cell, iterates ring by ring, computes exact distances (haversine, or planar distance in a local projection) for drivers in those cells, and keeps the best K in a bounded max-heap. Termination uses the rule that every cell in ring r+1 is at least r cell widths from the query point. Offers and holds are a small state machine per driver: available, offered (with deadline), on_trip, offline.
Layer 3The machine
A driver update is about 100 bytes, which fits in a single TCP segment and a single TLS record. At 1.25M messages per second, the cost is dominated by syscalls and wake-ups, so gateways read many messages per epoll_wait cycle and batch forwards. An index entry of 64 bytes is one CPU cache line; a ring-1 query on a 1 km cell in a dense city might touch a few hundred entries, which is tens of microseconds of CPU, mostly cache misses at around 100 ns each. Long-lived mobile connections cost memory per socket (kernel buffers of tens of KB), so a gateway holding 500,000 connections needs tens of GB just for socket state, which is why buffer sizes are tuned down. For scale, Uber's push platform (RAMEN) started on Server-Sent Events with acknowledgements and retries layered on top, peaked at over 70,000 messages a second on its first generation, and after a rebuild held more than 1.5 million concurrent connections and pushed over 250,000 messages a second, with a bidirectional gRPC protocol in beta (Uber Engineering, 2020). Cellular round trips are 50 to 300 ms and radios sleep between sends, so the 4-second interval is a battery trade-off, not a server one.
Why it's built this way
- Positions change every few seconds, so durable storage of the live index is wasted work; keep it in memory and rebuild from the stream.
- Queries start from a location, so shard and key by location cell, not driver ID.
- Drivers cluster in city centres, so cell size and shard ownership must follow density, not area.
- Two riders can race for one driver, so offers require an atomic per-driver hold.
- Mobile networks are slow and lossy, so use persistent connections, sequence numbers and TTL expiry instead of request-per-update.
Failure modes
- 10x load (New Year's Eve): request rate and map queries spike in a few cells. Reduce map refresh rates for riders, widen the batching window, and add read replicas of hot index shards for "nearby cars" display (which tolerates staleness) while keeping dispatch on the primary owner.
- Region outage: the live index is rebuilt in a standby region within one update interval once drivers reconnect there. Trip state must be replicated across regions synchronously or with a short, known loss window; in-flight trips continue because the driver app holds the trip ID and pickup locally and can re-sync. Uber took this further: dispatch periodically sent each driver phone an encrypted state digest, and after a datacenter failover the new region asked the phone for it when an update arrived for an unknown trip (High Scalability). The phones become the backup.
- Hot key (stadium or airport cell): one cell holds thousands of drivers and receives most queries. Use finer cells there, an airport queue model (first in, first out) instead of nearest, and split the cell's ownership across workers.
- Dispatcher crash mid-offer: holds expire after the offer timeout and the trip service retries dispatch for trips still in
MATCHING. - GPS jitter and spoofing: reject updates that imply impossible speeds; snap to roads for display.
In the wild
- Uber, 2016: the highest-QPS service skipped the clever index. Uber's geofence lookup service (which city and which zone is this point in) used neither an R-tree nor S2. It indexed city boundaries first and then the fences inside the matched city, cutting candidates from tens of thousands to hundreds, and swapped in rebuilt index segments under a read-write lock. On New Year's Eve 2015 it served 170,000 queries a second on 40 machines at about 35% CPU, with p95 under 5 ms and p99 under 50 ms (Uber Engineering).
- Uber, 2018: hexagons replace hand-drawn zones. Uber computes surge pricing from supply and demand measured per H3 hexagon in each city. Hexagons were chosen because every neighbour is the same distance away, which simplifies smoothing across gradients, and because they reduce quantization error as people move through a city (Uber Engineering).
- Uber, 2021: last-write-wins was not good enough for trips. Uber's fulfillment platform, the source of truth for trips and deliveries, dated from 2014 and accepted best-effort consistency with last-write-wins during split-brain. The rebuild moved it to Spanner's multi-region configuration with transactions across rows and tables, added a component to guarantee at-least-once post-commit side effects, and took over 100 engineers about two years (Uber Engineering).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Fixed grid or geohash | Simple, cheap updates, easy sharding | Boundary effects, uneven density | Default for live driver index |
| Quadtree | Adapts to density | Split/merge churn under heavy writes | Mostly static points of interest |
| H3 hexagons | Uniform neighbour distance, good for zones | Library dependency, less familiar | Surge zones, analytics, ring search |
| Greedy nearest match | Lowest latency, simple | Globally suboptimal in dense areas | Sparse markets |
| Batched assignment | Better total pickup time | Adds 1 to 2 s wait, more compute | Dense city centres at peak |
Leadership lens
- Build first: the connection gateway, location ingest into an in-memory grid index, and greedy dispatch with atomic holds. That is the minimum viable product. Batch matching, ETA re-ranking and adaptive cells come later.
- Ownership: a realtime platform team owns gateways and push; a marketplace team owns index and dispatch; a trips team owns the durable state machine; payments is separate with its own on-call.
- Cost drivers: gateway fleet sized by concurrent connections, location history storage, and ETA calls (often the most expensive per request). Cache ETAs per cell pair for short periods.
- Rollout: launch city by city, since each city is a natural blast radius and shard boundary. Shadow-run new matching logic and compare pickup times before switching.
- Ask in review: what is the maximum staleness of a position used in dispatch, how a hold is released when anything crashes, and how the system behaves when the ETA service is down (fall back to straight-line distance).
Check yourself
Two riders a block apart both see the same nearest driver. How does the design stop that driver from being dispatched twice?
Show answer
Every offer is preceded by an atomic hold, a conditional update that moves
driver_statefromavailabletoofferedonly if it is still available, so only the winner sends an offer. Holds carry an expiry so a crashed dispatcher does not strand the driver.With 5M drivers online, why is the live driver index sharded at all, and why is it not persisted?
Show answer
At about 64 bytes per entry it is only 320 MB and fits on one machine, so it is sharded for throughput (1.25M updates a second) and fault isolation, not capacity. Positions change every few seconds, so on a shard restart drivers repopulate it within one update interval and durable storage would be wasted work.
When would you choose batched assignment over greedy nearest matching?
Show answer
In dense city centres at peak, where collecting requests for one or two seconds and solving a small assignment problem minimises total pickup time. Greedy matching has the lowest latency and suits sparse markets, while batching adds a 1 to 2 second wait and more compute.
Why does the design use a fixed-resolution grid with ring expansion instead of a quadtree for the live driver index?
Show answer
A quadtree adapts to density, but every split and merge under a 1.25M-per-second update stream is coordination that has to be right. A fixed grid keeps each update a cheap in-memory set move, and dense areas are handled by a finer resolution rather than dynamic tree splits.
Sources and further study
Primary
- How to do distributed locking (Martin Kleppmann): why a lease with an expiry needs fencing tokens, the same problem as an expiring driver hold.
- Uber Engineering: Go geofence service: a measured, deliberately simple spatial lookup at 170k QPS.
- Uber Engineering: Fulfillment Platform re-architecture: why trip state moved to a strongly consistent store.
Systems docs
- H3 cell statistics by resolution: average area and edge length per resolution, for picking the cell size.
- H3 grid traversal API:
gridDiskandgridRing, the ring search used by nearest-driver queries, and their pentagon caveats. - S2 cell statistics: the area range of the level-12 cells Uber originally sharded on.
Books
- System Design Interview, Volume 2, Alex Xu and Sahn Lam, Ch 1 "Proximity Service" and Ch 2 "Nearby Friends": geohash and quadtree indexes and high-rate location updates over WebSockets.
- Designing Data-Intensive Applications (1st ed.), Martin Kleppmann, Ch 8 "The Trouble with Distributed Systems": process pauses, leases and fencing, the failure behind double dispatch.
Other curricula
- High Scalability: How Uber scales their real-time market platform: a summary of Uber's own talk, framing dispatch as supply and demand services over an S2-sharded index with Ringpop and phone-held state digests.
- Azure Architecture Center: Geode pattern: frames the per-region active-active deployment, and warns that it fits poorly when sessions hold temporary state, as an in-progress trip does.
Watch
- Engineering an H3-based Geospatial Data Platform at Uber (Uber Engineering): how Uber uses hexagonal cells for marketplace data.
- Design Uber/Lyft Location Index, Deep Dives (Jordan has no life): an interview-style deep dive on location indexing trade-offs.
Free resources
- H3: Uber's Hexagonal Hierarchical Spatial Index (Uber Engineering): why hexagons, and how Uber uses them for supply, demand and pricing.
- H3 documentation: resolutions, cell areas, and neighbour (ring) operations.
- Uber's Real-Time Push Platform (Uber Engineering): how Uber pushes trip and location updates to apps over persistent connections.
- Ringpop (Uber Engineering): consistent hashing and membership used to shard stateful services such as dispatch.
- Geohash (Wikipedia): the encoding, precision per character, and boundary caveats.