Marketplace & Booking
Design Uber / Ride Sharing
01
The problem
The problem
A rider opens the app, drops a pin and taps request. Within about three seconds the app should show a named driver, a car and a pickup time. From then until drop-off, both phones show each other moving on a map, and when the trip ends the fare is final and charged once.
Behind that flow sit four separate systems that interviews tend to blur together: a location index that absorbs a constant stream of GPS points, a dispatcher that picks and locks a driver, a live tracking channel that pushes positions to riders, and a pricing and ETA layer that decides what the trip costs and how long it takes. Each has a different consistency need, and treating them as one service is the most common mistake candidates make.
How do you find the closest available drivers to a point, when every driver moves every few seconds, and then give exactly one of them the job even though several dispatchers may be trying at once?
Rides are local. A request in Lagos never needs a driver in Lima. Partition everything by city and the global problem becomes several hundred small ones, the largest of which fits in the memory of a few machines.
02
What the platform must do
What the platform must do
- A rider sets pickup and drop-off, sees an upfront price and ETA, and requests a ride.
- The system offers the request to a nearby available driver, who accepts or declines within a short window, and retries with the next driver if needed.
- Online drivers report location every 5 seconds when idle and every 2 seconds on a trip.
- After a match, both apps see each other's live position, the pickup ETA and status changes.
- Trips move through a fixed lifecycle: requested, offered, accepted, arrived, in progress, completed or cancelled.
- Fares combine base, distance, time and a surge multiplier, and the rider is charged once at the end.
- From request to an offer on a driver's phone in under 2 seconds at p95.
- A driver can be assigned to at most one active trip, with no exceptions under retries or partitions.
- Position updates reach the rider within about 1 second of the driver sending them.
- Requesting and in-progress trips survive the loss of any single machine or availability zone.
- One city's overload or outage must not affect other cities.
Payments internals, driver onboarding and documents, ratings, shared rides and food delivery. Mention them in one sentence each and move on. Ask whether the interviewer wants pooled rides, because it turns matching from a nearest-neighbour search into a routing problem.
03
Sizing one busy city and the world
Sizing one busy city and the world
Assume 18 million trips a day across about 700 cities, 2.5 million drivers online at the global peak, and a largest city with 120,000 drivers online on a Friday night. About a third of online drivers are on a trip at any moment.
| Quantity | Working | Result |
|---|---|---|
| Trips per day | assumption | 18M |
| Average request rate | 18M / 86,400 s | about 210 / s |
| Peak request rate | 5x average, Friday evening across time zones | about 1,050 / s |
| Biggest city, peak requests | event exits can reach 10x its normal rate | about 90 / s normal, 900 / s burst |
| Location writes, global | 1.7M idle / 5 s + 0.8M on trip / 2 s | about 740,000 / s |
| Location writes, biggest city | 120K drivers, same mix | about 35,000 / s |
| Live index size | 2.5M drivers x 120 B (id, cell, lat, lng, heading, status, ts) | about 300 MB |
| Riders watching a car | 0.8M trips, plus about 0.3M pickups in progress | about 1.1M open streams |
| Push messages to riders | 1.1M streams x one update / 2 s | about 550,000 / s |
| GPS breadcrumbs stored | 0.8M on-trip drivers / 2 s x 48 B | about 19 MB / s, 1.7 TB / day raw |
| Trip records | 18M x about 4 KB with fare lines and events | about 72 GB / day |
Requests are tiny: about a thousand a second worldwide. Location writes are 700 times larger and nearly all of them are overwritten a few seconds later. That says the location index must live in memory and must not be a durable database. The durable history of where a car went belongs to a separate, append-only log.
35,000 writes per second and 15 MB of live state for the busiest city is well within a single sharded in-memory store. That is why splitting by city, rather than by some global hash, is the natural cut.
04
API design
API design
Phones lose signal in lifts and tunnels constantly. The socket carries live updates, but the source of truth is always the trip record. On reconnect the app fetches the trip, compares versions and only then trusts pushed events again.
05
Architecture overview
Architecture overview
flowchart LR
R["Rider app"] --> GW["API gateway"]
D["Driver app"] --> WS["Socket gateway"]
WS --> LI["Location ingest"]
LI --> IDX[("Hex index in memory")]
LI -. "breadcrumbs" .-> LOG[["Kafka: locations"]]
GW --> TS["Trip service"]
TS --> TDB[("Trip store")]
TS --> DP["Dispatcher"]
DP --> IDX
DP --> ETA["ETA service"]
DP --> DS[("Driver state, CAS")]
TS --> PS[["Trip channel pub/sub"]]
PS --> WS
GW --> PR["Pricing and surge"]The system is built as cells. A cell is a full copy of the real-time stack serving a group of nearby cities: its own socket gateways, location index, dispatcher, trip store and pub/sub. A routing layer maps each city to one cell, and a large city may get a cell to itself. Shared global services, such as accounts, payments and maps data, sit outside the cells and are called asynchronously wherever possible.
Two paths run through each cell. The location path is high volume and lossy by design: a driver's phone sends a point over its socket, ingest snaps it to a road, updates the in-memory hex index and appends the point to Kafka for history. Nothing on this path waits for a disk. The trip path is low volume and strict: creating a trip, offering it, accepting it and finishing it are durable state transitions with versioning.
They meet at the dispatcher. It reads candidates from the index, asks the ETA service for real drive times, and then claims a driver through an atomic compare-and-set on the driver state store. Every trip state change is published on a per-trip channel, which the socket gateway forwards to whichever phones are subscribed.
Holds about 60,000 persistent connections per node. Stateless apart from the sockets themselves, so a node loss means clients reconnect to another node, not lost data.
Driver positions bucketed by H3 cell, sharded by cell within the city. A ring query around the pickup returns candidates in well under a millisecond.
Builds the candidate list, ranks by true pickup ETA, sends one offer at a time and handles timeouts. Stateless; offer timers live in a durable delay queue.
A replicated database such as a sharded Postgres or Spanner-style store, keyed by trip ID, with a version column on every row. This is the only place trip truth lives.
Reads demand and supply counts per hex every 30 seconds and writes multipliers that quotes read. Quotes are signed so the price cannot change after confirmation.
Feeds trip distance for fares, ETA model training, fraud checks and the replay tool support uses for disputes.
06
Deep dive: finding nearby drivers
Deep dive: finding nearby drivers
The question the index answers thousands of times a second is: which available drivers are within a few minutes of this point? A database query on latitude and longitude ranges is too slow once each driver writes every few seconds. You want a scheme that turns a position into a short key, so that "nearby" becomes "these few keys".
| Scheme | How it works | Verdict for dispatch |
|---|---|---|
| Geohash | Interleaves latitude and longitude bits into a base-32 string. Prefixes form rectangles that shrink as the string grows. | Simple and supported by Redis. Cells change size with latitude, and two points across a cell boundary can have unrelated prefixes, so you always query the 8 neighbours. |
| Quadtree | Splits a region into four children whenever it holds too many points, so dense downtowns get small cells. | Adapts to density well. Every move can force splits and merges, which is awkward when 35,000 points change per second. |
| S2 | Projects the sphere onto a cube and orders cells along a Hilbert curve, giving 64-bit IDs with good locality. | Excellent for range scans and region covering. Cells are squares of varying shape, so distance to neighbours is uneven. |
| H3 | Covers the earth in hexagons at 16 fixed resolutions. Each cell has a 64-bit ID and exactly six neighbours at equal distance. | Our choice. Ring queries are uniform, which suits both nearest-driver search and surge zones. Uber open-sourced it for exactly this use. |
How the H3 index is laid out
We index drivers at resolution 9, where a hexagon is about 0.1 km2, roughly 175 metres across. For each cell the index holds a small set of driver IDs with their last point. A driver update does three things: compute the new cell from the snapped point, remove the driver from the old cell if it changed, and overwrite its entry in a per-driver hash with a TTL of 30 seconds. A driver who stops reporting therefore disappears from search without any cleanup job.
To search, take the pickup's cell and expand rings with gridDisk(k). Ring 1 is 7 cells, ring 3 is 37, ring 6 is 127. Stop as soon as you have about 15 available candidates or hit a maximum radius around 3 km. In a dense core that usually happens at ring 2; on the edge of a city it takes more rings, and the dispatcher widens the radius rather than giving up.
Straight-line distance is only a first filter. A driver 400 metres away on the other side of a river or a highway can be 12 minutes out. The dispatcher sends the candidate list to the ETA service and ranks by real pickup time, which is the number the rider actually experiences.
Sharding the index by driver ID spreads load evenly but forces every search to ask every shard. Sharding by a coarser parent cell (resolution 5 or 6) keeps a search on one or two shards. The cost is that a stadium or airport can heat one shard, which the surge section deals with.
07
Deep dive: dispatch and the driver claim
Deep dive: dispatch and the driver claim
flowchart LR REQ["requested"] -->|dispatcher sends offer| OFF["offered"] OFF -->|driver accepts, CAS wins| ACC["accepted"] OFF -->|decline or 10 s timeout| REQ REQ -->|no driver after 90 s| NOD["no_drivers"] ACC -->|driver at pickup| ARR["arrived"] ARR -->|rider on board| PROG["in_progress"] PROG -->|drop-off| DONE["completed"] ACC -->|either side cancels| CAN["cancelled"] ARR -->|either side cancels| CAN
Every trip and every driver has a small state machine, and every transition is a conditional write that names the version it expects. If the version has moved on, the write fails and the caller re-reads. This one rule handles nearly every race in the system: a rider cancelling while a driver accepts, two dispatchers working the same driver, a retry arriving after the first attempt succeeded.
Claiming a driver exactly once
A driver near a busy junction may be the best candidate for three requests at the same instant. If three dispatchers each send an offer, the driver gets three pings and two riders are disappointed. So before sending an offer, the dispatcher claims the driver:
Only the winner sends an offer. When the driver accepts, a second conditional write moves the driver to on_trip and the trip to accepted, both guarded by version. Because the two rows live in different stores, the trip service writes the trip first and the driver second, and a reconciler repairs any pair left half-done after a crash by checking offer expiry times.
In Redis the same claim is a short Lua script that compares status and version, then sets the new values. Either way, the lock is the state itself, so there is no separate lock service to fail or leak.
Choosing the offer window
Offers expire after 10 seconds. Shorter windows cause drivers who are mid-turn or glancing at the road to miss offers they would have taken; longer windows mean a rider waits through several silent drivers. With a typical acceptance rate around 80% and three candidates ready, the expected wait to an accepted driver stays under 15 seconds even with one decline.
In dense areas we also run batched matching: collect all requests and available drivers in a zone over a 2-second window and solve the assignment as a small bipartite matching problem that minimises total pickup time. Greedy nearest-driver assignment often gives one rider a great match and the next a terrible one; batching smooths that out at the cost of a 1 to 2 second delay.
A driver taps accept at 9.9 seconds, the request crosses a slow network and arrives at 10.4. The offer has expired and the dispatcher may already be claiming someone else. The accept call must fail with offer_expired, and the driver app must show "this request was reassigned". Never let a late accept overwrite a newer claim; the version check makes that impossible.
08
Deep dive: the live map
Deep dive: the live map
Once a trip is accepted, the rider watches the car approach. Polling would be the simplest design, but 1.1 million watchers polling every 2 seconds is another 550,000 HTTP requests per second, each paying for TLS, auth and routing to return a few bytes. Persistent sockets cost one connection per phone and a small frame per update.
flowchart LR
DA["Driver phone"] -->|"point every 2 s"| G1["Gateway node A"]
G1 --> IN["Ingest: snap and filter"]
IN --> IDX[("Hex index")]
IN --> CH[["Channel trip:t_7c03f9"]]
CH --> G2["Gateway node B"]
G2 --> RA["Rider phone"]
RA -->|"on reconnect"| API["GET trip state"]Driver and rider phones connect to whichever gateway node a load balancer picks. Each node subscribes to the channels of the phones it holds, so the two phones on one trip rarely share a node, and they do not need to.
Ingest publishes each snapped point to trip:{id}. Redis pub/sub or NATS fans it out to subscribed gateways. Channels are created on accept and removed on completion.
Raw GPS jumps between parallel streets in tall downtowns. Ingest runs a small Kalman filter and snaps points to the road graph, dropping fixes with accuracy worse than 50 metres.
With a point every 2 seconds, a car icon would hop. The rider app animates between points using speed and bearing, and corrects gently when the next point arrives.
When connections drop
Rider drops: the app reconnects with backoff, calls the trip endpoint, and resubscribes. Missed points are not replayed; only the latest position matters. Driver drops: the phone buffers points locally and uploads the batch on reconnect, so trip distance for the fare is still complete, while the rider sees a "connection lost" hint after 15 seconds without updates. Gateway node dies: its 60,000 phones reconnect to other nodes over the next few seconds. Jitter the reconnect delay on the client, or the surviving nodes take a thundering herd of TLS handshakes at once.
09
Deep dive: demand spikes and surge
Deep dive: demand spikes and surge
A stadium empties at 22:40. In five minutes, 9,000 people within 800 metres open the app. Three things go wrong together: demand outruns supply in a few hexagons, the shard holding those hexagons runs hot, and drivers already nearby are claimed instantly, leaving later requests with ETAs of 25 minutes.
Surge pricing as a control loop
Every 30 seconds, the pricing service counts open requests and available drivers per resolution-7 hexagon (about 5 km2) and smooths the ratio over the last few minutes. A multiplier comes from a capped curve of that ratio: 1.0 when supply covers demand, rising in steps of 0.1 up to a regulated maximum. Smoothing across neighbouring hexes stops prices jumping at a street corner.
The multiplier does two jobs at once. Some riders wait or choose a cheaper product, which trims demand. Drivers see a heat map and move towards the zone, which adds supply over 5 to 10 minutes. The quote a rider confirms is signed and honoured for a few minutes, so the price never changes after the rider says yes.
| Time | What is happening | System response |
|---|---|---|
| 22:38 | Normal: 140 drivers, 60 requests in the stadium hexes | Multiplier 1.0 |
| 22:41 | Requests jump to 1,100, idle drivers drop to 40 | Multiplier 1.6 after one smoothing cycle |
| 22:44 | Drivers from neighbouring zones head in, some riders wait | Index shard for the area at 3x normal writes |
| 22:50 | Supply reaches 300, open requests fall to 350 | Multiplier eases to 1.2 |
| 23:05 | Queue cleared | Multiplier back to 1.0 |
Keeping the hot shard alive
Ring searches from a thousand requests in one area all hit the same index shard. Three measures keep it healthy. First, coalesce searches: requests in the same resolution-9 hex within a 500 ms window share one candidate list. Second, give the shard read replicas, since a few hundred milliseconds of staleness is harmless for candidate search when the claim step re-checks status anyway. Third, for known venues and airports, run a virtual queue: riders join a line and drivers are matched in order at a pickup zone, which turns a geographic search into a FIFO.
New Year's Eve, festivals and big matches are on a calendar. Scale the city's cell up an hour before, warm caches, and push driver incentives in advance so supply arrives before demand does.
10
Deep dive: computing arrival times
Deep dive: computing arrival times
ETA shows up three times in one trip: ranking candidate drivers, quoting the price, and the countdown on the rider's screen. Calling an external maps API for each would mean several million paid calls an hour with 100 ms or more of latency each. Ranking alone calls for a dozen ETAs per request. So the ETA service is in-house and layered from cheap to precise.
flowchart TD Q["ETA request: origin, destination, time"] --> L1["Layer 1: straight line x city detour factor"] L1 --> L2["Layer 2: zone to zone travel time table"] L2 --> L3["Layer 3: road graph route with live speeds"] L3 --> L4["Layer 4: learned correction"] L4 --> OUT["ETA in seconds"]
Layer 1 multiplies the haversine distance by a per-city detour factor, usually around 1.3 to 1.5, and divides by a typical speed for that hour. It is free and used only to prune obviously far candidates. Layer 2 is a table of median travel times between resolution-7 hexes for each 15-minute slot of the week, built offline from trip history. It answers in microseconds and is good enough for rough price quotes.
Layer 3 runs a real shortest-path query on the road graph. Plain Dijkstra on a city graph with a few million edges takes far too long, so the graph is preprocessed with contraction hierarchies: nodes are ranked by importance and shortcut edges are added that skip over unimportant ones. A query then searches upward from both ends and meets in the middle, touching only a few thousand nodes, in about a millisecond. Live traffic is applied by updating edge weights from recent probe speeds; customisable variants of the technique let you refresh weights every few minutes without redoing the full preprocessing.
Layer 4 is a gradient-boosted model that predicts the error of layer 3 from features such as time of day, weather, pickup type (airport, mall, side street) and the driver's recent speed. Every completed trip is a labelled example, so the model improves as the platform grows, which is a real advantage over a new competitor.
In tunnels GPS disappears and the car seems to stop; ingest dead-reckons along the known road at the last speed for up to 60 seconds. When a driver leaves the planned route, ETA is recomputed from the new position on the next point rather than snapping the car back to the old path.
11
Decisions and their costs
Decisions and their costs
One persistent connection per driver, small frames every 2 to 5 seconds, and the server always has a fresh view without asking. Battery and data cost on the phone stays low.
Fresh and cheapPolling 2.5 million phones is impossible across mobile networks, and separate HTTPS posts repeat handshake and auth work hundreds of thousands of times a second.
Too much overheadSearches and updates take microseconds, stale drivers expire by themselves, and history is kept in Kafka for anyone who needs it. Losing the index loses at most a few seconds of positions, which the next updates refill.
Matches the dataDurable and familiar, but 35,000 writes a second per city into an indexed table with geometry columns is heavy, and every write is obsolete within seconds.
Wrong storage for churnDrivers see clear, exclusive offers, acceptance is easy to reason about, and batching keeps matches fair when demand is high.
Clean and fairFaster to fill in quiet areas, but most drivers lose the race and learn to ignore offers. It also creates the very conflicts the claim step is there to prevent.
Only for scarce supplyA driver can never hold two trips, a cancel and an accept cannot both win, and retries are safe. These writes run at about a thousand a second, so the cost is negligible.
Non-negotiableHigher availability on paper, but a double booking reaches real people at a real kerb before any repair runs. Save eventual consistency for locations and analytics.
Visible to users12
Failure modes
Failure modes
In dense downtowns, reflections off glass towers move a reported point 100 metres onto a parallel road. Riders see the car teleport, and ETA swings by minutes.
Filter with a Kalman model, snap to the road graph using heading and speed, and discard fixes whose reported accuracy is worse than 50 metres. Smooth the icon on the client.A driver's phone dies or loses signal, but their last position stays in the index. The dispatcher keeps offering them trips that time out after 10 seconds each.
Give every index entry a 30-second TTL refreshed by each update, and skip candidates whose last point is older than 15 seconds. Penalise drivers with repeated silent timeouts in ranking.A stadium, an airport or a festival sends most of a city's searches and updates to the shard that owns that area. Latency on that shard climbs and requests elsewhere in the city slow down with it.
Coalesce searches per hex, add read replicas for hot shards, split a hot parent cell into finer shards when write rates cross a threshold, and route venue pickups through a queue.Two dispatchers pick the same driver within a few milliseconds, or an accept retry lands after the first attempt succeeded. Without a guard, two riders both see the same car arriving.
Claim through a versioned compare-and-set before any offer is sent, make accept idempotent on offer ID, and run a reconciler that alerts on any driver with two non-final trips.A zone outage takes the primary of the trip store offline while thousands of trips are in progress. New writes fail and drivers cannot mark trips complete.
Replicate synchronously across zones with automatic failover. Driver apps queue state changes locally with timestamps and replay them once the store is back, and fares are computed from the location log, so they remain correct.Sixty thousand phones lose their sockets at once and reconnect in the same second, overloading the remaining nodes with handshakes.
Clients reconnect with randomised backoff, the load balancer limits new connections per node per second, and phones resume from the trip endpoint so no state is lost.13
Designs to avoid
Designs to avoid
Every update and search crosses the whole fleet, one city's spike slows the world, and any outage is global.
The database spends its life writing rows that are obsolete a few seconds later, and real transactional work queues behind them.
Rivers, one-way streets and highways make the nearest dot on the map a slow pickup, so riders wait longer than they would with the second-closest car.
Locks expire, holders crash, and you now have two sources of truth about who owns a driver.
14
How to run the interview
How to run the interview
Start with one rider and one driver. Walk the happy path end to end, then add concurrency. It gives the interviewer a map before you go deep.
Let the numbers pick the storage. Show that location writes outnumber trip requests by several hundred to one. That single ratio justifies an in-memory index and a separate durable path.
Name your spatial index and defend it. Geohash, S2 and H3 all work; say which, why, and how a ring search finds candidates. Vague "use a geo index" answers lose points.
Spend real time on double assignment. This is where depth shows. Explain the conditional write, the offer timeout and what happens to a late accept.
Partition by geography from the start. City cells contain failures and keep each problem small. Mention the awkward cases: cities near borders and airport trips across zones.
Separate the lossy path from the strict one. Locations can drop a point; trips and fares cannot. Interviewers like candidates who choose consistency per data type.
Bring up spikes before you are asked. Stadiums and rain storms are where these systems break. Surge as a feedback loop, coalesced searches and venue queues show operational sense.
15
Growing the design in stages
Growing the design in stages
Drivers post locations over HTTP, a spatial query finds nearby cars, and a transaction assigns them. Good enough to prove the market.
Live positions move to Redis with geospatial keys, drivers connect over sockets, and the trip store gets versioned writes. History moves to Kafka.
The stack is cloned into cells so one city's spike cannot hurt another. The index switches to H3 hexes, sharded by parent cell, and surge pricing reads the same hexes.
Cells span availability zones, trip stores replicate across regions for disaster recovery, matching batches requests in dense zones, and ETA gains its learned correction layer. The original PostGIS query survives only in the internal admin tool.
Watch and read
Try next
Free to read · better with landed
Whiteboard this with landed
landed runs it as a live system design round on the whiteboard and grades your trade-offs.

