Blueprint
Geo & marketplaces advanced

Design Uber

Two systems wearing one trench coat: a firehose of location writes that must never touch a durable database, and a matching flow that needs bank-grade consistency so no driver ever gets two rides.

asked at Uber · Lyft · Grab Frequently asked ~40 min walkthrough
01

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.
Say this out loud

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.

02

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.

QuantityEstimateHow you got there
Location writes~2M/s10M online drivers ÷ 5 s ping interval. The defining number of the question.
Cost of doing that naively~$200k+/day2M/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 MBonly the latest position per driver matters for matching: 10M × ~50 B. Fits in one Redis with room to spare.
Fare estimates~1k/sa 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/requesta GEOSEARCH within 1–2 km, ranked; sets the size of the offer loop.
Say this out loud

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.

03

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.

POST/v1/farebody: { pickup, destination } → { fareId, price, eta }. Server computes and persists the quote.
POST/v1/ridesbody: { fareId } → { rideId }. Rider identity comes from the session token, price from the stored fare — nothing trusted from the body.
POST/v1/drivers/locationbody: { lat, lng }. The 2M/s firehose; driverId from auth, tiny payload, fire-and-forget semantics.
PATCH/v1/rides/{rideId}body: { accept | decline } from the offered driver; accept runs the assignment transaction.
  • 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.
04

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.

ride
id PK · rider_id · driver_id? · fare_id · status · created_at
status transitions guarded by conditional updates — the OCC check in matching hangs off this row.
fare
id PK · pickup · destination · price · eta · expires_at
the server-persisted quote; rides reference it, clients never restate it.
driver locations (Redis GEO)
GEOADD drivers lng lat driver_id
geohash packed into a sorted-set score; latest position only; TTL evicts drivers who stop pinging.
driver locks (Redis)
SET lock:driver:{id} rideId NX EX 10
mutual exclusion during offer fan-out — the heart of no-double-dispatch.
The database call

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.

05

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.

client edge service data async Rider app Driver app API gateway Ride service fares, lifecycle Location service ingest, stateless Postgres rides, fares Redis GEO locations + locks Match queue Kafka, by region Matching service durable workflow FCM / APNs txn search + lock offer GEOADD match req fare, ride ping 5 s push
notice the two paths never share a store: driver pings stop at redis, ride state stops at postgres, and the only bridge is the matching service reading one and transacting on the other.
  1. 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.
  2. Meanwhile the driver firehose: every ping hits the location service → GEOADD into Redis with a TTL, overwriting the previous position. Nothing durable, nothing acknowledged beyond 200 OK.
  3. The matching service consumes the match request, runs GEOSEARCH for drivers within ~2 km, ranks by ETA and rating, and takes a 10 s NX lock on the top driver.
  4. 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.
  5. 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.
06

Deep dives — where the interview is won

Phase 01

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 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.

drivers ingest state Drivers × 2M Location LB Redis GEOADD Cassandra breadcrumbs 4s ping
2M pings/s land in redis GEO. no postgres involvement on the write path.
Trade-offRedis can lose recent pings on failover — and that's fine, because the next ping arrives within seconds; I'm explicitly trading durability I don't need for two orders of magnitude in throughput and cost.
Phase 02

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.

offers lock db Driver A accepts Driver B accepts Redis SETNX TTL 10s orders CAS confirm wins loses
TTL lock in redis picks winner. postgres UPDATE finalises with WHERE ride_id IS NULL.
Trade-offTTL tuning is a real dial: too short and the lock expires while a driver is still deciding, re-offering their ride under them; too long and a crashed offer keeps a driver idle. 10 s matches the product's decision window, and I'd say that number out loud.
Phase 03

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.

spike queue workers Concert crowd SQS buffer Matcher pool requests drain
surge burst goes to a queue with backpressure. autoscale can't beat a stampede.
Trade-offThe queue buys survivability at the cost of latency visibility — riders need honest UI feedback ('finding your driver…') because the system now degrades by slowing down rather than by failing fast.
Phase 04

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.

workflow steps Temporal durable timers Offer d1 8s Offer d2 8s Escalate timeout timeout
the offer sequence lives in temporal — durable timers, retries, cancels, per ride.
Trade-offA workflow engine adds an operational dependency and per-step persistence latency — irrelevant here, since the loop's own timescale is tens of seconds and correctness is the whole point.
07

Follow-ups you should expect

Offer to one driver at a time, or broadcast to five?
Serial by default: one offer, 10 s window, move down the ranked list — it's fair, simple, and the lock semantics stay clean. Broadcast is faster in dense areas but turns every acceptance into a race, so the OCC check on the ride row becomes first-accept-wins and the losers need graceful 'ride no longer available' handling. I'd ship serial and add broadcast only where match latency misses SLA.
How would you implement surge pricing?
Per-cell supply/demand: count open requests and available drivers per H3 hexagon over a sliding window, and apply a multiplier when the ratio crosses thresholds. Hexagons beat geohash rectangles here because uniform neighbour distances make smoothing across cells honest. The multiplier is baked into the fare quote, so the fareId flow already locks it in.
What happens if a driver's app dies after they get an offer?
Nothing needs special handling — the 10 s driver lock expires, the durable workflow times out the offer and moves to the next ranked driver. The dead driver's Redis GEO entry also stops being refreshed and drops out on TTL, so they stop being matched at all. Both recoveries are baked into the data model rather than into error-handling code.
Why can't the client send the price when confirming a ride?
Anything the client sends can be replayed or modified — price, userId, timestamps. The fare estimate is persisted server-side and the client confirms a fareId; the ride service reads the price from its own store. Same rule for identity: userId comes from the session token, never the body. This is the trust-boundary answer this question exists to check.
Where does the map and ETA data come from?
I buy it — an internal or third-party routing service handles ETA and navigation, because building routing is its own 40-minute interview (see the google-maps question). The one integration concern is that fare estimates put that service on my critical path, so I cache common route estimates briefly and degrade to distance-based pricing if it's down.

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.

Concepts this leans on

Found a bug or want more?

Tell me what's broken, missing, or wrong. Every message lands in my inbox.