Designing a Ride-Sharing App (Uber/Lyft)
Matching riders to drivers in real-time — geospatial indexing, dynamic pricing, the dispatch algorithm, and how to handle the thundering herd when it starts raining.
Like explaining to a 5-year-old: Imagine you’re in a big park and you need help carrying your backpack. You shout out “I need help!” and all the people nearby who are free and willing to help raise their hands. The park ranger (that’s the dispatch system) looks at who’s closest, who’s free, and who’s strong enough, then points to the best person and says “you — go help them.” That’s ride-sharing: matching people who need rides with nearby drivers who are available.
Uber operates in 10,000+ cities with millions of trips per day. The core challenge is a real-time matching problem: when a rider requests a ride, you have ~15 seconds to find, evaluate, and dispatch the best available driver nearby. Get it wrong and riders cancel; wait too long and drivers idle.
Requirements
Functional
- Rider requests a ride from location A to location B
- System finds available nearby drivers and dispatches the best match
- Rider and driver see each other’s real-time location during the trip
- Fare estimated upfront; surge pricing during high demand
- Trip history, ratings, payments
Non-Functional
- Matching latency: dispatch decision in < 2 seconds
- Location updates: 1M drivers updating location every 4 seconds
- Scale: 20M trips/day, 5M concurrent active sessions
- Consistency: a driver should never be matched to two riders simultaneously
Capacity Estimation
| Metric | Value |
|---|---|
| Active drivers (peak) | ~1M |
| Location updates/sec | ~250K |
| Trip requests/sec | ~230 (20M/day) |
| Active trip location streams | ~200K concurrent |
| Geospatial queries/sec | ~500K |
Location updates are the dominant write load: 250K writes/second to the driver location store.
System Components
Rider App ──────────────────────────────▶ Ride Request Service
│
Driver App ──▶ Location Update Service │ find nearby drivers
│ ▼
│ ┌─────────────────┐
└──▶ Driver │ Matching Engine │
Location └────────┬────────┘
Store │ dispatch
(Redis Geo) ▼
┌─────────────────┐
│ Driver App │
└─────────────────┘
│
┌─────────▼────────┐
│ Trip Service │
└─────────┬────────┘
│
┌─────────────┴──────────┐
▼ ▼
Location DB Trip History DB
(time-series) (PostgreSQL)
Geospatial Indexing: Finding Nearby Drivers
Given a rider at (lat, lng), find all available drivers within 5 km. You need a spatial index.
Option A: Geohash
The map squares analogy: Imagine dividing a map into a grid of squares. Each square has a name (like “drm3v”). Squares near each other have similar names. When you want things near you, you look for everything in your square and the 8 adjacent squares.
A geohash encodes a (lat, lng) pair into a string where similar prefixes = nearby locations.
import geohash2
# Driver at lat=37.7749, lng=-122.4194 (San Francisco)
driver_hash = geohash2.encode(37.7749, -122.4194, precision=6)
# → "9q8yy4"
# Rider requests from nearby location
rider_hash = geohash2.encode(37.7751, -122.4190, precision=6)
# → "9q8yy4" (same cell — driver is very close)
# Find nearby drivers: search current cell + 8 neighbors
nearby_cells = geohash2.neighbors("9q8yy4") + ["9q8yy4"]
Precision 6 = ~1.2km × 0.6km cells. Good for city-scale matching.
Option B: Redis GEO (Recommended)
Redis has native geospatial commands using the GEOADD / GEORADIUS interface, backed by a sorted set with geohash scores.
# Driver updates location every 4 seconds
redis.geoadd("drivers:available", driver_lng, driver_lat, driver_id)
# Rider requests: find drivers within 5km, sorted by distance
nearby = redis.georadius(
"drivers:available",
rider_lng, rider_lat,
radius=5, unit="km",
withdist=True, count=20, sort="ASC"
)
# → [("driver_42", 0.8km), ("driver_17", 1.2km), ("driver_99", 2.1km)]
Redis handles 250K geoadd/second easily. For 1M drivers, partition into city-level Redis instances: drivers:available:san_francisco, drivers:available:new_york.
Matching Algorithm
Finding nearby drivers is the easy part. Selecting the best match is harder. Simple scoring:
def score_driver(driver, rider_request) -> float:
distance_km = haversine(driver.location, rider_request.location)
eta_mins = distance_km / driver.avg_speed_kmh * 60
# Penalties and bonuses
if driver.rating < 4.0:
return 0 # exclude poor-rated drivers
score = (
- eta_mins * 0.6 # lower ETA is better
+ driver.acceptance_rate * 0.2 # reliable drivers preferred
+ driver.rating * 0.1 # higher-rated preferred
- driver.trips_today / 20 * 0.1 # spread load across drivers
)
return score
best_driver = max(nearby_drivers, key=lambda d: score_driver(d, request))
Avoiding Double-Dispatch
The hardest correctness problem: two rider requests arrive simultaneously, and the matching engine picks the same driver for both.
Solution: use optimistic locking in the database.
-- Driver assignment table
CREATE TABLE driver_assignments (
driver_id BIGINT PRIMARY KEY,
trip_id BIGINT,
assigned_at TIMESTAMPTZ,
version INT NOT NULL DEFAULT 0 -- optimistic lock version
);
-- Atomic assign: only succeeds if driver is still free
UPDATE driver_assignments
SET trip_id = ?, version = version + 1
WHERE driver_id = ? AND trip_id IS NULL AND version = ?
If two requests both try to assign the same driver, only one UPDATE will find trip_id IS NULL AND version = expected — the other gets 0 rows updated and must find a different driver.
Real-time Location Tracking During Trip
Once a trip starts, both rider and driver apps stream location every second. Rider sees driver’s live position.
Driver App
│ location update every 1s
▼
WebSocket Gateway
│
├──▶ Store in Redis (hot path: current location only)
│ SETEX location:{driver_id} 30 "lat,lng,ts"
│
└──▶ Write to TimescaleDB (cold path: full trip trace for history)
INSERT INTO locations (driver_id, lat, lng, ts) VALUES (...)
Rider App
│ subscribe via WebSocket
▼
WebSocket Gateway
│ read from Redis every 1s
▼
Rider App shows live driver pin
TimescaleDB stores the full GPS trace for trip replays, dispute resolution, and ML training.
Dynamic Pricing (Surge)
The umbrella shop analogy: When it starts raining, everyone wants an umbrella. The shop could sell out in 10 minutes, leaving most people without. Instead, they raise the price during rain — some people decide to wait, some pay more. This balances supply and demand. Surge pricing does the same: raise prices when riders > drivers, which recruits more drivers and reduces demand.
def calculate_surge(city_zone: str) -> float:
active_riders = count_requesting_riders(city_zone, last_5_min=True)
active_drivers = count_available_drivers(city_zone)
ratio = active_riders / max(active_drivers, 1)
if ratio < 1.2: return 1.0 # normal
if ratio < 1.8: return 1.5 # mild surge
if ratio < 2.5: return 2.0 # high surge
return min(ratio * 0.9, 3.5) # cap at 3.5x
Surge zones are computed per city grid cell (geohash cells) every 30 seconds.
ETA Calculation
Raw distance / average speed is too naive. Production ETA uses:
- Historical speed by road segment: traffic data by time-of-day and day-of-week
- Real-time traffic: live data from drivers currently on those roads
- Routing engine: A* or Dijkstra on the road network graph
Uber uses a routing engine fed by real-time telemetry from the fleet. The same data powers the live “traffic layer” you see on the map.
Trade-offs & Decisions
| Decision | Choice | Reason |
|---|---|---|
| Driver location storage | Redis GEO | Native geospatial; sub-millisecond radius queries |
| Location partitioning | Per-city Redis | Prevents hot-key problem; city = natural shard boundary |
| Double-dispatch prevention | Optimistic locking | No distributed locks needed; works at high concurrency |
| Trip location history | TimescaleDB | Time-series optimized; fast range queries by trip_id + time |
| Surge pricing | Per-cell demand ratio | Fine-grained; responds to local supply-demand imbalances |
| Real-time updates | WebSocket | Low latency push; better than polling for live tracking |
Ride-sharing is a matching problem wrapped in a geospatial problem wrapped in a real-time problem. The hardest part isn’t any one component — it’s the coordination: matching must be fast (< 2s), consistent (no double-dispatch), and fair (don’t always send the closest driver; consider driver fairness too).
You might also like
Designing a Chat System
Behind the scenes of real-time messaging — WebSockets, message storage, delivery guarantees, presence indicators, and how WhatsApp serves 100B messages per day.
Designing a Notification Service
Sending billions of push, email, and SMS notifications reliably — fan-out queues, delivery guarantees, user preference management, and why idempotency is everything.
Designing a Web Crawler
How Google indexes the internet — distributed BFS across billions of pages, politeness constraints, content deduplication, and the surprisingly hard problem of deciding what to crawl next.