Design Uber

System design Hard Uber
Reference
Start your answer

Remix this answer and make it yours, or start blank. Either way you work beside the reference, one chapter at a time, and it stays private until you publish it.

Problem statement

Riders request trips, nearby drivers get matched, and both see live location until drop-off, with pricing and payment, for millions of trips a day.

Requirements

We are building the ride-hailing backend: a rider asks for a fare and requests a trip, the system offers it to nearby drivers until one accepts, both sides watch the car move until drop-off, and the trip is priced and charged at the end.

Clarify early: one product tier or several (UberX, XL)? Is pooling in scope (assume no)? Do we own payments or call a processor (assume a processor; we own the ledger)? Which regions, and must a city keep working if a region fails (yes)?

Functional requirements

  • Rider gets an upfront fare estimate (with surge) for a pickup and drop-off.
  • Rider requests a trip; the system offers it to one driver at a time, 10–15 s per offer, until one accepts or 3 minutes pass.
  • Drivers go online/offline and stream GPS while online.
  • After acceptance, rider sees the driver approaching; during the trip both see live location and ETA.
  • Driver marks arrived, started, completed; the fare is finalized and the rider's card is charged.
  • Either side can cancel, with fees by rule (e.g. rider cancels > 2 min after match).

Non-functional requirements

  • A driver is never assigned to two trips, and a trip never gets two drivers. Correctness beats latency here.
  • Match within a few seconds of request at p50; nearby-driver search under 50 ms p99.
  • Location on the rider's map is no more than 2–3 s stale.
  • 99.99% for request and dispatch; a city keeps running when one region is lost.
  • Clients on bad mobile networks reconnect and resume without losing trip state.
  • Payment is exactly-once from the rider's view.

