All designs
System Design

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

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

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:

  1. Historical speed by road segment: traffic data by time-of-day and day-of-week
  2. Real-time traffic: live data from drivers currently on those roads
  3. 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

DecisionChoiceReason
Driver location storageRedis GEONative geospatial; sub-millisecond radius queries
Location partitioningPer-city RedisPrevents hot-key problem; city = natural shard boundary
Double-dispatch preventionOptimistic lockingNo distributed locks needed; works at high concurrency
Trip location historyTimescaleDBTime-series optimized; fast range queries by trip_id + time
Surge pricingPer-cell demand ratioFine-grained; responds to local supply-demand imbalances
Real-time updatesWebSocketLow 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).

RJ

Rajat Jog

Senior Backend Engineer from Pune, India. Specialises in Python, Django, and FastAPI. Builds reliable production systems and open-sources the hard parts.

More Designs

You might also like

← Back to system design