Scope the requirements
The skill being tested is spotting that this question has two opposite consistency profiles glued together. Location ingest wants throughput and tolerates loss; matching wants strict mutual exclusion and tolerates latency. Candidates who design one system for both fail on whichever half they ignored.
Functional — what it must do
- Rider requests a fare estimate for pickup → destination, then confirms a ride at that quoted price.
- The system matches the rider with a nearby available driver in real time.
- Drivers continuously report location, and can accept or decline ride offers.
- Ride lifecycle is tracked: requested → matched → in progress → completed.
- Explicitly cut: payments, ratings, pooling, scheduled rides — I offer surge pricing back as a follow-up because it falls out of the geo index.
Non-functional — the numbers that shape the design
- Match a rider to a driver in well under 1 minute; nearby-driver queries in tens of ms.
- Strong consistency in matching: a driver never holds two rides; a ride never has two drivers. This is the one place I refuse to be eventually consistent.
- Absorb regional demand spikes — 100k requests from one neighbourhood when a concert lets out.
- Scale: ~100M rider DAU, 10M drivers online at peak, each pinging location every 5 s.
- Ride records are durable and auditable; location pings are allowed to be lossy.
I'm going to say the split out loud before drawing anything: location updates are a lossy, in-memory, throughput problem, and matching is a small, transactional, correctness problem. Keeping those two paths physically separate is the design.
Back-of-envelope estimates
One number dominates and it's worth deriving slowly, because it justifies the most opinionated choice in the design — that driver locations never touch a durable database.
| Quantity | Estimate | How you got there |
|---|---|---|
| Location writes | ~2M/s | 10M online drivers ÷ 5 s ping interval. The defining number of the question. |
| Cost of doing that naively | ~$200k+/day | 2M/s against a pay-per-write store like DynamoDB — the arithmetic that forces an in-memory design. |
| Ride creations | ~300/s | ~25M rides/day ÷ 86,400 s. Trivial next to location writes — Postgres yawns. |
| Hot location store | ~500 MB | only the latest position per driver matters for matching: 10M × ~50 B. Fits in one Redis with room to spare. |
| Fare estimates | ~1k/s | a few estimate calls per ride request; each is a routing/ETA call — I'd buy this from a maps service rather than build it (see google-maps). |
| Match fan-out | ~10 drivers/request | a GEOSEARCH within 1–2 km, ranked; sets the size of the offer loop. |
2M writes a second and 300 ride transactions a second — a factor of nearly ten thousand between them. That asymmetry is why one size of storage cannot fit both, and it's the sentence I want the interviewer to remember.
Sketch the API
Four endpoints, and the interesting decision is the two-phase fare: the server persists the quote and hands back a fareId, so the client can never negotiate its own price.
- The fareId pattern is the security answer interviewers fish for: the client confirms a reference to a server-side quote, so replaying a modified price, userId or timestamp is impossible by construction.
- Location updates deliberately get weak delivery guarantees — a lost ping is corrected 5 s later by the next one, so retries and acks would spend engineering on a problem that fixes itself.
Data model
Transactional entities live in Postgres; the live location of every driver lives in Redis and nowhere durable. Same data domain, opposite stores, and the reasoning is the estimates table.
Postgres for riders, drivers, fares and rides — this is low-QPS transactional state and I want ACID for the assignment step. Redis for anything a driver's phone emits every 5 seconds. I'd move ride history to a partitioned store only when years of completed rides outgrow comfortable Postgres partitioning, and I'd stream location history to cheap cold storage via Kafka for analytics rather than ever making the hot path durable.
High-level design
Three planes: an ingest plane that eats the location firehose into Redis, a transactional plane for fares and rides, and an async matching plane behind a queue so demand spikes stretch latency instead of dropping requests.
- Rider requests a fare → ride service computes price and ETA (external routing API), persists the fare, returns fareId. Rider confirms → a ride row is created and a match request lands on the region's Kafka partition.
- Meanwhile the driver firehose: every ping hits the location service →
GEOADDinto Redis with a TTL, overwriting the previous position. Nothing durable, nothing acknowledged beyond 200 OK. - The matching service consumes the match request, runs
GEOSEARCHfor drivers within ~2 km, ranks by ETA and rating, and takes a 10 s NX lock on the top driver. - The offer goes out via push; the driver accepts → the ride service transactionally sets driver_id only if the ride is still unassigned (OCC), releases the lock, and notifies the rider.
- Decline or timeout → the durable workflow releases the lock and moves to the next ranked driver — the retry loop survives matcher crashes because its state lives in the workflow engine, not in process memory.
Deep dives — where the interview is won
2M location writes a second never touch a durable database
The first instinct — write pings to the rides database — dies on arithmetic: 2M writes/s would need a massively sharded durable store, and on a pay-per-write cloud database it's north of $200k a day, all to store data that's worthless 5 seconds later. Matching only ever asks one question: where is each driver right now? So the hot store keeps exactly one row per driver — latest position, ~500 MB total — in Redis GEO, where GEOADD encodes the geohash into a sorted-set score and GEOSEARCH answers radius queries in memory. A TTL on each entry makes silence self-cleaning: a driver whose phone dies simply evaporates from the index.
The second lever is cutting the writes at the source: adaptive ping rates. A driver waiting stationary at an airport pings every 30 s; a driver on a ride pings every 2–5 s. That's client logic, costs nothing server-side, and can halve the firehose. If the business later wants location history — safety audits, ML — that's a separate consumer tailing a Kafka stream into cold storage, never a change to the hot path.
One driver, one ride: a TTL lock plus a transactional check
Double-dispatch is the failure the interviewer is waiting to probe: two concurrent match requests both find the same nearest driver and both send an offer. Mutual exclusion during the offer window comes from a Redis lock — SET lock:driver:{id} rideId NX EX 10 — so only one matcher can offer to a given driver at a time. The TTL is the self-healing part: if the matcher crashes or the driver's app goes dark mid-offer, the lock evaporates in 10 s and the driver returns to the pool instead of being stranded.
The lock alone isn't sufficient, because locks with TTLs can expire at awkward moments — the driver taps accept at second 11. So acceptance is verified transactionally: the assignment UPDATE rides SET driver_id = ... WHERE id = ... AND driver_id IS NULL succeeds for exactly one accept, and the second writer gets zero rows and an apology screen. Optimistic concurrency in Postgres is the referee; the Redis lock is only an optimisation that stops most races before they reach the database.
The concert-crowd spike is absorbed by a queue, not by autoscaling
When a stadium empties, one geographic cell produces 100k ride requests in a couple of minutes. If riders call the matching service synchronously, matchers saturate, requests time out, clients retry, and the retry storm finishes the job. Putting Kafka between ride creation and matching converts that spike into queue depth: every request survives, and p99 matching latency degrades gracefully from seconds to a minute — which the requirement explicitly allows. The queue is partitioned by region so a Coachella spike never steals capacity from another city, and matchers scale per-partition.
Partitioning by region raises the boundary question — rider on one side of a cell edge, best driver on the other — and the answer is the same 8-neighbour trick from the proximity-service question: search the home cell plus adjacent cells, scatter-gather when a request sits near a partition boundary. For ranking and surge maths, hexagonal H3 cells (Uber's own library) behave better than geohash rectangles because all six neighbours are equidistant.
The offer loop is a distributed timer, so it runs on a durable workflow
Offer → wait 10 s → timeout → next driver is deceptively hard, because the naive implementation — a setTimeout in the matching process — loses every pending offer when that process crashes or deploys. Riders whose match died mid-loop wait forever. The state of the loop (which drivers were tried, who holds the current offer, when it expires) must live outside process memory: either a durable execution engine (Temporal, Step Functions) that replays the workflow after a crash, or a delayed-message queue where the timeout is itself a message that a fresh consumer can pick up.
This generalises past Uber and is worth flagging as a pattern: any multi-step flow with timers and external waits — offers, payment retries, booking holds — should keep its state machine in a store, not a stack frame. Interviewers use 'what happens if the matcher crashes mid-offer?' as the senior filter on this question.
Follow-ups you should expect
Offer to one driver at a time, or broadcast to five?
How would you implement surge pricing?
What happens if a driver's app dies after they get an offer?
Why can't the client send the price when confirming a ride?
Where does the map and ETA data come from?
Where candidates lose the room
- Writing raw location pings to a durable database — the 2M/s number exists to be noticed, and missing it means the whole design is built on the wrong storage.
- Trusting client-supplied price or userId on ride confirmation instead of a server-persisted fareId — an instant fail on security judgement.
- Keeping offer timeouts in process memory — a matcher deploy strands every in-flight rider, and the interviewer will ask exactly this crash question.
- No queue between ride requests and matching, so the concert spike becomes timeouts and a retry storm instead of a longer wait.
- Hand-waving 'distributed lock' without the TTL and the transactional accept check — the lock alone doesn't survive expiry races, and interviewers know it.