HV
home / system-design / uber

Design Uber (Ride-Sharing)

The core challenge is real-time geospatial matching at scale — finding nearby drivers fast, handling millions of location updates per second, and pricing under surge demand.

1. Requirements

Functional
  • · Rider requests a ride (pickup + dropoff location)
  • · Match rider with a nearby available driver
  • · Real-time driver location tracking on the map
  • · Dynamic (surge) pricing and ETA estimation
  • · Trip lifecycle: requested → accepted → in-progress → completed
  • · Payments and ratings after trip completion
Scale Assumptions
  • · 10M daily active riders, 1M active drivers
  • · Driver app pings location every 4 sec
  • · 5M ride requests/day
  • · Peak concentration: rush hour in dense cities
  • · Location update QPS dominates over ride-request QPS

2. Estimation

Location update QPS: 1M active drivers / 4 sec interval = 250,000 updates/sec <- the real bottleneck Ride request QPS: 5M requests/day / 86,400 = ~58 QPS average (bursty: 5-10x at rush hour) Storage (location pings, if persisted for analytics): 250,000/sec x ~100 bytes x 86,400 = ~2.1 TB/day (usually NOT stored long-term, only latest position is kept in memory) Matching latency budget: User expects a driver match in < 3 seconds end-to-end

3. Core Challenge: Geospatial Indexing

"Find all available drivers within 2km of the rider" is a spatial range query — a plain SQL table scan or B-Tree index on (lat, lng) can't do this efficiently since neither column alone is sorted by proximity.

Geohash

Encodes (lat, lng) into a base32 string — nearby locations share a common prefix. Store drivers in Redis sorted by geohash prefix; query = prefix range scan.

✓ Simple, works with Redis GEOADD/GEOSEARCH natively
✗ Edge case: two nearby points can have very different prefixes near cell boundaries
Quadtree

Recursively subdivides the map into 4 quadrants until each cell has few enough drivers. Dense areas (city centers) get finer cells; sparse areas stay coarse.

✓ Adapts cell size to driver density — even load per cell
✗ More complex to shard/replicate across servers than a flat geohash

Uber's actual approach (simplified): a hierarchical hexagonal grid (similar to Google's S2 / Uber's own H3 library) that divides the world into hexagonal cells at multiple resolutions. Driver locations are bucketed by cell ID; matching expands outward ring-by-ring (search current cell → neighboring rings) until enough drivers are found.

4. Architecture

Driver App -- location ping every 4s --> Location Ingestion Service (gRPC/WebSocket) | Kafka (driver_location topic) | Location Service consumer updates: Redis GEO index: key=cell_id, members=driver_ids (in-memory, only latest position kept) Rider App -- POST /request_ride --> API Gateway --> Trip Service | Matching Service: 1. geohash/H3 cell of pickup point 2. query Redis GEO index, expand rings until N candidate drivers found 3. rank candidates: ETA (via routing service), driver rating, acceptance rate 4. send ride offer to top driver (timeout ~10s, fall through to next candidate on decline) | Pricing Service: base fare + surge multiplier (surge computed from live demand/supply ratio per geo-cell, updated every ~1 min) | Trip Service persists trip -> Trip DB Notification Service pushes to rider + driver (WebSocket / push notification for live updates) During trip: driver location streamed to rider's app via WebSocket / long-poll (subscribed to that specific trip_id channel)

5. Database Choices & Rationale

DataStorageWhy
Live driver locations Redis (GEO / sorted sets, in-memory) Sub-ms reads/writes at 250K updates/sec. Only latest position matters — no durability needed.
Trip records PostgreSQL / MySQL (sharded by trip_id or region) Needs ACID for payment-linked state transitions (requested → completed → paid).
Location history (analytics) Cassandra / data lake (S3 + Parquet) High write volume, append-only, time-series. Used for ETA model training, not live queries.
Driver/rider profiles PostgreSQL Relational data with strong consistency needs (ratings, payment methods).
Surge pricing state Redis counters per geo-cell Needs fast read/write of demand/supply ratio, recomputed every ~1 minute per cell.
ETA / routing Dedicated routing service (graph-based, e.g. OSRM-like) Shortest path over road graph — specialized algorithm (contraction hierarchies), not a generic DB.

6. Bottlenecks & Solutions

⚠️ 250K location updates/sec overwhelm a single Redis instance or DB
→

Shard the geo-index by region/cell range across multiple Redis nodes. Each shard owns a set of cells — writes route by cell_id hash. Use write-behind buffering (batch updates every 1-2s) instead of writing every single ping.

⚠️ Dense event (concert, stadium) causes a hot cell with thousands of ride requests + drivers in one small area
→

Dynamically subdivide hot cells into finer sub-cells (quadtree-style) so matching doesn't scan thousands of drivers per lookup. Apply surge pricing to naturally throttle demand.

⚠️ Two riders matched to the same driver simultaneously (race condition on driver availability)
→

Atomic compare-and-swap on driver status in Redis (Lua script: check-and-set 'available' -> 'matched' atomically) or a distributed lock per driver_id with short TTL.

⚠️ Rider app loses connection mid-trip and stops getting driver location updates
→

Fallback to periodic polling (every 5s) if WebSocket drops. Store the last N location updates server-side (short TTL) so a reconnecting client can catch up immediately instead of waiting for the next push.

7. Key Trade-offs Summary

DecisionOption AOption BChosen & Why
Geo-indexing Quadtree Geohash / H3 grid Hex grid (H3-like) — uniform neighbor lookups, easy ring expansion
Location storage Durable DB In-memory (Redis) Redis — only latest position matters, needs sub-ms writes at huge QPS
Matching strategy Global optimal assignment Greedy nearest-first with timeout Greedy — near-optimal is fine, users need a match in seconds, not a perfect one
Live trip updates Polling WebSocket push WebSocket — lower latency, less wasted bandwidth than polling every few seconds
Consistency (trip state) Eventual Strong (ACID) Strong — trip/payment state must never be ambiguous or double-charged
Surge computation Real-time exact Periodic approximation (~1 min) Periodic — exact real-time recompute isn't worth the cost; riders tolerate slightly stale surge

8. Follow-up Questions

Q: How do you compute surge pricing without recalculating for every single request?

A: Divide the map into geo-cells and maintain rolling demand (open ride requests) and supply (available drivers) counters per cell in Redis, recomputed on a fixed interval (e.g. every 30-60s) rather than per-request. The multiplier for a cell is looked up (O(1)) at request time instead of computed fresh.

Q: How would you handle a driver going offline mid-trip (app crash, no network)?

A: Trip service uses a heartbeat/timeout: if no location update for the active trip_id in N seconds, flag the trip as 'connection lost' and notify the rider, but don't cancel immediately — allow a grace period for reconnection before escalating to support/cancellation.

Q: How do you prevent double-booking the same driver from two concurrent ride requests?

A: Driver state transitions must be atomic: use a Redis Lua script (or DB row-level lock) to compare-and-swap status from 'available' to 'pending_match' — only one matching request can win the race; the loser retries with the next candidate driver.

Q: How would you scale the matching service globally across many cities?

A: Partition the entire system by geographic region (e.g. by city or country) — each region has its own matching service instance and geo-index shard, since a rider in Bangalore never needs to be matched with a driver in São Paulo. This keeps each shard's working set small and localized.