Back-of-the-envelope estimation

  • 25M trips/day ≈ 300 trips/s average, ~1,500/s at peak (Friday night, New Year's spikes to 3–5×).
  • 5M drivers online at peak, one GPS ping every 4 s → 1.25M location writes/s. This is the dominant load.
  • Ping payload ~100 B → 125 MB/s ingest; keeping 30 days of trip traces for disputes: only on-trip pings, ~1.5M concurrent trips × 1/4 s × 100 B × 86,400 ≈ 3 TB/day.
  • Fare estimates run ~5–10× trip requests (people check prices): ~10k/s at peak.
  • Live trip fanout: ~1.5M active trips, each push to one rider every 2–4 s ≈ 500k pushes/s.
  • Hot index: 5M drivers × ~200 B (id, cell, lat/lng, status, vehicle, heading, ts) ≈ 1 GB. Fits in memory per city shard with room to spare.
API design

Communication: plain HTTPS for commands (estimate, request, accept, status changes); one long-lived WebSocket (or gRPC stream) per app for pushes (offers, driver location, trip status). Mobile push notifications are a fallback when the socket is down.

POST /v1/fare-estimates
  {pickup:{lat,lng}, dropoff:{lat,lng}, product:"uberx"}
  → {estimate_id, fare_cents:1840, currency, surge:1.3, eta_s:240, expires_at}

POST /v1/trips                         Idempotency-Key: <uuid>
  {estimate_id, payment_method_id}
  → 202 {trip_id, status:"searching"}

POST /v1/drivers/me/location           (or frames on the socket)
  {lat, lng, heading, speed, ts, seq}  → 204

POST /v1/offers/{offer_id}/accept      Idempotency-Key
  → 200 {trip_id, status:"driver_assigned", rider, pickup}
  → 409 {code:"offer_expired" | "trip_taken"}

POST /v1/trips/{id}/events  {type:"arrived"|"started"|"completed", ts, odometer}
POST /v1/trips/{id}/cancel  {reason}
GET  /v1/trips/{id}         → full trip snapshot incl. version

WS /v1/stream?resume_from=<trip_version>
  server → {type:"offer", offer_id, trip_id, pickup, fare, expires_at}
  server → {type:"trip", trip_id, version:17, status, driver_loc:{lat,lng,ts}, eta_s}

Recovery contract: every trip change bumps a monotonic version. On reconnect the client sends its last version; the server replies with the current snapshot if anything changed, then resumes deltas. Commands carry idempotency keys, so a retried request or accept after a timeout returns the original result instead of creating a second trip.

Data model
Store Key / table Contents Why
In-memory geo index (Redis or custom service), sharded by city cell:{h3_res9} → set of driver_ids; driver:{id} → lat, lng, status, product, ts Online drivers only, TTL 30 s 1.25M writes/s, sub-ms reads, data is disposable
Trips DB (sharded SQL, e.g. MySQL/Vitess or Spanner-like), partition by city_id trips(trip_id PK, city_id, rider_id, driver_id, status, version, estimate_id, fare_cents, requested_at, …); index (rider_id, requested_at), (driver_id, requested_at) Source of truth for the trip state machine Transactions + conditional updates for assignment
Same DB driver_state(driver_id PK, city_id, status, current_trip_id, version) Who is free Conditional update makes assignment atomic
Same DB offers(offer_id PK, trip_id, driver_id, status, expires_at) Offer history, expiry Audit and accept validation
Redis estimate:{id} TTL 5 min Quoted fare + surge Rider is charged what they saw
Wide-column (Cassandra) trip_trace(trip_id, ts) On-trip pings Append-heavy, read rarely; fare and disputes
Ledger DB (SQL) ledger_entries(entry_id, trip_id, account, amount, idempotency_key UNIQUE) Charges, driver payouts Double-entry, exactly-once
Kafka driver-locations, trip-events by city_id Event stream Feeds surge, ETA models, analytics
High-level design
flowchart LR
  R[Rider app] --> GW[API gateway]
  D[Driver app] --> GW
  R <--> WS[Connection gateway]
  D <--> WS
  GW --> P[Pricing service]
  GW --> T[Trip service]
  GW --> LOC[Location service]
  LOC --> GEO[(Geo index per city)]
  LOC --> K[Kafka]
  T --> DB[(Trips DB)]
  T --> DS[Dispatch service]
  DS --> GEO
  DS --> ETA[ETA and routing]
  P --> ETA
  K --> SURGE[Surge calculator]
  SURGE --> P
  T --> PAY[Payment service]
  PAY --> PSP[Card processor]
  T --> K
  K --> WS
  1. Fare estimate. Pricing asks ETA/routing for distance and duration, applies the city's rate card and the current surge multiplier for the pickup cell, stores the quote under estimate_id for 5 minutes, and returns it.
  2. Request and dispatch loop. Trip service validates the estimate, inserts the trip as searching (idempotency key unique), and hands it to Dispatch for that city. Dispatch queries the geo index for available drivers in the pickup cell and neighboring rings, ranks the top ~10 by road ETA (not straight-line distance), and sends an offer to the best one over the socket with a 15 s expiry. On decline or timeout it moves to the next candidate and widens the radius.
  3. Accept and commit. The driver's accept hits Trip service, which commits the assignment in one transaction (below). Success → the trip becomes driver_assigned, the event goes to Kafka, and both apps get a push. Failure → 409 to the driver, who goes back to the pool.
  4. Live tracking. The driver app pings every 4 s (faster on trip). Location service updates the geo index and publishes to Kafka. For drivers with an active trip, the connection gateway holding the rider's socket gets the update and pushes it. Status events (arrived, started, completed) go through Trip service and bump the version.
  5. Completion. Fare = quoted upfront fare, or for metered products computed from the trip trace. Payment service charges via the processor with the trip id as idempotency key and writes ledger entries for rider, driver, and Uber.
Deep dives

Geospatial driver search. Index drivers by H3 hexagon (res 9, ~0.1 km² per cell) or S2 cells. A ping moves the driver between cell sets only when the cell changes; otherwise it just updates the record. A search for pickup cell c reads kRing(c, 1) (7 cells), then k=2 (19), k=3 (37) until it has ~20 candidates, filters by status/product/staleness (< 15 s), then calls routing for ETAs on the top 10 to rank.

Option Updates at 1.25M/s Radius query Notes
SQL with lat/lng index Poor Range scans, slow Fine for 1k drivers, not 5M
Geohash prefixes Good Edge problems at cell borders; uneven cell sizes Simple, works with Redis GEO
Quadtree in memory Rebalancing on move is costly Good, adapts to density Better for static points
H3/S2 cells, in-memory, sharded by city Good: O(1) set move k-ring of uniform hexes Pick this

Shard by city (and by cell range within the largest cities) so a search never crosses machines. Replicate each shard 2× in-region; losing the index is survivable because drivers repopulate it within one ping interval.

Dispatch assignment correctness. The race: two trips offered to the same driver, or a driver accepting a trip another driver already took. Make offers exclusive and commit with conditional writes:

BEGIN;
UPDATE driver_state SET status='on_trip', current_trip_id=:t, version=version+1
  WHERE driver_id=:d AND status='available';            -- must hit 1 row
UPDATE trips SET driver_id=:d, status='driver_assigned', version=version+1
  WHERE trip_id=:t AND status='searching';             -- must hit 1 row
UPDATE offers SET status='accepted' WHERE offer_id=:o AND expires_at > now();
COMMIT;

Any update that hits 0 rows rolls back and returns 409. Keep driver_state and trips for a city on the same shard so this is a single-shard transaction. Dispatch also marks a driver offered in the geo index while an offer is out, so concurrent searches skip them; that soft lock expires with the offer. Offer one driver at a time per trip (simple, no double accepts), or in dense cities batch: every 2 s, collect open trips and free drivers in a zone and solve a bipartite matching on ETA, which beats greedy by a few percent of total pickup time.

Live trip fanout and reconnect recovery. Connection gateways hold ~100k sockets each, so ~3M concurrent app connections need ~30–50 hosts per region. A session registry (user_id → gateway_host, in Redis, TTL refreshed by heartbeat) tells the trip publisher where to send. Location updates for a trip are routed by trip_id to the gateway holding the rider. Deltas carry the trip version and a per-driver seq; the client drops anything older than what it has. Location is lossy by design (the next ping replaces it), but status changes are not: on reconnect the client sends resume_from, and if the server's version is higher it sends the snapshot from the Trips DB. If the socket is gone, status changes also go out as mobile push notifications.

Surge pricing. Every 30–60 s per H3 res-7 cell (~5 km²), a stream job over Kafka computes open requests and estimate views vs available drivers, smoothed over neighboring cells and time so prices don't flap. The multiplier is read by Pricing and frozen into the estimate, so the rider pays what they were quoted even if surge changes before dispatch.

Peak demand and backpressure. On New Year's, requests can exceed drivers 5:1. Dispatch holds a per-city queue of searching trips; when queue age passes a threshold, surge rises and the app shows "high demand" before the request instead of letting trips time out silently. Fare-estimate traffic is the first thing shed (serve cached surge, skip per-request routing). Location pings degrade gracefully: idle drivers report every 10–15 s, on-trip drivers every 2–4 s, adjusting with speed and battery.

Trade-offs
  • City-sharded dispatch keeps searches and commits local and fast, but trips near city borders and airports need a cross-shard handoff, and one huge city needs sub-sharding by cell.
  • Sequential offers are simple and never double-book, but a chain of declines adds 15 s each; batched matching is more efficient but adds up to 2 s of wait and is harder to explain to drivers.
  • An in-memory geo index with no durability gives sub-ms reads and cheap writes, at the cost of a brief blind spot (one ping interval) after a shard failover.
  • Upfront pricing gives riders a firm price and the business takes route and traffic risk; metered pricing is fairer on detours but less predictable.
  • Lossy location pushes keep fanout cheap; correctness lives only in versioned trip status, so a reconnect costs one snapshot read.
  • Strongly consistent SQL for assignment costs some latency and limits write scale per shard; at ~1,500 assignments/s that is well within one shard per city.

Other operational points: a sweeper closes ghost trips (no driver ping for 5 minutes during an active trip → alert support, stop the meter) and expires stale offers. Each city has a home region plus a warm standby; failover moves the city's dispatch and Trips DB leadership, and the driver apps rebuild the geo index on their next ping. Hot cells (stadium letting out) get split to a finer H3 resolution and their own index partition.

Interviewer expectations

Mid-level (L4)

  • Clear request → match → track → pay flow with a working API and a trip state machine.
  • Knows lat/lng range queries in SQL won't scale and proposes geohash or a grid in memory.
  • Sizes the location write rate and sees it as the main load.

Senior (L5)

  • Cell-based geo index sharded by city, ranking by road ETA, widening search rings.
  • Atomic assignment with conditional updates or leases; explains the double-booking race and offer expiry.
  • WebSocket fanout with session routing, idempotent commands, and versioned reconnect.
  • Surge computed per cell from a stream and frozen into the quote; idempotent payment with a ledger.

Staff (L6+)

  • Compares sequential offers with batched bipartite matching and says when each wins.
  • Plans for failure: city-level regional failover, rebuilding the geo index from pings, ghost-trip and stuck-offer sweepers.
  • Backpressure under peak demand: what is shed first, how surge and queue age interact, adaptive ping rates.
  • Separates lossy streams (location) from must-not-lose state (trip status, money) and designs each accordingly.