Ride-hailing looks simple from the back seat: you type a destination, see a price, tap “Book”, and a car turns up. Behind that tap, millions of drivers are moving through cities and reporting where they are every few seconds, and a dispatcher must pick the right one for you in about a second — not the closest by crow-flight across the Mutha river, but the one that can reach you fastest. It must never send the same driver to two riders, it must keep both phones showing each other’s live position, and it must cope when an IPL match ends at Chinnaswamy and 30,000 people open the app in the same five minutes, or when a late flight lands at Delhi’s Terminal 3 at 2 am. In this problem you design the core of Uber (or Ola, Lyft or Grab): quotes, matching, live tracking and the trip lifecycle.
Understand the problem
Ride-hailing is two very different systems glued together. One is a firehose of small, short-lived facts — “driver 42 is here now” — where losing a few is fine and freshness is everything. The other is a set of business transactions — “rider 7 is in driver 42’s car, owes ₹312” — where losing or duplicating one is a real incident. Saying this split out loud early shapes every later choice.
Functional requirements
- Riders should be able to see a fare estimate and request a ride from a pickup point to a destination, for a product such as auto, mini or sedan.
- The system should match the request with a nearby available driver. The driver gets an offer and accepts or declines within a timeout; if they decline or ignore it, the next-best driver gets it.
- Riders and drivers should be able to see each other’s live location and ETA during pickup and during the trip.
- Riders and drivers should be able to take a trip through its lifecycle — driver arrived, trip started, trip completed — with payment triggered at the end.
Non-functional requirements
- Scale. Design for about 30 million trips a day, roughly 1,000 ride requests a second at peak, and about 3 million drivers online at the busiest hour, each sending a location every 4 seconds — the estimates below derive these.
- Fast matching. Candidate search in under 50 ms at p99; the first offer reaches a driver within about 1–2 seconds of the request; most riders have a confirmed driver within 15 seconds (human accept time included).
- Correct assignment. A driver is assigned to at most one active trip, ever, and an accepted trip is never lost. Assignment and trip state need strong consistency.
- Fresh enough locations. Driver positions may be eventually consistent: a few seconds stale is fine, and an occasional lost update is harmless because the next one arrives 4 seconds later.
- Availability. 99.99% for requesting and matching (about 4 minutes of downtime a month). A failed server or data centre must not strand trips already in progress.
- Local spikes. Demand in one area can jump 5–10× in minutes — Diwali night, New Year’s Eve, a stadium emptying — without hurting other cities.
In the interview
Ask: “Which products — single rides only, or pooled rides too?”, “How many drivers are online at peak, and how often do they report location?”, “What matters more when supply is short — rider wait time or driver utilisation?” and “Is the price fixed upfront or metered?” The second question sizes the whole location system; the last one decides whether you need a quote with an expiry.
Out of scope
- Pooled rides. Matching several riders into one car is a harder optimisation; we treat it as a follow-up.
- Scheduled rides. Booking for 6 am tomorrow is a timer that starts this same flow later.
- Ratings, driver onboarding and documents. Ordinary CRUD services with no interesting real-time behaviour.
- Payment internals. We emit a “trip completed” event with the fare; capturing money is the payment system’s job.
- Maps and routing. We call a routing service for routes and ETAs; how it works is Design Google Maps.
The set-up
Plan the approach
Walk the functional requirements in order — quote and request, match, track, finish — with the simplest design that works, then spend your time on the deep dives: indexing moving drivers, choosing the best driver, double assignment, sharding by geography, durable trips and push. The estimates will show that the location stream dominates everything, so say the shape early: “a high-volume, lossy location pipeline kept in memory, and a low-volume, strongly consistent trip pipeline on durable storage.”
Core entities
- Rider and Driver. A driver has a vehicle, the products they can serve, and a dispatch status:
offline,available,offered,on_trip. - Location update. Driver ID, latitude, longitude, heading, speed, accuracy and timestamp. Ephemeral: the live copy is overwritten every 4 seconds; history goes to a log for receipts, safety and analytics.
- Fare quote. Pickup, drop, product, price, surge multiplier and an expiry, typically a few minutes.
- Ride (trip). Rider, driver, quote, pickup and drop, state, and a version number for conditional updates.
- Offer. One attempt to give a ride to one driver: offer ID, ride ID, driver ID, expiry.
Ride states: MATCHING → DRIVER_ASSIGNED → DRIVER_ARRIVED → IN_PROGRESS → COMPLETED
(MATCHING can end in NO_DRIVER; anything before IN_PROGRESS can end in CANCELLED)
API
Riders and drivers use ordinary HTTPS calls for actions, and a persistent stream (WebSocket or gRPC) for everything the server needs to tell them.
POST /v1/fare-quotes
{ "pickup": {"lat": 12.9716, "lng": 77.5946}, "drop": {"lat": 13.1989, "lng": 77.7068}, "product": "mini" }
200 OK
{ "quote_id": "q_71f", "price_inr": 812, "surge": 1.3, "pickup_eta_s": 240, "expires_at": "2026-10-27T21:45:00+05:30" }
POST /v1/rides
Idempotency-Key: 5c0e…
{ "quote_id": "q_71f" }
202 Accepted
{ "ride_id": "r_9a2", "state": "MATCHING" }
The driver app sends location over its stream, a few updates per frame on a bad network:
{ "type": "loc", "driver_id": "d_42", "seq": 8812,
"points": [ { "lat": 12.97161, "lng": 77.59460, "heading": 84, "speed_mps": 6.1, "ts": 1792571012 } ] }
The server pushes offers to drivers and updates to riders on the same kind of stream:
{ "type": "offer", "offer_id": "o_1c", "ride_id": "r_9a2", "pickup_eta_s": 180, "expires_in_s": 15 }
{ "type": "driver_location", "ride_id": "r_9a2", "lat": 12.9702, "lng": 77.5951, "eta_s": 170, "seq": 31 }
POST /v1/offers/o_1c:accept driver accepts; idempotent on offer_id
POST /v1/offers/o_1c:decline
POST /v1/rides/r_9a2:arrive driver at pickup
POST /v1/rides/r_9a2:start body: rider's 4-digit PIN
POST /v1/rides/r_9a2:complete
POST /v1/rides/r_9a2:cancel rider or driver; fee rules apply
Back-of-envelope estimates
Uber reported 3.1 billion trips in the fourth quarter of 2024 — about 33 million a day across rides and deliveries. Design the rides side for 30 million trips a day and you have headroom. (Method: back-of-envelope estimation.)
Ride requests. 30M ÷ 86,400 s ≈ 350 requests/s on average. Evening peaks run about 3× the daily average: 350 × 3 ≈ 1,050 requests/s. If each request is preceded by about five fare quotes (riders compare products and tweak the pickup pin), that is about 5,000 quotes/s, each needing a route ETA.
Drivers online. Assume a trip takes 20 minutes plus 5 minutes to reach the pickup, so 25 driver-minutes per trip: 30M × 25 min = 750M driver-minutes = 12.5M driver-hours on trips a day. If drivers spend about half their online time on trips, online time is 25M driver-hours a day, or 25M ÷ 24 ≈ 1M drivers online on average. At the evening peak, about 3× that: 3M drivers online.
Location writes. 3M drivers ÷ 4 s = 750,000 location updates/s at peak. At about 100 bytes each, that is 75 MB/s, or 600 Mbit/s of ingress — easy for the network, brutal for any database that must durably update an index per write.
Live index memory. 3M drivers × about 200 bytes (position, cell, status, vehicle, timestamp) = 600 MB. One machine could hold the whole planet’s live drivers in RAM. Memory is not the problem; write rate and fault tolerance are.
Trips in progress. By Little’s law, at 1,050 requests/s and 25 minutes (1,500 s) per trip, 1,050 × 1,500 ≈ 1.6M trips are in progress at peak — about half the 3M online drivers, which matches the utilisation we assumed.
Push fan-out. Each of those 1.6M riders sees the driver move every 4 seconds: 1.6M ÷ 4 ≈ 400,000 pushed updates/s. Persistent connections: 3M drivers plus 1.6M riders on trips plus riders browsing ≈ 5–6M concurrent streams; at about 50,000 per connection server that is roughly 120 servers.
Location history. About 1M drivers online on average × 21,600 updates a day each (86,400 ÷ 4) ≈ 21.6 billion points; at 100 bytes, about 2.2 TB a day. That goes to a log and cheap storage, never to the dispatch path.
Trip writes. 1,050 rides/s × about 8 state changes per ride ≈ 8,400 transactional writes/s at peak — modest for a sharded, strongly consistent database.
The numbers decide the design. Location traffic outnumbers ride requests by about 700 to 1 (750,000 ÷ 1,050), so live locations get an in-memory, sharded, lossy pipeline. Trips need under 10,000 writes a second, so they can afford a strongly consistent database. And 400,000 pushes a second need a dedicated connection tier.
Tip
When an interviewer gives you “drivers send their location every few seconds”, divide the online drivers by that interval before you draw anything. The result — hundreds of thousands of writes a second for data that is stale in seconds — rules out the obvious database design on its own.
High-level design
Build the system one requirement at a time; by the end it works for one city at modest traffic.
Riders should be able to see a fare estimate and request a ride
The rider app calls an API gateway, which authenticates the user and routes the call. A fare service asks a routing and ETA service for the route distance and duration, reads the current surge multiplier for the pickup area, and applies the product’s rate card. It stores the result as a quote with an ID and an expiry of a few minutes, and returns it.
When the rider taps “Book”, the app sends only the quote ID. The ride service checks the quote is valid and unexpired, creates a ride in state MATCHING in the trips database, and hands it to dispatch. The idempotency key on the request means a rider on a flaky connection who taps twice still gets one ride.
The surge table is written by a separate stream job that counts requests and available drivers per area every minute or so; the fare service only reads it. We come back to how surge areas are defined once H3 cells appear in the deep dives.
The system should match the request with a nearby available driver
Matching needs to know where every available driver is right now. Each driver app sends its GPS position every 4 seconds to a location service, which validates it (drops jumps of 2 km in 4 seconds), optionally snaps it to the road, and writes it into a live geo index: an in-memory structure that answers “which available drivers are near this point?” Nothing on this path waits for a disk: a position is worthless a few seconds after it arrives, and the next one is already on its way.
When the ride service hands over a new ride, dispatch asks the geo index for available drivers in the area around the pickup, asks the ETA service how long each would take to drive there, and picks the best. It then sends that driver an offer through a push gateway that holds a persistent connection to every online driver app.
The driver has a fixed window — say 15 seconds — to accept. A decline or silence moves the ride to the next driver on the ranked list, and a ride that runs out of candidates after three or four offers, or about a minute, ends as NO_DRIVER and the rider is told to try again.
Riders and drivers should see each other’s live location and ETA
Once a driver accepts, the same location stream that feeds the geo index has a second consumer. The location service publishes each update from a driver on a ride to that ride’s trip channel (a pub/sub topic keyed by ride ID). The push gateway holding the rider’s connection subscribes to the channel and forwards each position, so the car icon moves every 4 seconds. An ETA service recomputes time to pickup or drop-off every 20 seconds or so, and immediately if the driver leaves the expected route, and publishes the new ETA on the same channel. The rider’s own position flows the other way so the driver can find a rider who has walked to the next gate.
Riders and drivers should be able to take a trip through its lifecycle
A ride is a small state machine, and every transition is triggered by a person or a timer: the driver taps “Arrived”, the rider reads out a 4-digit PIN so the driver can start the trip, the driver ends it at the drop. The ride service applies each transition as a conditional write: “move ride r_9a2 from DRIVER_ARRIVED to IN_PROGRESS if its version is still 6”. A duplicate tap, or a stale retry from a phone that lost signal, fails the condition and changes nothing.
On COMPLETED, the ride service writes the final fare and publishes a trip.completed event; payments, receipts and driver earnings consume it asynchronously, so a slow card network never keeps a driver stuck in a finished trip.
You now have a working ride-hailing system for one city. Everything hard about it at Uber’s scale is hidden inside four boxes — the geo index, dispatch, the ride service and the push gateway.
Deep dives
How do we index millions of drivers who never stop moving?
The geo index does two jobs. It absorbs 750,000 position updates a second, and it answers about 1,000 searches a second of the form “available mini cabs within about 2 km of this point”. A write-heavy, read-light, throwaway workload is the opposite of what most databases are built for. (For the general toolbox, see geospatial indexes and proximity search.)
Bad: a table with a B-tree on latitude and longitude. Every 4 seconds, UPDATE drivers SET lat = …, lng = … WHERE id = 'd_42'. A composite index on (lat, lng) can only use the latitude range, so a “nearby” query scans a thin band right across the city and filters on longitude. Worse is the write path: in PostgreSQL every UPDATE writes a new row version and a WAL record, and because an indexed column changed, it adds new index entries too, leaving dead ones for vacuum. A well-tuned primary manages tens of thousands of such writes a second, so 750,000 would need dozens of shards — all fsyncing data that is obsolete 4 seconds later. A PostGIS R-tree (GiST) answers radius queries well, but moving a point means deleting and reinserting it in the tree. Spatial databases are the right tool for things that sit still, like the restaurants in nearby places search; they are the wrong tool for cars.
Good: geohash buckets, for example Redis GEO. A geohash interleaves the bits of latitude and longitude into one number, so nearby points usually share a prefix. Redis stores a geo set as a sorted set scored by a 52-bit geohash: GEOADD drivers:blr 77.5946 12.9716 d_42 moves a driver in place, and GEOSEARCH … BYRADIUS 2 km checks the cell and its eight neighbours. You gain an in-memory, off-the-shelf index that takes on the order of 100,000 updates a second per primary. You pay in four ways. Square cells have two kinds of neighbour — four at distance 1 and four at 1.41 — and shrink east–west away from the equator. Members cannot expire individually, so a driver whose phone died stays “available” until something calls ZREM. One key per city puts all of Bengaluru on one shard. And the set holds only positions, so filtering by status or product needs another lookup.
Great: H3 cells in a purpose-built in-memory index. Uber built H3 for exactly this marketplace. It tiles the Earth with hexagons at 16 resolutions, each cell about one seventh of its parent’s area. At resolution 8 a cell averages 0.74 km² with 0.53 km edges. The index keeps two maps in each shard:
- cell → set of available driver IDs, one set per product;
- driver → latest state: position, cell, status, product, last-seen time.
An update computes the driver’s cell, which takes microseconds. If it is the same cell as before, the index overwrites the position; if not, it moves the ID from one set to another. A res-8 hexagon is about √3 × 0.53 ≈ 0.92 km across, so a car at 30 km/h (8.3 m/s) takes about 110 seconds to cross one — fewer than 4% of updates change cell. A search takes the pickup’s cell and its k-ring: k = 1 gives 7 cells covering 7 × 0.74 ≈ 5.2 km², roughly a 1.3 km radius; k = 2 gives 19 cells, about 14 km² or a 2.1 km radius. It widens k until it has, say, 20 candidates, then filters by exact distance. Staleness is cheap: each entry carries its last-seen time, searches skip anything older than 15 seconds, and a sweeper evicts drivers silent for 30 seconds — about seven missed updates.
Why not a quadtree, which splits busy areas into smaller squares so dense Koramangala and the sparse outskirts both get sensible cells? It shines for points that sit still; with 750,000 moves a second its nodes split and merge constantly, and once you rebuild snapshots every few seconds, fixed cells are simpler. Hexagons also have one neighbour distance, so a k-ring fairly approximates a circle — the property Uber’s H3 write-up stresses for things that move between cells. And the hierarchy is free: the same driver counted at resolution 8 for dispatch can be rolled up to resolution 7 (about 5.2 km² a cell) for surge pricing.
| Approach | 750k updates/s | “Nearby” query | Neighbours | Stale drivers | Verdict |
|---|---|---|---|---|---|
| B-tree on (lat, lng) | Dozens of shards; WAL + index churn | Latitude band, then filter | — | Needs a sweep | Wrong tool |
| PostGIS R-tree | Delete + reinsert per move | Excellent | Exact geometry | Needs a sweep | Great for static places |
| Redis GEO (geohash) | Fine per shard; one hot key per city | 9-cell scan, built in | Two distances | No per-member TTL | Good start |
| Quadtree | Splits and merges churn | Adapts to density | Varies by level | Rebuild snapshots | Static, uneven data |
| H3 cells in memory | Overwrite; under 4% move cells | k-ring set lookups | One distance | Last-seen per entry | Recommended |
The recommendation: H3 resolution-8 cells in a custom in-memory index, sharded by geography — the next deep dives cover how it is sharded and how it survives failures. Durability comes from the drivers themselves: lose the index and it is full again within one 4-second reporting cycle.
How do we choose the best driver, not just the nearest one?
Bad: nearest by straight line. The index already gives you distances, so pick the smallest. Uber’s own description of its matching is that it began by asking “who’s closest?” and learned that closest is not quickest: traffic, overpasses and rivers get in the way. A driver 600 m away on the far side of the river, or on a flyover with no U-turn for 2 km, may need 12 minutes; one 1.5 km away on your side of the road needs 4.
Good: shortlist by distance, rank by road ETA. Take the 20 nearest available drivers from the k-ring and ask the ETA service for all 20 driving times in one call. Uber computes ETAs by running a routing engine over the road graph with live traffic and then correcting the result with a machine-learning model; its DeepETA write-up calls that model the highest-traffic model at Uber, with a budget of a few milliseconds. At 1,050 requests a second × 20 candidates, that is about 21,000 ETA evaluations a second from dispatch alone — fine for a dedicated, horizontally scaled service. Then score each candidate: ETA first, adjusted for heading, likelihood of accepting, and drivers about to finish a trip 200 m from your pickup. You gain minutes per ride. You pay an ETA call on the critical path, so give it a tight timeout and fall back to straight-line distance.
Great: batch matching over a short window. Greedy dispatch handles requests one by one, and each choice can steal the driver the next rider needed. Instead, collect the requests and available drivers in an area for about 2 seconds, build a matrix of ETAs, and solve the assignment that minimises total pickup time (or total time plus a penalty on the worst wait). The Hungarian algorithm solves an n × n assignment in O(n³) — milliseconds for a few hundred requests and drivers per area per window.
You gain lower average and worst-case waits, and drivers spend less time driving empty. You pay up to one window of extra latency, a solver that must re-run when an offered driver declines, and some complexity in explaining “why did my nearby car go to someone else?”. The gain is largest when supply is tight — exactly Diwali night — and close to zero when there are plenty of idle cars, so make the window adaptive: about 0 s when supply is plentiful, 2 s when it is scarce.
How do we make sure a driver is never assigned to two riders?
Three situations can put one driver into two rides: two dispatch workers for neighbouring areas both see driver d_7 near their shared boundary; a dispatcher retries a call whose first attempt had actually succeeded; or a driver taps “Accept” at 15.2 seconds, after the offer has moved on. This is the contention and double booking pattern, and it is where strong consistency earns its keep.
Bad: check, then set. Read d_7’s status, see available, write on_trip. Two dispatchers interleave and both win.
Good: reserve the driver with a lease. Before offering, take a short-lived reservation in Redis: SET offer:d_7 r_1 NX PX 15000. NX makes it succeed for only one dispatcher; the 15-second expiry frees the driver if that dispatcher dies. That stops two simultaneous offers cheaply. But Martin Kleppmann’s analysis of distributed locks draws the line: a lease like this is fine for efficiency (avoiding duplicate work) and not enough for correctness. Redis replication is asynchronous, so a failover can lose the key, and a dispatcher paused by garbage collection can act after its lease expired.
Great: make the assignment a conditional write in the strongly consistent store. The trips database holds the truth: each driver row has a current_ride and a version. Accepting an offer runs one transaction:
UPDATE drivers SET current_ride = 'r_1', version = version + 1
WHERE id = 'd_7' AND current_ride IS NULL AND version = 41;
UPDATE rides SET state = 'DRIVER_ASSIGNED', driver_id = 'd_7', version = version + 1
WHERE id = 'r_1' AND state = 'MATCHING' AND offer_id = 'o_1c';
-- commit only if both statements updated exactly one row
Exactly one transaction can move d_7 from version 41 to 42; the other updates no rows and goes to its next driver. The version acts as a fencing token, so a delayed or retried write carrying an old version cannot clobber a newer one. The accept call is idempotent on offer_id: a second tap returns the same result, and an accept for a withdrawn offer gets 409 offer_expired and a friendly “this ride was reassigned”. Keep the Redis lease as a fast filter in front, so the database sees about one attempt per offer instead of a stampede.
The rule generalises: locations may be eventually consistent; assignments may not. That is why the design has two stores with opposite trade-offs, the CAP and PACELC choice made per piece of data rather than once for the whole system.
A lock is not the source of truth
If a Redis lock is the only thing standing between a driver and a second trip, a Redis failover will eventually double-book someone. Use the lock to avoid wasted offers; let a conditional write in a strongly consistent database decide who actually gets the driver.
How do we split dispatch across machines, cities and failures?
The geo index and the dispatchers that search it must be spread over many machines, and the split should follow geography: a search for a pickup in Indiranagar should touch one machine, not fifty.
Bad: one global dispatch service with one index. 600 MB fits in one machine’s memory, but 750,000 updates a second on one box leaves no headroom, every request worldwide crosses an ocean to reach it, and one bad deploy stops every city at once.
Good: a fixed shard per city. Bengaluru’s drivers and requests go to one server group in the nearest region. You gain locality and isolation — Mumbai’s Diwali can’t slow Jakarta. You pay with lopsided load, since big cities are hundreds of times busier than small towns, so the busiest shard decides your hardware. Rebalancing is a manual config change, and city lines are fuzzy: Bengaluru airport is 35 km from the centre, and a trip from Gurugram to Noida crosses three municipalities.
Great: H3 cells on a consistent hash ring, with gossip membership. Shard by a coarse H3 cell — resolution 6 averages 36 km², so a city of about 700 km² is roughly 20 shard keys. Each driver’s fine resolution-8 cell maps to its resolution-6 parent, and consistent hashing maps that parent to a worker. This is how Uber’s Ringpop library worked: a hash ring with several replica points per node, plus a handle-or-forward rule — a worker that receives a location update or search for a key it doesn’t own forwards it to the owner. Membership comes from SWIM, a gossip protocol: each worker regularly pings a random peer; if no ack arrives, it asks a few others to ping-req that peer before marking it suspect, and membership changes ride along on the pings. Ringpop also compares membership checksums when forwarding and refuses requests while two nodes disagree, so callers retry until the ring converges.
What you gain:
- Throughput with headroom. 750,000 updates a second over 100 workers is 7,500 each — trivial for an in-memory map. You run that many workers for isolation and fast failover, not for raw speed.
- Self-healing state. When a worker dies, the ring hands its cells to the next worker, and the drivers’ next reports — at most 4 seconds later — refill its index. Nothing needs replicating, because the drivers are the replicas.
- Boundaries handled by scatter-gather. A k-ring that spans two owners, as on the right of the figure, becomes two parallel queries whose results are merged before ranking. A batch solver near a boundary may see a driver that the neighbouring solver also sees; the conditional write from the previous deep dive settles it.
- Hot spots split. A stadium emptying at 11 pm can make one resolution-6 cell 50 times busier than its neighbours. Shard that cell by its resolution-7 children instead, or give it more replica points, so its load spreads across several workers.
What you pay: during convergence two workers may both believe they own a cell for a few seconds — harmless for locations, fatal for assignments, which is another reason assignments live in the database. And gossip slows down as the ring grows. Uber’s push platform, also first built on Ringpop, later moved to a central coordinator (ZooKeeper with Apache Helix) because gossip convergence time grew with the ring and topology views drifted. For a few hundred workers either works; beyond that, keep the shard map in ZooKeeper or etcd and let workers watch it.
Losing a whole region. Serve each city from one active region with a warm standby. Location state is never replicated across regions: on failover, apps reconnect to the standby, and its index fills within a reporting cycle. Rides and workflow history, in contrast, are replicated synchronously, or stored in a multi-region database, so the 1.6 million rides in progress at peak continue where they were. More in multi-region and active-active.
How do we keep every ride correct when servers crash mid-trip?
A ride lasts from seconds to hours and is driven by timers: 15 seconds for an offer, 5 minutes of waiting before a no-show fee, an alert if a trip runs far past its ETA. Whatever owns those timers must survive restarts.
Bad: state and timers in the dispatcher’s memory. A deploy or a crash loses the 15-second timer, and the rider stares at “Finding your ride” forever. A retry that runs on another server may offer the ride twice.
Good: state in the database plus a sweeper. Store each ride’s state and deadline; a job polls every second for rides whose offer has expired and moves them on. It works, but timers lag by the polling interval, the logic for one ride is scattered across handlers and sweepers, and polling millions of rows for the few that are due is wasteful. The Cadence documentation makes this case against database-polling and timer-plus-queue designs: the state machine gets complicated, and there is no transaction spanning the queue and the database.
Great: one durable workflow per ride. Cadence, which Uber open-sourced, and its successor Temporal let you write the lifecycle as ordinary code, while the engine records every step’s result in an event history. If the worker running a ride dies, another worker replays the history to rebuild the ride’s state and carries on; timers are stored by the engine, not in a worker’s memory. More in workflow engines.
def ride_workflow(ride):
for driver in activities.ranked_candidates(ride): # dispatch search, retried
offer = activities.send_offer(driver, ride) # idempotent on offer_id
if wait_for(offer.accepted, timeout=seconds(15)): # durable timer
break
activities.withdraw(offer)
else:
return activities.finish(ride, "NO_DRIVER")
wait_for(ride.arrived) # minutes
if not wait_for(ride.started, timeout=minutes(5)): # no-show timer
return activities.cancel(ride, fee="NO_SHOW")
wait_for(ride.completed) # up to hours
activities.publish_completed(ride) # outbox → payments
You gain lifecycle code that reads like the business rules, durable timers, retries with backoff for every external call, and a full audit trail per ride. You pay for another critical cluster: at 1,050 rides a second and about 15 history events each, it records about 16,000 events a second, with 1.6 million open workflows at peak. Workflow code must also be deterministic so that replay reproduces the same decisions. And every activity — send offer, publish the completion event — must be idempotent, because a retry after a timeout may repeat work that had in fact succeeded. The completion event is written with a transactional outbox, so “ride completed” and “payment requested” can never disagree.
How do we push live updates to millions of phones?
Bad: polling. If 1.6 million riders each poll for the driver’s position every 2 seconds, that is 800,000 requests a second, almost all returning “nothing new”. Uber’s engineers wrote that polling calls once made up 80% of requests to their API gateway, draining batteries and choking patchy 2G and 3G networks, and making cold starts slow while dozens of features each fetched their state.
Good: a persistent connection per app, with push. Each app holds one long-lived stream to a connection gateway — WebSocket, server-sent events or a gRPC stream; see real-time connections. A registry maps each user to the gateway node holding their connection, and backend services publish to a user or a ride without knowing where the socket lives. The cost is state: 5–6 million open connections spread over about 120 servers, and a deploy or failure drops thousands of them at once.
Great: push with a delivery protocol. Uber’s RAMEN platform shows what “reliable” means on mobile networks:
- Sequence numbers. Each message on a stream gets an increasing number. A reconnecting client sends the last number it saw, and the server resends from there — an at-least-once guarantee, with the bookkeeping on the server.
- Heartbeats. The server sends a one-byte heartbeat every 4 seconds; a client that hears nothing for 7 seconds assumes the connection is dead and reconnects, instead of waiting for TCP to notice.
- Priorities, expiry and “latest only”. An offer outranks a promotional banner. Every message has a time to live, from seconds to 30 minutes. And for positions, only the newest message of a type matters: after a tunnel, the rider needs where the car is now, not the 3 positions it missed.
RAMEN began on server-sent events in 2015 and later moved to bidirectional gRPC streams, so acknowledgements travel instantly. Its published scale — more than 1.5 million concurrent connections and 250,000 messages a second — is the same order as our 400,000 pushes a second. Two operational rules matter as much as the protocol. Drain connection servers slowly during deploys, and make clients reconnect with jittered backoff; otherwise a restart becomes a reconnect storm. And deliver driver offers at high priority with a fallback to the operating system’s push notifications, because an offer that misses its 15-second window is a lost ride. The general pattern is in real-time updates.
The final design
Put the pieces together. Two pipelines share the diagram: a lossy, in-memory location pipeline along the bottom, and a strongly consistent ride pipeline across the top, meeting at dispatch.
Follow one ride through the system:
- Quote. The rider app asks the API gateway for a fare. The fare service gets a route ETA, reads the surge multiplier for the pickup’s cell, and stores a quote valid for 5 minutes.
- Request. “Book” sends the quote ID with an idempotency key. The ride service starts a trip workflow, which writes the ride in state
MATCHINGto the trips database. - Match. The workflow asks dispatch to find a driver, holding the 15-second offer timers itself.
- Search and rank. The dispatch worker that owns the pickup’s cell reads the k-ring from its in-memory H3 index (and from a neighbouring worker if the ring crosses a boundary), ranks about 20 candidates by road ETA, and batches requests for up to 2 seconds when supply is tight.
- Offer and assign. Dispatch takes a short lease on the best driver and pushes the offer over the driver’s stream. On accept, one transaction moves the driver and the ride to their new versions; if another dispatcher got there first, the next candidate is offered.
- Locations. Every 4 seconds the driver app sends its position through the stream gateway to the location service, which updates the geo index shard and appends to the event log for history and analytics.
- Tracking. While a ride is active, each position (and a fresh ETA every 20 seconds or on a reroute) is published on the ride’s channel, and the stream gateway pushes it to the rider with sequence numbers.
- Completion. The driver ends the trip; the workflow writes
COMPLETEDand, through the outbox, publishes the event that payments, receipts, driver earnings and the surge job consume.
Check it against the requirements. Candidate search is an in-memory k-ring lookup plus one batched ETA call, well inside 50 ms at p99. 750,000 location updates a second spread over about 100 workers, and a lost worker refills in 4 seconds. A driver can hold only one ride because assignment is a conditional transaction, not a cache entry. Rides survive crashes and region failover because their state and timers live in replicated, durable stores. And 400,000 pushes a second ride a dedicated connection tier that resumes by sequence number after every tunnel and flyover.
What interviewers look for by level
| Area | Mid-level | Senior | Staff |
|---|---|---|---|
| Location ingest | Stores lat/lng in a database table | Computes ~750k writes/s and keeps live positions in memory | Self-healing ephemeral state, staleness rules, history to a log |
| Geo indexing | “Query drivers within 2 km” | Geohash or Redis GEO with neighbour cells | H3 k-rings, resolution choice, why quadtrees churn |
| Matching | Nearest available driver | ETA ranking, offer timeout, next driver | Batched assignment, adaptive windows, supply-aware scoring |
| Correctness | “Use a lock” | Conditional write on the driver row, idempotent accept | Lossy vs strong data split, leases only for efficiency, fencing |
| Partitioning | Add more servers | Shard by city | Cells on a hash ring, gossip vs coordinator, hot cells, boundaries |
| Lifecycle | A status column | State machine with conditional transitions | Durable workflows, timers, outbox, region failover |
| Real-time | Polling | WebSocket or SSE push | Sequence resume, heartbeats, latest-only, reconnect storms |
Follow-up questions
What if we add pooled rides?
Matching becomes routing. A new request can be inserted into a car that already has a rider, so candidates include busy drivers, and each candidate is scored by the detour it adds for everyone on board — capped both absolutely and as a share of each rider’s trip, since 5 extra minutes is fine on a 40-minute ride and not on a 10-minute one. Hold pooled requests in a matching pool for up to about a minute to find good pairs, prune pairs whose directions disagree using the H3 cells of their pickups and drops, and let the batch optimiser choose among insertions. The driver row gains seat capacity, and the conditional write checks free seats instead of “no current ride”.
What if 40,000 people leave a stadium at once?
Demand jumps in a handful of resolution-8 cells around the gates. The surge job, which Uber’s H3 write-up describes as measuring supply and demand per hexagon, sees the ratio climb within a minute and raises prices there. That pulls nearby drivers in and spreads demand over time; smooth and cap the multiplier so it doesn’t flicker. Operationally, geofence pickup zones a short walk away so cars aren’t trapped in the crowd, rate-limit fare quotes per user, put requests in a visible queue rather than failing them, and split the stadium’s shard by finer cells so one worker isn’t doing all the work.
What if drivers queue at the airport?
At airports, fairness beats proximity: a driver who has waited 90 minutes in the holding lot should get the next airport ride before one who arrived 2 minutes ago, even if the newcomer is 100 m closer. Keep a strongly consistent FIFO queue per airport, ordered by geofence entry time, and drop drivers who leave the geofence. Dispatch for airport pickups takes the head of the queue instead of ranking by ETA. Use the flight-arrivals feed to warn drivers before a late wave of landings at 2 am.
What if GPS is noisy or missing?
Glass towers and underpasses make GPS jump by hundreds of metres. Reject impossible moves (above, say, 150 km/h between points), smooth with a Kalman filter, and map-match points to the road graph so the car is on a road, not in a lake. On the rider’s screen, animate the car along the route between updates so it glides rather than teleports, and compute the fare from the matched route so a bad fix can’t inflate the price.
What if the driver’s phone dies mid-trip?
The ride doesn’t: its state lives in the workflow and the trips database. The gap shows up as missing updates, and after a few minutes the workflow raises a safety check with both people. When the phone returns, the app resyncs from the server, the source of truth; if it never does, the trip ends at the destination with a fare estimated from the route, and support reviews it.
How to say it in the interview
“I’ll design ride-hailing for about 30 million trips a day: around 1,000 ride requests a second at peak and 3 million online drivers each reporting location every 4 seconds — 750,000 writes a second. That ratio drives everything: live locations are lossy, short-lived and huge, while ride assignments are small, rare and must never be wrong. So I build two pipelines.
The location pipeline keeps no database on the hot path. Drivers stream positions into an in-memory index of H3 resolution-8 hexagons, each mapped to a set of available drivers; a k-ring of 7 or 19 cells approximates a 1 to 2 kilometre radius. The index is sharded by coarse H3 cells on a consistent hash ring, and if a worker dies, the drivers repopulate its replacement within 4 seconds.
Matching: dispatch takes the k-ring around the pickup, shortlists about 20 drivers, ranks them by real road ETA rather than straight-line distance, and when supply is tight it batches requests for a couple of seconds and solves the assignment globally. It offers the ride to one driver at a time with a 15-second timeout.
Correctness: a short lease avoids duplicate offers, but the assignment itself is a conditional transaction on the driver’s version in a strongly consistent database, so a driver can never hold two rides, and accept is idempotent on the offer ID. Each ride runs as a durable workflow, so its timers — offer expiry, no-show — survive crashes.
Real time: every app keeps one stream to a connection tier, about 5 to 6 million connections, with sequence numbers, heartbeats and latest-only delivery for positions, which is roughly 400,000 pushes a second. Completed rides go out through an outbox to payments and to the surge job, which prices each hexagon from supply and demand.”
Concepts used in this design
- Geospatial indexes: geohash, quadtrees, S2 and H3 — choosing cells for millions of moving points.
- Proximity search — turning “nearby” into a set of cell lookups.
- Contention and double booking — two riders, one driver.
- Optimistic vs pessimistic locking — version-checked writes for assignment.
- Distributed locking — why a lease is a hint, not the truth.
- Consistent hashing — mapping cells to dispatch workers.
- Gossip protocol — SWIM-style membership for the dispatch ring.
- CAP theorem and PACELC — lossy locations, strongly consistent assignments.
- Workflow engines (Temporal) — durable rides and timers.
- Transactional outbox — completion events that never disagree with the database.
- Idempotency — safe retries for requests, offers and accepts.
- Real-time connections: long polling, SSE and WebSockets — the stream under every app.
- Real-time updates — pushing positions instead of polling.
- Multi-region and active-active — surviving the loss of a region mid-trip.
- Design food delivery — the same location machinery with prep times and batching.
References and further reading
- H3: Uber’s Hexagonal Hierarchical Spatial Index (Uber Engineering)uber.comWhy hexagons (one neighbour distance), 16 resolutions each with one seventh of the parent’s area, kRing, and surge computed from supply and demand per hexagon.
- H3 documentation: tables of cell statistics across resolutionsh3geo.orgAverage hexagon area and edge length per resolution — about 0.74 km² and 0.53 km at resolution 8.
- Ringpop: architecture, design and implementationringpop.readthedocs.ioSWIM ping and ping-req membership, a consistent hash ring with replica points, handle-or-forward routing and membership checksums.
- How Uber’s matching works (Uber Marketplace)uber.comMatching started with “who’s closest?” and moved to road-aware matching, because traffic, overpasses and rivers make closest differ from quickest.
- Uber’s Real-Time Push Platform (Uber Engineering)uber.comPolling was once 80% of gateway traffic; RAMEN’s sequence-numbered stream, 4 s heartbeats, 1.5M connections and 250k messages/s, and why gossip-based sharding was replaced.
- Uber announces results for fourth quarter and full year 2024investor.uber.com3.1 billion trips in Q4 2024, about 33 million a day across rides and deliveries; 11.27 billion trips in the year.
- DeepETA: How Uber predicts arrival times using deep learning (Uber Engineering)uber.comETAs from a routing engine plus an ML residual, used for fares and matching; the highest-QPS model at Uber with a few-millisecond budget.
- Cadence documentation: workflowscadenceworkflow.ioFault-oblivious, durable workflow functions with durable timers, and why database polling or timer-plus-queue designs get fragile.
- Redis GEOADD commandredis.ioGeo sets are sorted sets scored by a 52-bit geohash; radius queries check 1 + 8 areas; members are removed with ZREM.
- How to do distributed locking (Kleppmann, 2016)martin.kleppmann.comLocks for efficiency versus correctness, leases that expire under process pauses, and fencing tokens.