Design Uber
Design Uber step by step: driver locations, geo discovery, atomic dispatch, offer timeouts, trip state, surge pricing, payments, and the reasoning behind every major decision.
Page content
Alice and Bob both open the app in Connaught Place and tap Request. The map shows one nearby car: Dev. Dispatch looks at the geo index twice and sees AVAILABLE twice.
If both requests succeed, Dev is on two trips. That is the bug this interview is about. A GPS ping being two seconds stale is not.
Location is a hint that changes every few seconds. A trip is a contract: one rider, one driver, one fare, one payment. Those two facts must not share one consistency model.
The main design question is:
How do we find nearby drivers quickly, assign exactly one of them, and keep the trip correct while locations, offers, and payments fail independently?
We will start with a trip row in PostgreSQL. Redis GEO, offer timeouts, Kafka, and surge appear when a simpler path fails.
This is matching and lifecycle, not restaurant tracking. For GPS pipes and reconnect races in more detail, see Design a Food Delivery Tracking System. Here the scarce resource is the driver.
1. Clarify the problem
“Design Uber” includes maps, ETAs, pooling, scheduled rides, and a global marketplace. That is too broad for one interview.
I would ask:
- One city or many?
- Only UberX, or pooling and UberXL?
- Must the driver accept an offer, or do we auto-assign?
- How fresh should the rider’s map be?
- Payments in scope, or only a charge after complete?
- Live tracking over WebSockets?
If the interviewer gives no extra constraints, I would state:
Product Request, match, arrive, start, complete, pay
Not in scope Pooling, scheduled rides, maps tiles, full ETA ML
Matching Offer to nearest eligible driver; 10s timeout
Capacity One trip per driver
Location freshness A few seconds; stale is allowed
Payment After TRIP_COMPLETED; mocked provider
Realtime WebSocket or equivalent for offers and location
Deployment One city first; city is the partition
Stack Go, PostgreSQL, Redis GEO, Kafka, outbox
Auto-assign without an offer is simpler and used in some markets. An explicit accept is better for teaching reservation and timeout. I would pick offers unless told otherwise.
2. Functional requirements
The system must:
- Let a rider request a ride with pickup, destination, and vehicle type.
- Let a driver go online/offline and stream location.
- Discover nearby available drivers from a geo index, not from PostgreSQL.
- Offer the trip, expire the offer, and try another driver.
- Assign at most one driver to a trip, and a driver to at most one trip.
- Advance a trip through a valid lifecycle, including cancel.
- Estimate fare with a simple surge multiplier by area.
- Charge once when the trip completes.
- Push offers to the driver and location to the rider.
The first version does not include:
- shared rides and capacity of two riders;
- driver destination filters in depth;
- a real payment provider;
- turn-by-turn routing;
- fraud scoring beyond basic rate limits.
3. Non-functional requirements
| Requirement | Target |
|---|---|
| Request ACK | p99 below 200 ms to persist SEARCHING |
| Match latency | a few seconds to first offer in a dense cell |
| Location ingest | tens of thousands of points/s per city at peak |
| Assignment | never two trips on one driver |
| Offer timeout | ~10 s, durable, not a goroutine per offer |
| Payment | at-least-once processing, exactly-once charge via idempotency |
| Availability | location/Redis loss must not invent an assignment |
Consistency is not one global setting.
Trip row, payment row strong; source of truth in PostgreSQL
Driver reservation atomic conditional update
Location eventual; last point wins
Geo index derived from location + availability
Surge cell cached; may lag demand
Offer / location push at-least-once
Edge cases to keep in mind
- Alice and Bob request Dev at the same instant.
- Dev’s app dies while
OFFERED. - Dispatch worker crashes after reserving Dev.
- Alice retries
POST /rideswith the same idempotency key. TripCompletedis consumed twice and would charge twice.- Kafka is down after the trip row is
TRIP_COMPLETED. - Dev’s GPS is 30 seconds stale; he looks nearby and is not.
- A hot cell (airport) concentrates all demand.
- Alice cancels while Dev is accepting.
4. Estimate the scale
Assumptions for one large metro, labeled as planning numbers:
Rider DAU 2 million
Drivers online at peak 50,000
Location interval ~3 s
Ride requests 300,000/day
Peak requests ~40/s city-wide; much higher in one cell
GPS ingest 50,000 / 3 ≈ 17,000 points/s
GPS is the volume. Trip writes are not:
300,000 trips/day ≈ 3.5/s average
If we SELECT * FROM drivers WHERE status = 'AVAILABLE' every request, we scan 50,000 rows and still have a stale answer. The geo index exists because of ingest and lookup shape, not because 300,000 trips overflow PostgreSQL.
A global Uber is many copies of this city. Partition by city (or geo cell of the pickup) before you shard trips by UUID.
Hot cell: the airport at 6pm. Dispatch, GEO, and surge must isolate that cell so downtown matching still works.
5. What are we storing?
Rider identity and payment method ref
Driver identity, vehicle type, durable status
Trip the contract
Payment charge for a completed trip
Location latest point, not a history table on the trip
Surge multiplier per geo cell
Source of truth trips, payments, driver durable status
Derived Redis GEO, availability set, surge cache
Ephemeral last_seen, socket registry, offer deadline
Events outbox → Kafka
Do not append every GPS point to trips. The trip needs pickup, destination, timestamps, and fares. The moving dot is Redis (or a location store) keyed by driver_id.
6. APIs
Request ride
POST /v1/rides
Authorization: Bearer <token>
Idempotency-Key: alice-phone-9f3a
{
"pickup": { "lat": 28.6139, "lng": 77.2090 },
"destination": { "lat": 28.5355, "lng": 77.3910 },
"vehicle_type": "UBER_X"
}
HTTP 201 Created
{
"trip_id": "t_8f3a",
"status": "SEARCHING",
"estimated_fare": 420
}
The 201 means the trip exists and matching may begin. It does not mean a driver accepted.
The same idempotency key returns the original trip. It must not create t_8f3b.
Driver presence and location
POST /v1/drivers/{id}/online
POST /v1/drivers/{id}/offline
POST /v1/drivers/{id}/location
{
"lat": 28.6139,
"lng": 77.2090,
"timestamp": 1786970000
}
Offline is rejected if the driver is OFFERED or ON_TRIP unless you explicitly cancel/complete first.
Offer, trip commands
POST /v1/rides/{id}/accept
POST /v1/rides/{id}/start
POST /v1/rides/{id}/complete
POST /v1/rides/{id}/cancel
GET /v1/rides/{id}
Errors
400 VALIDATION_ERROR, INVALID_TRANSITION
401 UNAUTHENTICATED
403 NOT_ASSIGNED_DRIVER
404 TRIP_NOT_FOUND
409 DRIVER_NOT_AVAILABLE, OFFER_EXPIRED, IDEMPOTENCY_CONFLICT
7. Basic data model
riders
id, name, phone, payment_method_ref, created_at
drivers
id, name, phone, vehicle_id, vehicle_type
status OFFLINE | AVAILABLE | OFFERED | ON_TRIP
rating
version
created_at
trips
id
rider_id
driver_id nullable until assigned
pickup, destination
vehicle_type
status
estimated_fare, final_fare
offer_expires_at nullable
version
created_at, started_at, completed_at
payments
id, trip_id unique
amount, status
idempotency_key unique
provider_ref
outbox_events
idempotency_keys for POST /rides and complete
Indexes: trips(rider_id), trips(driver_id), trips(status), outbox_events(status, created_at).
Valid trip transitions:
REQUESTED → SEARCHING → DRIVER_ASSIGNED → DRIVER_ARRIVING
→ TRIP_STARTED → TRIP_COMPLETED
SEARCHING / DRIVER_ASSIGNED / DRIVER_ARRIVING → CANCELLED
TRIP_STARTED → CANCELLED is a product choice; default: no
REQUESTED → TRIP_COMPLETED is rejected in the service, and the SQL WHERE status = $expected is the lock.
8. Start with one city and PostgreSQL
A first version can insert a trip and SELECT available drivers. That teaches the state machine. It fails as soon as GPS volume or two dispatchers appear.
Do not begin with 20 microservices. One Go process can host HTTP, a dispatch loop, an outbox publisher, and a payment consumer. Interfaces make Redis and Kafka testable.
9. Locations belong in a geo index
POST /drivers/{id}/location must not UPDATE drivers SET lat, lng.
GEOADD city:del:drivers 77.2090 28.6139 driver:dev
SET driver:dev:last_seen now EX 30
Discovery:
GEORADIUS city:del:drivers pickup 3 km
filter vehicle_type
filter status AVAILABLE
sort by distance
Put the geo API behind FindAvailableDrivers(lat, lng, radius, vehicleType). Redis GEO is the local implementation. H3/S2 cells are the production evolution: list nearby cell ids, fetch drivers in those cells. The dispatch service should not import Redis types.
Why not PostgreSQL earthdistance for the hot path:
17,000 writes/s of points that are discarded when the next point arrives
index churn
still stale by the time dispatch reads
Postgres keeps the driver row (name, vehicle, durable status). Redis keeps the moving point and a fast “who is around.” If Redis loses the GEO set, drivers look gone until the next ping. Trips already assigned remain in PostgreSQL. That is the correct failure mode.
Drop drivers whose last_seen is older than ~15–30 s from the available set. A disconnected app must not keep winning offers.
10. Request is idempotent; matching is async
POST /rides + Idempotency-Key
BEGIN
claim idempotency key
insert trip SEARCHING, estimated_fare
insert outbox TripCreated
COMMIT
return trip_id
Dispatch can run in-process after commit or as a consumer of TripCreated. Do not hold the HTTP request open for 10 s of offers. Alice’s app polls GET /rides/{id} or receives a WebSocket DRIVER_ASSIGNED.
Idempotency is “this HTTP request already created t_8f3a.” It is not “Dev is still free.” Those are different layers. The unique trip row is transactional consistency; the header is client retry safety.
11. Dispatch: candidates, then a reservation
FindAvailableDrivers
→ rank by distance (later: rating, ETA, destination)
→ try reserve nearest
→ if fail, try next
→ if none, stay SEARCHING and retry with backoff
The geo index is a candidate source. It is not the assignment.
Naive dispatch:
read Dev AVAILABLE
offer Dev
Alice’s worker and Bob’s worker both read AVAILABLE. Both offer. Both “succeed.”
12. Atomic reservation is the whole matching interview
Reservation must be a compare-and-set.
PostgreSQL (easy to explain, source of truth for ON_TRIP):
UPDATE drivers
SET status = 'OFFERED',
version = version + 1
WHERE id = 'd_dev'
AND status = 'AVAILABLE'
Zero rows: someone else won. Try the next candidate.
Redis (faster, must not disagree with Postgres):
if GET driver:dev:status == AVAILABLE
then SET OFFERED
is not atomic. Use SET key OFFERED NX on a lock, or a Lua compare-and-set, and still persist OFFERED on the driver/trip row. If Redis says OFFERED and Postgres rolls back, Dev is stuck. Prefer:
BEGIN
CAS driver AVAILABLE → OFFERED
set trip.offer_driver_id, offer_expires_at = now()+10s
insert outbox DriverOffered
COMMIT
then remove Dev from GEO available set
If the CAS fails, Dev was never yours. If the commit succeeds, the offer exists even if the WebSocket send fails — timeout will free him.
Two riders, one driver: exactly one UPDATE matches AVAILABLE. That is the proof you want on the whiteboard. Application if status == AVAILABLE without the WHERE is not a proof.
Also unique-constrain “one active trip per driver” if you can express it (partial unique index on driver_id where status not terminal). Belts and braces.
13. Accept is a second CAS
POST /v1/rides/t_8f3a/accept
Only Dev, and only while the offer is live:
BEGIN
UPDATE trips
SET status = 'DRIVER_ASSIGNED', driver_id = 'd_dev', version = version + 1
WHERE id = 't_8f3a'
AND status = 'SEARCHING'
AND offer_driver_id = 'd_dev'
AND offer_expires_at > now()
UPDATE drivers
SET status = 'ON_TRIP', version = version + 1
WHERE id = 'd_dev' AND status = 'OFFERED'
COMMIT
If either update hits zero rows, the offer expired or another accept won. Return 409 OFFER_EXPIRED. Retrying accept after success returns the assigned trip (idempotent command).
Driver and trip must move together in one transaction. Otherwise you get ON_TRIP with no trip, or DRIVER_ASSIGNED with an AVAILABLE driver.
14. Offer timeout without a goroutine per offer
A timer in the dispatch process dies with the process. After a crash, Dev stays OFFERED forever.
Store offer_expires_at on the trip (or a Redis key offer:{tripId} with TTL plus a sweeper). A single worker:
every 1s:
SELECT trips WHERE status = SEARCHING
AND offer_driver_id IS NOT NULL
AND offer_expires_at < now()
for each:
CAS driver OFFERED → AVAILABLE (only if still OFFERED for this trip)
clear offer, keep SEARCHING
re-dispatch
That is crash-safe. Ten thousand in-flight offers are rows, not ten thousand goroutines.
If Dev accepts in the same second the sweeper runs, CAS on trip version or offer_expires_at decides one winner.
15. Start and complete
Start: assigned driver, DRIVER_ASSIGNED or DRIVER_ARRIVING → TRIP_STARTED, set started_at.
Complete:
BEGIN
CAS trip TRIP_STARTED → TRIP_COMPLETED
set final_fare, completed_at
driver ON_TRIP → AVAILABLE
insert outbox TripCompleted { trip_id, fare }
COMMIT
add driver back to GEO
Pricing:
estimated = (base + km * per_km + minutes * per_min) * surge(pickup_cell)
final = same formula with actual path/time, or estimated if you skip GPS trace
Keep PricingService as an interface. Real Uber pricing is not the interview. Surge is:
GET surge:{h3_cell} default 1.0
A batch job can write multipliers from (requests / available drivers) per cell. Cache in Redis because the request path should not compute marketplace stats. Stale surge is allowed; stale assignment is not.
Cancel: CAS from a cancellable status, free the driver if reserved, outbox TripCancelled. Alice cannot cancel after TRIP_STARTED unless you define that product rule.
16. Outbox, Kafka, payment
Complete must not kafka.Publish inside the SQL transaction or only after commit with no durable event.
DB committed, publish failed trip done, payment never starts
publish succeeded, DB rolled back charge for a trip that does not exist
Outbox: same transaction as TRIP_COMPLETED. Publisher later writes Kafka trip.events keyed by trip_id so one trip stays ordered.
Payment consumer:
TripCompleted
INSERT payments (trip_id, amount, idempotency_key=trip_id)
ON CONFLICT DO NOTHING
charge provider with the same key
Duplicate events: one payment row. Timeout to the provider: retry the same key; do not infer failure. This is the booking-system payment lesson applied after the ride, not during matching.
TripCreated / DriverAssigned / TripStarted feed notifications and analytics. They must not assign drivers. Assignment already committed in SQL.
17. Realtime
Driver app --location frames--> connection server --> Redis GEO
Driver app <--offer frames----- dispatch after CAS
Rider app <--location of Dev-- after DRIVER_ASSIGNED
WebSockets are pipes. If the offer frame is lost, Dev’s app can GET open offers, and the timeout still runs. If you skip a full gateway in a code project, keep the interface: OfferNotifier.Notify(driverID, tripID).
Location to Alice is coalesced under backpressure; trip status is not. Same split as food delivery: dots vs TRIP_COMPLETED.
18. Failure scenarios
| Failure | Behavior |
|---|---|
| PostgreSQL down | No new trips or accepts |
| Redis GEO down | Matching degrades; do not scan all SQL drivers as a silent fallback at scale; fail matching honestly |
| Kafka down | Outbox waits; completed trips exist; payment lags |
| Dispatch crash | Offers expire via offer_expires_at |
| Driver disconnect | last_seen ages out; if OFFERED, timeout; if ON_TRIP, trip remains, location freezes |
| Duplicate POST /rides | Same trip |
| Duplicate TripCompleted | One payment |
| Payment provider timeout | Unknown; retry same key |
| Alice cancel vs Dev accept | One CAS wins; the other 409 |
Do not mark Dev AVAILABLE in Redis if the trip row still says ON_TRIP. Durable status is PostgreSQL. Redis availability is a projection you repair from the row.
19. Observability and security
Log request_id, trip_id, rider_id, driver_id, from_status, to_status, offer_expires_at.
Metrics that matter:
request to first offer
offer accept rate
offer timeout rate
assignment conflicts (CAS misses)
GPS ingest rate
stale driver drops
payment success / retry
Authorize: only the assigned driver accepts/starts/completes; only the rider cancels as rider. Rate-limit requests per rider so a retry storm is not a marketplace attack. Location is PII; do not log high-precision points in info logs.
20. How this would scale to Uber-level traffic
Do not implement these in the practice project. Be ready to talk.
Geographic partition. Every trip has a city (or pickup H3 cell). Dispatch workers, Redis GEO, and Kafka topics are per city. A Delhi outage should not block Bangalore matching.
H3/S2 cells. GEORADIUS on one key of 50,000 drivers becomes many small sets cell:{h3}:available. Nearby search is a ring of cells. Hot airport cell: isolate its Redis shard and dispatch pool.
Location volume. 500,000 global online drivers at 1 Hz is 500,000 writes/s to the location plane. Coalesce on the phone, sample, and never write that stream into the trip database.
Dispatch. One worker per cell ring, not a global queue. Kafka partition key = city or cell so matching stays local.
WebSocket gateways. Shard connections by driver_id. Offer notifier looks up driver → gateway in Redis, same as chat connection registry.
Databases. trips shard by city + time. payments follow trip_id. Do not shard by driver UUID if you still query “active trip for driver” in that city.
Multi-region. Matching is regional. A trip does not have two writers. Payments follow the trip’s home region. Location is too ephemeral to replicate globally.
Backpressure. If offers pile up, increase timeout or fail SEARCHING with “no drivers” rather than unbounded in-memory queues. Rate-limit GPS if a client sends 20 Hz.
Disaster recovery. Restore trips and payments from PostgreSQL. Rebuild GEO from the next location pings. Do not restore Redis as the ledger.
Eventual location, strong trip/payment: say it twice.
21. Where logic lives
internal/trip state machine, cancel
internal/dispatch find, reserve, timeout sweeper
internal/location ingest, last_seen, GEO adapter
internal/pricing fare + surge lookup
internal/payment consumer, idempotent charge
internal/outbox
Handlers parse HTTP. Dispatch does not send Kafka inside BEGIN. Tests fake GEO and run two concurrent Reserve(dev) against a real SQL CAS.
22. Final architecture
Rider / Driver apps
│
▼
API + connection servers
│
├── PostgreSQL trips, drivers.status, payments, outbox
├── Redis GEO, last_seen, surge, sockets
└── Outbox → Kafka
├── notifications / tracking
└── payment consumer
Dispatch sweeper: expire offers, re-search
Location ingest: GEOADD, not trip rows
Happy path:
Driver online + GPS
Rider POST /rides → SEARCHING
Dispatch CAS Dev AVAILABLE → OFFERED
Dev accept → DRIVER_ASSIGNED + ON_TRIP
start → complete → outbox → pay once
Dev AVAILABLE + GEO again
23. Interview-ready summary
Key decisions to remember
- Scope to request, match, trip lifecycle, and pay — not pooling or maps.
- Location is ephemeral in Redis GEO; trips are durable in PostgreSQL.
- Geo index returns candidates; assignment is a CAS
AVAILABLE → OFFERED. - Two riders, one driver: exactly one conditional update wins.
- Accept updates trip and driver in one transaction.
- Offer timeout is
offer_expires_atplus a sweeper, not a goroutine. POST /ridesidempotency ≠ assignment atomicity.- Complete writes
TripCompletedin the outbox; payment is an idempotent consumer. - Surge is a cell multiplier in Redis; pricing is a small interface.
- City/cell is the scale unit; rebuild GEO from pings after Redis loss.
How to walk through in 10–15 minutes
0–2 min. Alice and Bob race for Dev. Location vs trip consistency.
2–5 min. APIs, state machine, idempotent create.
5–9 min. Redis GEO, dispatch, CAS reservation, accept transaction, timeout sweeper.
9–12 min. Complete, outbox, payment idempotency.
12–15 min. Failures, hot cells, city partitioning.
Likely interviewer follow-up questions
- Why not query Postgres for nearby drivers?
- How do you prove two dispatchers cannot both assign Dev?
- What if the offer WebSocket is lost?
- Why not a goroutine
sleep(10s)per offer? - Redis CAS vs SQL CAS — which is source of truth?
- What happens if Dev is OFFERED and the worker dies?
- How is payment not charged twice?
- How does surge avoid slowing the request path?
- How do you isolate the airport cell?
- How is this different from food-delivery dispatch?
Senior-level points that differentiate the answer
- Treat the geo index as a hint, like a cache, never as the lock.
- Put reservation in a
WHERE status = AVAILABLEupdate, then explain zero rows. - Separate HTTP idempotency from marketplace atomicity.
- Make timeouts durable so process death is not a stuck driver.
- Keep GPS out of the trip table even when the interviewer says “just store it.”
- Charge after complete with the same idempotency story as any payment saga.
- Scale by city/cell before by service count.
- Fail matching when GEO is down rather than a dangerous full-table fallback.
A 1–2 minute verbal answer
I would design a one-city ride-hailing backend: request, geo match, offer, trip lifecycle, and pay after complete. At 50,000 online drivers pinging every few seconds, location ingest is tens of thousands of points per second, while trip writes are tiny. I put latest location and availability in Redis GEO and keep the trip, driver durable status, and payment in PostgreSQL.
POST /rideswith an idempotency key insertsSEARCHINGand an outbox event. Dispatch loads nearby candidates from GEO, then reserves withUPDATE drivers SET status=OFFERED WHERE status=AVAILABLE. Only one of Alice or Bob wins Dev. The offer hasoffer_expires_at; a sweeper, not a goroutine, returns Dev to AVAILABLE. Accept CAS-es trip and driver together toDRIVER_ASSIGNED/ON_TRIP.Complete CAS-es the trip, frees the driver, and writes
TripCompletedin the same transaction. A payment consumer charges once usingtrip_idas the idempotency key. Location may be stale; assignment and payment may not.
For the broader interview framework around this problem, see the System Design Interview Complete Guide.
