You tap “Request ride”. A few seconds later a driver three minutes away accepts, and a small car icon starts moving toward you on the map. It feels like one tap and one answer.
Behind it, an assumed half a million cars are moving. Each one reports its position every few seconds. In that moment the system must find the drivers near you, work out which one can really reach you soonest by road, ask that driver, and make sure nobody else is promised the same car.
Three words sum up the problem: near, now, claimed once. This lesson builds the system around one rule: index by place, rank by road, claim once.
- The interview framework and estimation primer.
- Design a chat system for held connections, design a rate limiter for atomic checks, and design a web crawler for leases, because all three return here.
Before You Start#
How did the crawler make sure a fetcher that crashed did not lose its URL? It handed URLs out with a lease: a claim with a time limit, so unanswered work came back. An offer to a driver is the same idea, with a person on the other end.
- Finding nearby drivers is a spatial lookup: cut the map into cells and search the rider’s cell and its neighbours. Cell size trades cells read against drivers examined.
- Keep only the latest position of each driver in memory, because the live set is small and the writes are the problem.
- Rank a few candidates by road travel time, not straight-line distance, and match riders in short batches when drivers are scarce.
- An offer is a fast claim with an expiry, and the accept is a conditional write on a durable ride record, so a driver is never promised to two riders and never stays blocked.
- A ride is a state machine of stored events, and each transition is applied at most once because it checks the version it expects.
Design a ride-sharing service end to end in 45 minutes: state your assumptions, index drivers by location, ingest their updates, rank candidates by road time, offer rides with expiring claims, choose consistency per component, and keep ride state correct. The production side (failures, hot cells, regions, surge, payments, privacy, targets) is in the follow-ups lesson.
Step 1: Pin Down the Problem#
Ask before drawing. Each answer you assume becomes a number:
- One city or many? (Assume many, but a ride never leaves its city, so cities can be independent shards.)
- One vehicle type, or several? Pooled or scheduled rides? (Assume one type, none of the others.)
- Does the system pick the driver, or does the rider browse cars? (Assume the system picks.)
- How fresh must a driver position be? (Assume a few seconds.)
- Are pricing, payments and fraud in scope? (Assume not here. The follow-ups lesson covers them.)
Functional requirements:
- Drivers go online and share their location.
- A rider requests a ride from a pickup point to a destination.
- The system finds a nearby driver, offers the ride, and on acceptance links the two.
- Both see the ride progress live, until it is completed or cancelled.
Out of scope (say these out loud): pricing and surge, payments, fraud, driver onboarding and ratings, pooled and scheduled rides.
Non-functional requirements, each with a number. These are assumptions you announce:
- Fast: a rider sees a driver matched within a few seconds when drivers are nearby (assume the median under 10 s).
- Correct: a driver is never matched to two riders at once, and a rider is never charged for two rides from one tap.
- Available: a location service hiccup must not stop rides already in progress.
- Scale: assume 500,000 drivers online at peak across all cities. Step 4 turns that into numbers.
Step 2: Entities, API and Data Model#
POST /v1/rides
{pickup, dropoff, request_id}
201 new ride
200 repeat of a request_id
409 same id, different body
422 pickup outside area
429 too many requests
GET /v1/rides/{id}
POST /v1/rides/{id}/cancel
GET /v1/rides?cursor=...
(history, paged)
Driver connection (held):
location {lat, lng, heading,
speed, ts, seq}
POST /v1/offers/{id}/accept
POST /v1/offers/{id}/decline
200 matched
409 OFFER_EXPIRED or takenAll calls carry a bearer token. A rider may read only their own rides, and a driver sees the pickup only once an offer is made.
Two fields matter most. request_id is made by the rider’s app, so a retry of the same tap never creates a second ride; a repeat with a different body is a bug, so it fails with 409 (payment APIs such as Stripe’s compare repeat requests with the original for the same reason). expires_at on an offer means a driver who does not answer stops blocking the ride.
Data model#
| Data | Where | Why |
|---|---|---|
Live driver position: driver:{id} and cell:{id} sets | In memory, per region, expiring after about 30 s | Overwritten every 4 s, rebuilt in seconds |
Ride: ride_id, request_id, rider, driver, state, version, pickup, dropoff | Durable store, partitioned by city, conditional writes | The record that must never be wrong |
Ride events: ride_id, seq, type, time | Append-only, same store | The source for both apps and for audits |
Offer: offer_id, ride, driver, expires_at, status | Memory with expiry, outcome copied to events | Short-lived, but the outcome is history |
| Location trail for trips | Stream, then cheap object storage | Disputes and analysis, never read on the hot path |
Access patterns drive the choices: ride by ride_id, ride by (rider, request_id), the active ride of a driver. Any store with single-row conditional writes works, such as a relational database or DynamoDB with condition expressions, where a write goes ahead only if the condition holds.
Step 3: A Design That Works#
Start with the obvious design. Drivers report their positions into a table. When a rider asks for a ride, the server computes the distance from the rider to every driver, sorts, and takes the nearest few.
It is correct, and for a small town it is fine. It is where many candidates stop.
Assume a city has 20,000 drivers online and a rider is in the east of it. How many drivers does the server check, and how many of them could possibly be the answer?
All 20,000 (an assumed city size) are checked, and the answer is almost certainly among the few dozen nearest. Most of the work is wasted on drivers on the other side of the city. A toy test shows the gap: a grid index examined ≈ 338 drivers per request instead of 20,000, and returned the same five drivers every time. The rest of the design is about making “near” cheap.
Step 4: Estimate What Matters#
Assume 500,000 drivers online
Update every 4 s:
500,000 / 4 = 125,000/s
Per update ~100 B on the wire:
125,000 x 100 B = 12.5 MB/s
= 100 Mbit/s inbound
Latest position: 100 B each
500,000 x 100 B = 50 MB
Rides: 5M a day / 86,400
≈ 58/s, peak ≈ 290/s
Rides in progress (20 min each):
5M x 1,200 s / 86,400
≈ 69,000 on averageThen the neighbourhood:
Dense area: 40 drivers/km2
2 km radius: π x 2 x 2
≈ 12.6 km2
≈ 500 candidates
Driver at 50 km/h
≈ 56 m moved per 4 s
Keep every point for a day:
12.5 MB/s x 86,400
≈ 1.1 TB/dayWhat each number decides#
- ≈ 125,000 updates a second is the real load, almost all writes. The 100 Mbit/s inbound spreads over many gateways.
- The live set is only ≈ 50 MB. It fits in memory on one machine, so the problem is write rate and latency, not size.
- ≈ 290 ride requests a second at peak is small next to the updates. Even 290 × 500 candidates is 145,000 distance checks a second, trivial for memory. Ingest is the bottleneck, not matching.
- ≈ 1.1 TB a day if every point were kept. That is why the trail keeps only points from trips in progress and expires them, not the whole idle fleet.
- A driver moves ≈ 56 m between updates. Phone GPS is typically good to about 4.9 m under open sky and worse near buildings, so 56 m of staleness dominates. That is fine for finding candidates, but the final ETA must account for it.
- All of these are assumptions for the arithmetic. Measure your own.
Step 5: Deep Dives#
Four questions decide this design. Take them in this order unless the interviewer pulls you elsewhere.
How Do You Find Nearby Drivers Fast?#
Turning a location into a cell id is a hash with a useful property: nearby places get nearby ids. That is what lets a lookup read a small neighbourhood instead of everything.
Scan everything#
Compute the distance to all drivers and sort, as in step 3.
Grid cells#
Cut the map into square cells and file each driver under the cell that holds it. For a request, look in the rider’s cell and the eight cells around it, then widen by a ring if too few drivers are close enough. The search is exact once the fifth-nearest driver is closer than the distance the searched cells guarantee to cover.
A toy test on an assumed 25 × 20 km city with 1 km cells found the five nearest drivers for 2,000 random requests. With 20,000 drivers (40 per km²) the grid examined ≈ 338 per request, not 20,000, and every answer matched. With 1,000 drivers (2 per km²) it examined ≈ 24, and 27% of requests needed a second ring.
Real stores give you this. Redis’s geospatial commands keep points in a sorted set, interleaving latitude and longitude bits into a 52-bit geohash that fits a sorted-set score, and GEOSEARCH finds members in a radius or box, sorted by distance. There is no delete command: use ZREM.
Hierarchical cells#
Cities are uneven: a downtown cell holds hundreds of drivers and a suburb cell holds one. Hierarchical indexes let you choose the cell size per area. H3, Uber’s open-source index, uses hexagonal cells where each cell has seven children, changing resolution takes a few bitwise operations, and geographically close places tend to have numerically close indexes. Uber’s H3 post gives the reason for hexagons: a cell has only one distance between its centre and each neighbour’s, where squares have two kinds of neighbour.
The costs are real: the same sources say a child cell is only approximately inside its parent, and every H3 resolution has exactly twelve pentagons among its cells. The H3 page also warns that where exact boundaries matter, the grid should be an optimisation on top of a precise check.
Which index?#
All three common choices turn a point into a number.
| Index | Cells | Strength | Cost |
|---|---|---|---|
| Geohash (Redis GEO) | Rectangles from interleaved bits, 52-bit score | Already in Redis, one command for radius search | Neighbours across a cell edge can have far-apart numbers, so the store reads several ranges; cell size follows the bit count |
| S2 | Squares on six cube faces, four children, 64-bit ids ordered along a Hilbert curve | Exact nesting; nearby ids are nearby cells, so a database can answer a region with id range scans | Neighbour lookup is a little more code; level 13 is about 1.27 km², level 16 about 0.02 km² |
| H3 | Hexagons, seven children, resolutions 0 to 15 | One neighbour distance, simple gridDisk rings, good for per-cell counts such as demand | Child cells only approximately inside the parent; pentagons |
Recommendation (judgement): for an in-memory index with a rider radius search, H3 or Redis GEO both work; choose H3 if you also want per-cell supply and demand counts, and S2 if the index lives in a sorted key-value store that serves range scans.
What cell size?#
The H3 table lists resolution 8 at an average 0.737 km² and 0.53 km edge, and resolution 9 at 0.105 km² and 0.20 km. A gridDisk of k returns every cell within k hops, which is 3k(k+1)+1 cells.
Target: 2 km radius = 12.6 km2
Neighbour spacing = 1.73 x edge
Res 8: spacing ≈ 0.92 km
k = 3 -> 37 cells x 0.737
= 27.3 km2 -> 1,090 drivers
Res 9: spacing ≈ 0.35 km
k = 6 -> 127 cells x 0.105
= 13.4 km2 -> 535 drivers
(at 40 drivers/km2)Using the H3 areas, resolution 8 does 37 lookups and examines about 1,090 drivers, and resolution 9 does 127 lookups and about 535. Measure which is cheaper (judgement). Downtown, at an assumed 400 drivers/km², a resolution 8 cell holds ≈ 295 drivers, so dense areas want a finer one. The ring counts are rough: a precise distance check makes the result exact, and the search widens if fewer than five pass.
How Do You Ingest 125,000 Updates a Second?#
Drivers hold a connection to a gateway, as chat clients do. The updates arrive through it, and only the latest one matters.
Write every update#
Each update is a row insert or update in a table of drivers.
In memory#
Keep the live positions in memory, in the grid or in a geo sorted set, and update a driver’s entry in place. When a driver crosses into a new cell, remove them from the old cell and add them to the new one. At about 50 MB the live set is tiny, so one process per region holds it all.
Make the write safe to repeat and to reorder. Each update carries a per-driver sequence number seq, and the index ignores one that is not newer than the stored one, so a delayed packet never moves a car backwards. Raw phone GPS is noisy, so snap points to the road network with map matching, which Valhalla provides.
By region#
Give each region its own location service, with one writer per region so updates never race. This is sharding by place. Let the app adapt its rate: faster when a driver is close to a pickup, slower when idle, which is a judgement call. Mark a driver offline when no update has arrived for a set time, such as 30 seconds. Send history to a separate stream for analysis so it never slows the live index.
How Do You Match Without Double-Booking?#
A lease: work handed out with a time limit, so a silent worker does not hold it forever. An offer to a driver works the same way.
Nearest wins#
Pick the nearest driver on the map and send the ride.
Rank, then claim#
Take the ≈ 5 best candidates from the grid and rank them by travel time. OSRM has a Table service that returns durations between all pairs of coordinates, and Valhalla offers a time and distance matrix. One call ranks all candidates.
Then claim the driver atomically. In Redis, SET with NX only sets a key that does not exist, and PX gives it an expiry in milliseconds. One rider’s claim holds the driver, a second rider’s attempt fails, and if the driver never answers the claim expires by itself.
Sequence and batches#
If the first driver declines or the claim expires, release it and offer to the next one. If candidates run out, widen the search ring. Make every step safe to repeat by keying it on the rider’s request_id. When one step must update several keys together, such as the driver’s claim and the ride’s state, use an atomic script: Redis guarantees a script’s atomic execution.
When riders and drivers are both scarce, matching one rider at a time is greedy. Uber describes the alternative: evaluate nearby riders and drivers in one batch in the seconds after a request, aiming to reduce the average wait for everyone, not just the closest pair. Pairing riders to drivers so total cost is smallest is an assignment problem, and solvers such as the Hungarian algorithm handle a batch of hundreds quickly (judgement on size).
Why batching wins#
Assume two riders and two drivers with pickup ETAs in minutes (made-up numbers):
D1 D2
R1 2 3
R2 3 9
Greedy, R1 first:
R1-D1 (2) then R2-D2 (9) = 11
Batch, best pairing:
R1-D2 (3) + R2-D1 (3) = 6The batch gives an average wait of 3 minutes against 5.5. If a batch window is 2 s and requests arrive at the peak of 290/s, a batch holds about 580 riders, a real assignment problem. Because a wide batch costs rider wait, shrink the window when supply is plentiful and widen it when it is scarce (judgement).
What Do You Pick Under a Partition?#
The claim must not double-book, even when a node fails, so state a stance per component rather than a slogan. CAP and PACELC give the words.
| Component | During a partition | Otherwise | Why |
|---|---|---|---|
| Live locations | Stay available, serve stale positions | Low latency | A 30 s old car is a small error |
| Offer claim | Refuse to offer rather than risk two | A few ms more for the check | A double booking costs a rider and a driver |
| Ride record | Consistent: one owner per ride, reject on doubt | Conditional write per transition | The record is the truth for money and trust |
| Rider and driver pushes | Available, may be late | Low latency | The app can fetch the state again |
The claim is a fast filter, not the final word#
Redis uses asynchronous replication, so acknowledged writes can be lost during a failover, and WAIT does not turn it into a strongly consistent system. After a failover a claim can vanish and a second rider could claim the same driver.
So the accept is a conditional write on the durable ride record, such as “set driver = D and state = matched only if state is offered and the offer id is X”. Only one write passes, and the loser gets a 409. A stale holder is also why locks use fencing tokens: each offer carries an increasing number, and the store rejects anything older.
In PACELC terms this is PC/EC for claims and ride state and PA/EL for locations (judgement). Idempotent accept and decline follow from the same write: a double tap finds the ride already matched to that driver and returns 200 again; an accept after the offer expired fails with 409 and the app shows “no longer available”. When the 15 s timer and the accept race, both are conditional writes on the same version, so exactly one wins.
How Do You Keep Ride State Correct?#
Store the record first, then tell people. Both apps learn about a ride the same way: from stored events, with a nudge on top.
Polling#
The rider’s and driver’s apps ask for the ride’s state every few seconds.
Stored events#
A ride moves through fixed states: requested, offered, matched, driver arriving, in progress, completed, with cancellation allowed before the ride starts. Store every transition as an event first, then push it to both apps over their held connections. If an app drops and reconnects, it resumes from its last event number, as in the chat lesson. While the app is in the background, a mobile push nudge wakes it, as in the notification lesson.
Idempotent transitions#
Key every transition on a request or event id, so a retry that arrives twice is idempotent and changes nothing the second time. Every update says “from version v to v+1”, so a repeat fails the check and changes nothing. That is how at-least-once delivery becomes an effect that happens once. Shard ride state by city, since rides rarely cross cities.
| From | To | Trigger | Rule |
|---|---|---|---|
| requested | offered | Matcher picks a driver | Needs the driver claim |
| offered | offered | Decline or timeout | Next offer, version +1 |
| offered | matched | Driver accepts | Conditional on offer id and version |
| matched | driver arriving | Driver starts navigation | Driver only |
| arriving | in progress | Pickup confirmed | Driver or rider app |
| in progress | completed | Drop-off confirmed | Triggers payment |
| requested to arriving | cancelled | Rider or driver cancels | Fee rules are product choices |
Index by place, rank by road, claim once. A grid tells you who is near, a routing engine tells you who is soonest, and a claim backed by a conditional write makes sure only one rider gets them.
The Final Design in One Picture#
Follow one ride:
- Drivers send their position every few seconds. The regional location service updates its in-memory grid.
- A rider taps request. The app sends a
request_idwith the pickup and destination. - The matching service reads the cells around the pickup and takes the closest candidates.
- It asks the routing service for travel times from those candidates to the pickup, and ranks them, in a batch when drivers are scarce.
- It claims the best driver with an expiring key and sends an offer. If the driver declines or the claim expires, it releases the claim and offers to the next one.
- On acceptance a conditional write stores the ride as matched, and both apps are notified. Later transitions are stored first, then announced.
What Each Level Should Show#
| Level | Expected in the room |
|---|---|
| Mid-level | A driver table, a distance query, a notification to the driver. May need a hint about scale and spatial indexes. |
| Senior | A grid or geohash index, in-memory live locations, ETA ranking by road, an expiring claim on the driver, a ride state machine, an idempotent request. |
| Staff+ | Cell size arithmetic, batch matching, a per-component consistency stance with fencing, regional shards, hot cells, and what to build first. |
Red Flags and Green Flags#
“Store driver locations in a database and query the nearest ones.” Nothing about the assumed 125,000 writes a second, road distance, or two riders getting the same driver.
“The live positions are only tens of megabytes, so I’ll keep them in memory per region in a grid. I’ll rank the few nearest by road time from a routing service, then claim the driver with an expiring key and make the accept a conditional write, so one rider gets them even after a failover.”
“Redis SET NX makes sure nobody is double-booked.” It ignores asynchronous replication, a paused holder, and a claim that expires while the driver is tapping accept.
“The claim is a fast filter. The truth is a conditional write on the ride record, with a version, so a late accept gets a 409.”
Spending the time on the map and the “nearest driver” query and never reaching the double booking. The interviewer is waiting for the write load, the claim and what happens when a driver does not answer.
Try It: Choose the Cell Size#
A downtown zone has 400 drivers per km², a suburb has 10 per km². Using the H3 areas above (resolution 8 is 0.737 km², resolution 9 is 0.105 km²):
- How many drivers does one cell hold downtown and in the suburb, at each resolution?
- Which resolution would you use for each zone, and why?
- What happens in the suburb if a rider’s 3-ring search finds only 2 drivers?
Hint
Multiply density by cell area. Then ask which is worse: a cell with hundreds of drivers to examine, or a cell with none.
One reasonable answer
Downtown 400/km2:
res 8: 400 x 0.737 ≈ 295
res 9: 400 x 0.105 ≈ 42
Suburb 10/km2:
res 8: 10 x 0.737 ≈ 7
res 9: 10 x 0.105 ≈ 1Downtown, use resolution 9 (or finer): 42 drivers per cell is cheap to scan, while 295 per cell wastes work. In the suburb, use resolution 8: at resolution 9 most cells are empty, so a search needs many rings.
If a 3-ring search finds only 2 of the 5 wanted drivers, widen to the next ring and repeat, as the toy test did in 27% of requests at 2 drivers per km². Keep a maximum radius, after which the rider is told no cars are available and is offered a retry.
Expect the follow-ups in this order: “How do you find drivers near a rider?”, “How do you handle 125,000 location updates a second?”, “What if two riders request the same driver?”, and “What happens when a driver does not answer?” Each one maps to a deep dive above. Close each answer with one trade-off and one figure. Then they will ask what breaks, how a stadium crowd, surge pricing, payments and location privacy work: that is the follow-ups lesson.
Check Yourself#
1.Why does a grid beat scanning every driver, and when does it need a second ring?
It looks at the rider’s cell and its neighbours, a few hundred drivers instead of all of them. It needs a second ring when the area is sparse and fewer than the wanted number of drivers are close enough, which happened in 27% of requests at 2 drivers per square kilometre in the toy test.
2.Why keep driver locations in memory, and what do you lose on a restart?
The live set is tiny but overwritten every few seconds, so a database wastes effort. A restart empties the index until drivers report again, which takes seconds. That is fine for locations, but not for ride state.
3.Two riders choose the same driver in the same second, and the Redis primary fails over right after. What stops a double booking?
The Redis claim stops the first collision, but replication is asynchronous, so the new primary may not have it. The accept is a conditional write on the durable ride record, which succeeds for one offer only. The other rider gets a 409 and moves to the next candidate.
4.A rider's app retries the request after a timeout. How do you avoid two rides?
The app sends the same request_id each time. The server stores the ride keyed by it, so a repeat finds the existing ride and returns it instead of creating another. The same id with a different body fails with 409.
5.When does batch matching beat nearest-first, and what does it cost?
When drivers are scarce: the first rider may take the driver a second rider needed (11 minutes against 6 in the example). It costs every rider a short wait, so shrink the window when supply is plentiful.
Tomorrow, say the rule aloud, then trace one ride from tap to match in two minutes. For each step name what could fail (a restart, a decline, a double tap) and which part handles it.
In One Picture#
Requirements
locate, request, match, track
Scale
125,000 updates/s · 50 MB live
Near
cells sized to density
Soonest
road ETA, batch when scarce
Claim
fast key + conditional write
State
versioned events, one owner
Rule
index by place, rank by
road, claim onceConclusion#
A ride-sharing service is a live map plus a careful handshake. Keep the latest position of every driver in a spatial index in memory, rank a few candidates by real travel time, and claim the winner with an expiring key backed by a conditional write so only one rider gets them. Store every ride transition before announcing it, and shard by city.
The same ideas, cheap lookups for “near”, leases for work that can go quiet, and stored events for state, run through most of the problems in this module.
Which would you defend first in an interview: the grid, the ETA ranking or the claim? Tell us in the comments.
- What breaks, and the money side: the follow-ups lesson on failures, hot cells, surge, payments and privacy.
- Held connections: design a chat system, where a dropped app resumes.
- Atomic checks and expiring claims: design a rate limiter and design a web crawler.
- Splitting by place: the lesson on splitting and copying data.

