# Designing a Ride-Hailing Platform: How I Would Build the Next Uber

## Blog Details

- **Author**: Naveen R.
- **Date**: September 2, 2026
- **Tags**: system design, ride-hailing, distributed systems, geospatial indexing, real-time matching
- **Read Time**: 20 mins

When I sit down to design a ride-hailing platform like Uber or Lyft, I'm immediately confronted with a deceptively simple problem: connect a rider who wants a ride with the nearest available driver, and do it in seconds. Simple to state, but the technical challenges are staggering. I need to track millions of moving vehicles in real-time, process hundreds of thousands of location updates per second, match riders to drivers in under 30 seconds, prevent double-booking, calculate dynamic pricing based on supply and demand, and ensure the entire system stays up 99.99% of the time.
x
In this post, I'll walk through exactly how I would design this system from the ground up. This isn't interview prep. This is my engineering approach to building a production-grade ride-hailing platform that can scale to serve 30 million daily active users across multiple cities. I'll cover everything from scoping the problem to the intricate details of geospatial indexing, matching algorithms, state management, and surge pricing.

## Scoping the Problem and Clarifying Assumptions

Before I write a single line of code, I need to understand exactly what I'm building. When I think about a ride-hailing platform, I'm focusing on the Uber/Lyft model: on-demand ride matching where riders request rides through a mobile app, drivers accept requests and provide the service, and the entire trip is tracked in real-time with GPS.

Here's what I'm explicitly including in my design:

**Core User Flows:**
- Riders can request rides with a pickup location and destination
- Drivers receive ride requests and can accept or decline
- Both parties see real-time location tracking during the trip
- Automated fare calculation and payment processing
- Dynamic surge pricing based on supply and demand

**What I'm NOT building (at least not in this design):**
- Ride scheduling for future times (though I'll note where the architecture could accommodate it)
- Carpooling/ride-sharing with multiple riders
- Food delivery or package delivery (different optimization constraints)
- Driver background checks and onboarding (important but outside the core technical system)

**Key Assumptions:**
- I'm designing for a major metropolitan area initially, with plans to scale globally
- Drivers send GPS updates every 3-5 seconds when online
- The average ride takes 15-20 minutes
- Payment processing integrates with third-party providers (Stripe, PayPal)
- I need to support both iOS and Android mobile apps
- The system must work across different vehicle types (economy, XL, luxury)

**How Other Platforms Differ:**
Traditional taxi dispatch systems work fundamentally differently: they typically use radio dispatch with manual assignment and don't require real-time GPS tracking of all vehicles. Food delivery platforms like DoorDash have different constraints: they optimize for batch pickup (multiple orders from one restaurant) and don't need the same level of real-time rider tracking since the "rider" (restaurant) is stationary.

## Functional and Non-Functional Requirements

### Functional Requirements

Here's what my system absolutely must do:

1. **Driver Location Tracking**: Continuously ingest and store GPS coordinates from active drivers
2. **Proximity Search**: Find available drivers near a rider's pickup location within seconds
3. **Ride Matching**: Assign the optimal driver to a ride request based on distance, ETA, and other factors
4. **Real-time Updates**: Push location updates to riders and drivers during active trips
5. **Trip State Management**: Track the complete lifecycle from request through completion
6. **Fare Calculation**: Compute ride costs based on distance, time, and surge multipliers
7. **Payment Processing**: Handle secure payment transactions
8. **Surge Pricing**: Dynamically adjust pricing based on supply-demand imbalance

### Non-Functional Requirements

The performance targets I need to hit are aggressive:

**Scale:**
- 30 million daily active users (DAU)
- 2 million active drivers at peak hours
- 500,000 concurrent rides at peak
- 10 million rides per day

**Performance:**
- Location ingestion: 500,000 writes per second at peak
- Matching latency: < 30 seconds from request to driver assignment
- Geo-query latency: < 10ms for proximity search
- Dispatch latency: < 500ms p99 for sending offers to drivers
- Real-time location updates: sub-second delivery to riders

**Availability:**
- 99.99% uptime during peak hours (maximum 52 minutes downtime per year)
- Graceful degradation when components fail

**Consistency:**
- Strong consistency for driver assignment (no double-booking)
- Eventual consistency acceptable for location updates and analytics

### Back-of-the-Envelope Capacity Estimation

Let me work through the numbers to understand what I'm dealing with:

**Location Update Volume:**
- 2M active drivers × (1 update / 4 seconds) = **500K writes/second**
- Each update: ~200 bytes (driver_id, lat, lng, timestamp, heading, speed)
- Bandwidth: 500K × 200 bytes = 100 MB/s = **800 Mbps**
- Daily storage: 500K × 86,400 seconds × 200 bytes = **8.64 TB/day** (if storing all raw data)

**Matching Query Volume:**
- 10M rides/day ÷ 86,400 seconds = ~116 rides/second average
- Peak multiplier: 5x = **580 ride requests/second**
- Each request triggers: 1 proximity search + N driver eligibility checks + top 3 ETA calculations
- Proximity query load: **50,000 queries/second** (including retries, radius expansions, and ongoing trip updates)

**Storage Requirements:**
- Per ride metadata: ~100KB (rider info, driver info, route, fare breakdown, timestamps)
- 10M rides/day × 100KB = **1 TB/day** for ride data
- Annual: ~365 TB for ride history
- Location history per ride: ~200 points × 200 bytes = 40KB
- Total with location history: ~1.4 TB/day

**Network Bandwidth:**
- Location ingestion: 800 Mbps
- WebSocket connections: 2M drivers + 500K active riders = 2.5M concurrent connections
- Location fan-out to riders: 500K active trips × 1 update/4 sec × 200 bytes = 25 MB/s = 200 Mbps
- Total peak: **~5 Gbps** (accounting for API calls, matching traffic, and overhead)

These numbers tell me I need a distributed system with careful partitioning, caching strategies, and specialized data stores for different access patterns.

## High-Level Architecture and Core Components

Here's how I would structure the system at a high level:

![High-level architecture: rider and driver apps reach a load balancer and API gateway that routes to the Location, Matching, and Trip Management services; Location writes driver positions to Redis Geo, Matching runs proximity search against Redis Geo and takes dispatch locks in Redis, and Trip Management persists state to PostgreSQL and pushes real-time updates back to the apps through a notification service](https://d5osvdbc8um23.cloudfront.net/static-asset/blog_images/system-design-ride-hailing/01-high-level-architecture.png)

**Component Responsibilities:**

**Gateway Service**: API gateway handling authentication, rate limiting, and request routing. I'm using this as the single entry point for all mobile app traffic.

**Location Service**: Ingests GPS updates from drivers at 500K writes/second. I'm writing to Redis Geo for hot data (active drivers) and asynchronously streaming to a data lake for analytics. This service is stateless and horizontally scalable.

**Matching Service**: The brain of the operation. When a ride request comes in, this service queries Redis Geo for nearby drivers, applies filtering logic, scores candidates, and dispatches offers. I'm keeping this stateless so I can scale it horizontally to handle 50K queries/second.

**Trip Management Service**: Owns the trip state machine and orchestrates the entire ride lifecycle. This writes to PostgreSQL for transactional consistency and publishes events to a message queue for downstream consumers.

**Redis Geo (Hot Location Store)**: Stores current driver positions with geospatial indexing. I'm using Redis because it gives me sub-10ms proximity queries, which is critical for matching performance.

**Redis Lock + Cache**: Distributed locking to prevent double-dispatch, plus caching for routing results and driver metadata.

**PostgreSQL (Trip State)**: Primary source of truth for trip state, fare calculations, and payment records. I need ACID guarantees here to ensure financial consistency.

**Surge Pricing Service**: Monitors supply-demand ratios per geographic zone and calculates surge multipliers in real-time.

**Notification Service**: Manages WebSocket/MQTT connections to push real-time updates to mobile apps. I'm separating this into its own service because managing millions of persistent connections requires specialized infrastructure.

**Routing/ETA Service**: Wraps external routing APIs (OSRM, Google Maps) with caching and fallback logic to calculate accurate ETAs without breaking the bank on API costs.

The key architectural principle I'm following is **geographic partitioning**. Instead of having a single global matching service that knows about all drivers worldwide, I'm partitioning by city or region. Each partition can operate independently, which eliminates cross-region coordination and keeps latencies low.

### Scaling It Out

The clean view above hides what it takes to hold 30M users and 500K location writes/second. Scaled out, I'd front everything with a load balancer and rate-limiting gateway over pools of stateless service replicas; keep the write-heavy paths async through Kafka into an analytics lake and the surge/forecasting pipeline; split storage by job: Redis Geo for hot positions, Redis for locks and cache, PostgreSQL for transactional trip state; and treat routing/maps and payments as external APIs behind caches.

![Scalable architecture: rider/driver apps through a load balancer and rate-limiting API gateway to replicated Location, Matching, and Trip Management services; Location writes Redis Geo and streams to Kafka; Matching queries Redis Geo, takes Redis locks, and calls an external routing API for ETAs; Trip Management persists to PostgreSQL, emits events to Kafka, charges an external payment API, and pushes updates via the WebSocket notification service; Kafka feeds an analytics warehouse and the surge-pricing service, which an ML demand-forecast model informs; monitoring observes the system](https://d5osvdbc8um23.cloudfront.net/static-asset/blog_images/system-design-ride-hailing/02-scalable-architecture.png)

## Deep-Dive: Real-Time Location Ingestion and Geospatial Indexing

This is where the rubber meets the road, literally. I need to track 2 million moving vehicles, and the naive approach simply doesn't work.

![Location ingestion and geospatial index: driver apps send a GPS update every few seconds to the Location Service, which GEOADDs the position into Redis Geo (geohash cells with a 30-second TTL) and asynchronously streams the raw points to a location-history warehouse via Kafka; the Matching Service runs GEORADIUS proximity queries against Redis Geo to find nearby drivers](https://d5osvdbc8um23.cloudfront.net/static-asset/blog_images/system-design-ride-hailing/03-location-geospatial.png)

### Why Traditional Databases Fail

When I first think about storing driver locations, my instinct might be to use a relational database:

```sql
CREATE TABLE drivers (
    driver_id BIGINT PRIMARY KEY,
    latitude DECIMAL(10, 8),
    longitude DECIMAL(11, 8),
    last_updated TIMESTAMP,
    status VARCHAR(20)
);

-- Find nearby drivers
SELECT driver_id, latitude, longitude
FROM drivers
WHERE status = 'available'
  AND SQRT(POW(latitude - 37.7749, 2) + POW(longitude - (-122.4194), 2)) < 0.02;
```

This query is a disaster. Here's why:

1. **Full table scan**: The WHERE clause calculates distance for every single row. With 2 million drivers, that's 2 million distance calculations per query.
2. **No spatial index**: Traditional B+ tree indexes work on one-dimensional data. Latitude and longitude are two dimensions, so a standard index can't help.
3. **CPU-intensive**: The SQRT and POW functions are expensive, and I'm running them millions of times.

When I benchmark this, I'm looking at **seconds** per query. I need **milliseconds**. This approach is fundamentally broken for geospatial queries at scale.

### Understanding Geospatial Indexing

The breakthrough insight is that I need to convert 2D coordinates into a 1D representation that preserves spatial locality, so points that are close in 2D space should be close in 1D space. This lets me use standard database indexes.

There are three main approaches I consider:

#### Option 1: QuadTrees

A QuadTree recursively divides 2D space into four equal quadrants. Each node represents a bounding box, and leaf nodes contain the actual driver locations.

**How it works:**
- Start with the entire world as the root node
- If a node contains more than N drivers, split it into 4 quadrants (NW, NE, SW, SE)
- Recursively split until each leaf has ≤ N drivers
- To find nearby drivers, traverse the tree starting from the rider's quadrant

**Advantages:**
- Automatically adapts to driver density: downtown Manhattan gets fine-grained subdivision, rural areas stay coarse
- Efficient for uneven distributions

**Disadvantages:**
- Rebalancing during rush hour is expensive
- Tree structure is complex to maintain in a distributed system
- Not easily shardable across multiple servers

MongoDB uses QuadTree-based 2dsphere indexes for geospatial queries, and they work well for moderate scale. But at 2 million drivers, I need something simpler.

#### Option 2: Geohash

Geohash is elegant: it interleaves the bits of latitude and longitude to create a single string where **shared prefix = spatial proximity**.

**The encoding process:**
1. Convert latitude and longitude to binary using iterative bisection
2. Interleave the bits: lat[0], lng[0], lat[1], lng[1], ...
3. Encode the resulting bit string in base32

**Example for San Francisco (37.7749, -122.4194):**
- Geohash: `9q8yyk8ytpxr`
- Precision levels:
  - `9q` (~1700km) - California region
  - `9q8y` (~80km) - San Francisco Bay Area
  - `9q8yyk` (~5km) - Downtown SF
  - `9q8yyk8ytpxr` (~1m) - Specific building

**The magic property**: All drivers with geohash prefix `9q8yyk` are within ~5km of each other. This means I can use a simple string prefix search to find nearby drivers:

```redis
# Store drivers by geohash
ZADD drivers:9q8yyk 1234567890 driver_123
ZADD drivers:9q8yyk 1234567891 driver_456

# Find all drivers in this cell
ZRANGE drivers:9q8yyk 0 -1
```

**The critical insight**: Each character I remove from the geohash multiplies the search area by 32x (base32 encoding). So I can start with a 6-character prefix (5km radius), and if I don't find enough drivers, expand to 5 characters (80km radius).

**Limitations:**
- Edge problem: Two points near a cell boundary might be close but have different prefixes
- Non-uniform cell sizes: Cells near the poles are smaller than cells near the equator

#### Option 3: Production-Grade Solutions

Based on my research, here's what actually works at Uber/Lyft scale:

**Uber H3 (Hierarchical Hexagonal Grid):**

Uber built H3 to solve geohash's edge problem. Instead of squares, H3 uses **hexagons** arranged in a hierarchical grid.

**Why hexagons?**
- Every hexagon has 6 neighbors at equal distance (squares have 4 close neighbors and 4 far corners)
- Uniform area across the globe (no polar distortion)
- Better for visualizing surge pricing zones, with no weird artifacts at cell boundaries

Uber uses H3 primarily for **surge pricing zones** and **supply-demand balancing**, not for real-time proximity search (they still use geohash-like approaches for that).

**Google S2 Geometry:**

S2 projects the sphere onto a cube and uses a Hilbert curve to create a 1D index. The Hilbert curve has better spatial locality preservation than geohash's Z-order curve.

Multiple ride-sharing platforms have adopted S2, particularly for region-based sharding and cross-border operations.

**Grab's GrabNearby:**

Grab (Southeast Asia's largest ride-hailing platform) published their approach: **Redis Geo + geohash** for proximity search, handling **2M+ active drivers** with **sub-10ms query latency**.

This is exactly what I need.

### My Implementation: Redis Geo

Here's how I would implement location ingestion and proximity search:

**Data Model:**

```redis
# Separate sorted sets per vehicle type and city
GEOADD drivers:available:sf:economy -122.4194 37.7749 driver_123
GEOADD drivers:available:sf:xl -122.4194 37.7749 driver_456

# Store additional metadata in hashes
HSET driver:123 heading 45 speed 30 rating 4.8 status available
```

**Location Ingestion Flow:**

```python
def ingest_location_update(driver_id, lat, lng, city, vehicle_type):
    # 1. Update geospatial index
    key = f"drivers:available:{city}:{vehicle_type}"
    redis.geoadd(key, lng, lat, driver_id)
    
    # 2. Set TTL to auto-expire stale drivers
    redis.expire(f"driver:{driver_id}", 30)  # 30 second TTL
    
    # 3. Update metadata
    redis.hset(f"driver:{driver_id}", mapping={
        "lat": lat,
        "lng": lng,
        "last_update": time.time(),
        "heading": heading,
        "speed": speed
    })
    
    # 4. Async: Stream to data lake for analytics
    kafka_producer.send("driver_locations", {
        "driver_id": driver_id,
        "lat": lat,
        "lng": lng,
        "timestamp": time.time()
    })
```

**Proximity Search:**

```python
def find_nearby_drivers(pickup_lat, pickup_lng, city, vehicle_type, radius_km=5):
    key = f"drivers:available:{city}:{vehicle_type}"
    
    # Redis GEORADIUS: O(N + log(M)) where N = results, M = total drivers
    drivers = redis.georadius(
        key,
        pickup_lng,
        pickup_lat,
        radius_km,
        unit='km',
        withdist=True,
        sort='ASC',
        count=20  # Top 20 nearest drivers
    )
    
    # Returns: [(driver_id, distance), ...]
    return drivers
```

**Performance Characteristics:**

- **Write throughput**: Redis can handle 500K writes/second on a moderately sized cluster
- **Query latency**: Sub-10ms for GEORADIUS queries, even with millions of keys
- **Memory usage**: ~200 bytes per driver × 2M drivers = 400MB per city/vehicle-type combination
- **Auto-expiry**: The 30-second TTL ensures offline drivers are automatically removed

**Handling the Edge Problem:**

Geohash has an edge problem: two drivers on opposite sides of a cell boundary might be close but have different prefixes. Here's how I handle it:

```python
def find_nearby_drivers_robust(pickup_lat, pickup_lng, city, vehicle_type):
    # 1. Search primary cell
    drivers = find_nearby_drivers(pickup_lat, pickup_lng, city, vehicle_type, radius_km=5)
    
    if len(drivers) < 10:
        # 2. Expand radius if not enough drivers
        drivers = find_nearby_drivers(pickup_lat, pickup_lng, city, vehicle_type, radius_km=10)
    
    if len(drivers) < 5:
        # 3. Final fallback: search neighboring geohash cells
        geohash = encode_geohash(pickup_lat, pickup_lng, precision=6)
        neighbors = get_geohash_neighbors(geohash)
        
        for neighbor in neighbors:
            additional = search_geohash_cell(neighbor, city, vehicle_type)
            drivers.extend(additional)
    
    return drivers[:20]  # Return top 20
```

**Geographic Partitioning:**

To scale beyond a single Redis instance, I'm using **consistent hashing** to partition by city:

```python
# Uber's Ringpop-inspired approach
def get_location_service_node(city):
    # Hash city to a node in the consistent hash ring
    # Each node owns a geographic region
    return consistent_hash_ring.get_node(city)

# Route location updates to the appropriate node
location_service = get_location_service_node("san_francisco")
location_service.ingest_location_update(...)
```

This gives me:
- **Local proximity queries**: No cross-region coordination
- **Horizontal scalability**: Add nodes as I expand to new cities
- **Fault isolation**: If the NYC node fails, SF rides continue unaffected

### Monitoring and Optimization

I'm tracking these metrics:

- **Location update lag**: p99 latency from GPS ping to Redis write (target: <100ms)
- **Stale driver rate**: % of drivers whose last update is >10 seconds old (target: <1%)
- **Proximity query latency**: p99 GEORADIUS latency (target: <10ms)
- **Memory usage per city**: Ensures I don't exceed Redis memory limits

**Optimization: Adaptive Update Frequency**

To reduce write volume, I'm using adaptive update frequency:

```python
def should_send_location_update(driver):
    # Send updates more frequently when:
    # 1. Driver is on an active trip (rider needs real-time tracking)
    # 2. Driver is moving quickly (heading toward pickup)
    # 3. Driver is in a high-demand zone
    
    if driver.on_trip:
        return every_3_seconds()
    elif driver.speed > 20:  # mph
        return every_4_seconds()
    else:
        return every_5_seconds()
```

This reduces write volume by ~20% without impacting match quality.

## Deep-Dive: Rider-Driver Matching and Dispatch

Matching is the heart of the system. When a rider requests a ride, I have **30 seconds** to find a driver, or the rider will cancel and try a competitor. Here's how I make that happen.

![Matching and dispatch: a rider request goes to the Matching Service, which pulls the top ~20 nearby drivers from Redis Geo, filters and scores them (ETA, rating, detour), calling an external routing API only for the top few ETAs, then acquires an atomic Redis driver lock (SET NX EX) and sends a 15-second offer to one driver; on accept the trip goes to Trip Management, on decline or timeout it retries the next candidate](https://d5osvdbc8um23.cloudfront.net/static-asset/blog_images/system-design-ride-hailing/04-matching-dispatch.png)

### The Matching Pipeline

My matching service follows this flow:

1. **Proximity Search**: Find candidate drivers within radius
2. **Filter**: Apply eligibility criteria
3. **Score**: Rank candidates by multiple factors
4. **Dispatch**: Send offer to top candidate(s)
5. **Wait**: Give driver 15 seconds to respond
6. **Retry**: If declined/timeout, try next candidate

Let me break down each step.

### Step 1: Proximity Search

I start by querying Redis Geo for the top 20 nearest drivers:

```python
def get_candidate_drivers(ride_request):
    candidates = redis.georadius(
        f"drivers:available:{ride_request.city}:{ride_request.vehicle_type}",
        ride_request.pickup_lng,
        ride_request.pickup_lat,
        radius_km=5,
        withdist=True,
        sort='ASC',
        count=20
    )
    
    return candidates  # [(driver_id, distance_km), ...]
```

**Why 20 candidates?** I need enough drivers to account for filtering (some won't meet criteria) and retries (first driver might decline). Research shows 20 gives a good balance between query cost and match success rate.

### Step 2: Filter for Eligibility

Not all nearby drivers can accept the ride:

```python
def filter_eligible_drivers(candidates, ride_request):
    eligible = []
    
    for driver_id, distance in candidates:
        driver = get_driver_metadata(driver_id)
        
        # Eligibility checks
        if driver.status != 'available':
            continue  # Driver might have just accepted another ride
        
        if driver.rating < 4.0:
            continue  # Minimum rating threshold
        
        if driver.vehicle_capacity < ride_request.passenger_count:
            continue  # Vehicle too small
        
        if driver.consecutive_trips >= 10:
            continue  # Fairness: give other drivers a chance
        
        if is_in_blocked_zone(driver.lat, driver.lng):
            continue  # Airport waiting lots, private zones, etc.
        
        eligible.append((driver_id, distance, driver))
    
    return eligible
```

**Consistency Challenge**: Between the proximity search and the filter check, a driver's status might have changed. I handle this with **optimistic locking**: I'll verify the driver is still available when I acquire the dispatch lock (next section).

### Step 3: Score and Rank

Distance alone isn't enough. I need to consider multiple factors:

```python
def score_drivers(eligible_drivers, ride_request):
    scored = []
    
    for driver_id, distance, driver in eligible_drivers:
        # Calculate ETA (fast path: haversine / avg speed)
        eta_minutes = estimate_eta_fast(driver, ride_request.pickup_location)
        
        # Scoring formula
        score = (
            10.0 / eta_minutes  # Prefer shorter ETA (weight: high)
            + 0.5 * driver.acceptance_rate  # Prefer reliable drivers
            + 0.3 * driver.rating  # Prefer high-rated drivers
            - 0.2 * calculate_detour_cost(driver, ride_request)  # Avoid big detours
        )
        
        scored.append((driver_id, score, eta_minutes))
    
    # Sort by score descending
    scored.sort(key=lambda x: x[1], reverse=True)
    return scored
```

**ETA Estimation Strategy:**

I use a two-tier approach to balance accuracy and cost:

```python
def estimate_eta(driver, pickup_location, is_top_candidate=False):
    # Fast path: Haversine distance / average speed
    # Accuracy: ~80%, Latency: <1ms, Cost: free
    if not is_top_candidate:
        distance_km = haversine_distance(driver.location, pickup_location)
        avg_speed_kmh = get_avg_speed_for_area(driver.location, time_of_day)
        return (distance_km / avg_speed_kmh) * 60  # minutes
    
    # Accurate path: External routing API
    # Accuracy: ~95%, Latency: 50-200ms, Cost: $0.005 per call
    else:
        # Check cache first
        cache_key = f"route:{driver.location_cell}:{pickup_location_cell}:{hour}"
        cached = redis.get(cache_key)
        if cached:
            return cached
        
        # Call routing API
        route = osrm_api.get_route(driver.location, pickup_location)
        eta = route.duration_minutes
        
        # Cache for 30 seconds (traffic changes)
        redis.setex(cache_key, 30, eta)
        return eta
```

**Why this works:**
- I use the fast path for initial scoring of all 20 candidates (<20ms total)
- I call the routing API only for the top 3 candidates (150ms total)
- Caching reduces API costs by 70-80% during stable traffic conditions

**Detour Cost:**

If a driver is heading north and the pickup is south, that's a bad match:

```python
def calculate_detour_cost(driver, ride_request):
    # If driver is moving, penalize pickups in opposite direction
    if driver.speed > 5:  # mph, driver is moving
        driver_heading = driver.heading  # degrees
        pickup_bearing = calculate_bearing(driver.location, ride_request.pickup_location)
        
        angle_diff = abs(driver_heading - pickup_bearing)
        if angle_diff > 180:
            angle_diff = 360 - angle_diff
        
        # Penalty increases with angle difference
        return angle_diff / 180.0  # 0 to 1
    
    return 0  # Driver is stationary, no detour penalty
```

### Step 4: Dispatch with Double-Booking Prevention

This is the most critical part. I need to ensure **exactly one driver is offered the ride at a time**, with no double-booking.

```python
def dispatch_offer(ride_id, driver_id, eta_minutes):
    # Acquire distributed lock on driver
    lock_key = f"driver_lock:{driver_id}"
    lock_acquired = redis.set(lock_key, ride_id, nx=True, ex=20)  # 20 second TTL
    
    if not lock_acquired:
        # Driver is already considering another offer
        return DispatchResult.DRIVER_BUSY
    
    try:
        # Double-check driver is still available (optimistic locking)
        driver_status = redis.hget(f"driver:{driver_id}", "status")
        if driver_status != "available":
            return DispatchResult.DRIVER_UNAVAILABLE
        
        # Create offer record
        offer = {
            "offer_id": generate_id(),
            "ride_id": ride_id,
            "driver_id": driver_id,
            "eta_minutes": eta_minutes,
            "expires_at": time.time() + 15,  # 15 second timeout
            "status": "pending"
        }
        db.insert("offers", offer)
        
        # Send push notification to driver app
        notification_service.send_offer(driver_id, offer)
        
        # Schedule timeout handler
        task_queue.schedule(
            delay_seconds=15,
            task=handle_offer_timeout,
            args=(offer["offer_id"],)
        )
        
        return DispatchResult.SUCCESS
    
    finally:
        # Lock will auto-expire in 20 seconds if not released
        pass
```

**The Redis lock is critical**: `SET driver_lock:{id} NX EX 20` is atomic: it only succeeds if the key doesn't exist, and it automatically expires in 20 seconds. This prevents:
- Two rides from offering the same driver simultaneously
- Deadlocks (TTL ensures locks don't get stuck)

**Why 15 seconds timeout?** Research shows:
- Drivers respond to offers in **8-12 seconds** on average
- 15 seconds gives enough time without making riders wait too long
- If no response, I move to the next candidate

### Step 5: Handle Driver Response

```python
def handle_driver_response(offer_id, response):
    offer = db.get_offer(offer_id)
    
    if offer.status != "pending":
        return  # Already handled (timeout or another response)
    
    if response == "accept":
        # Mark offer as accepted
        db.update_offer(offer_id, status="accepted")
        
        # Update ride with driver assignment
        db.update_ride(offer.ride_id, 
            driver_id=offer.driver_id,
            status="matched",
            eta_minutes=offer.eta_minutes
        )
        
        # Update driver status
        redis.hset(f"driver:{offer.driver_id}", "status", "on_trip")
        
        # Remove driver from available pool
        redis.zrem(f"drivers:available:{city}:{vehicle_type}", offer.driver_id)
        
        # Release lock
        redis.delete(f"driver_lock:{offer.driver_id}")
        
        # Notify rider
        notification_service.notify_rider(offer.ride_id, "Driver is on the way!")
        
    elif response == "decline":
        # Mark offer as declined
        db.update_offer(offer_id, status="declined")
        
        # Release lock
        redis.delete(f"driver_lock:{offer.driver_id}")
        
        # Retry with next candidate
        retry_match(offer.ride_id)
```

### Step 6: Retry Logic

If the driver declines or times out:

```python
def retry_match(ride_id):
    ride = db.get_ride(ride_id)
    
    # Check if we've exceeded max retries
    if ride.match_attempts >= 5:
        # Expand search radius
        if ride.search_radius < 20:  # km
            ride.search_radius = min(ride.search_radius * 2, 20)
            ride.match_attempts = 0  # Reset attempts for new radius
        else:
            # No drivers available
            db.update_ride(ride_id, status="no_drivers_available")
            notification_service.notify_rider(ride_id, "No drivers available. Please try again.")
            return
    
    # Increment attempt counter
    db.update_ride(ride_id, match_attempts=ride.match_attempts + 1)
    
    # Re-run matching with updated parameters
    match_ride(ride_id)
```

**Radius Expansion Strategy:**
- Start: 5km radius (urban areas)
- First expansion: 10km
- Final expansion: 20km
- If still no match, inform rider

### Performance Optimizations

**Batching Offers (Experimental):**

Some platforms send offers to multiple drivers simultaneously to reduce time-to-match:

```python
def dispatch_batch_offers(ride_id, top_drivers, batch_size=3):
    # Send to top 3 drivers simultaneously
    # First to accept wins
    
    offers = []
    for driver_id, score, eta in top_drivers[:batch_size]:
        offer = dispatch_offer(ride_id, driver_id, eta)
        offers.append(offer)
    
    # Wait for first acceptance or all timeouts
    # Cancel remaining offers when one accepts
```

**Trade-offs:**
- **Pro**: Faster average match time (reduces p99 latency)
- **Con**: Wastes driver attention (drivers see offers that get canceled)
- **Con**: Can feel unfair to drivers

I would start with sequential dispatch and only move to batching if match times exceed SLA.

**Precomputed Candidate Lists:**

For high-demand areas, I can precompute candidate lists:

```python
# Background job runs every 10 seconds
def precompute_hotspot_candidates():
    hotspots = get_current_hotspots()  # Airports, stadiums, etc.
    
    for hotspot in hotspots:
        candidates = find_nearby_drivers(hotspot.lat, hotspot.lng, ...)
        redis.setex(
            f"candidates:{hotspot.id}",
            10,  # 10 second TTL
            json.dumps(candidates)
        )
```

When a ride request comes from a hotspot, I can skip the proximity search entirely.

### Monitoring and Alerting

I'm tracking:
- **Match success rate**: % of ride requests successfully matched (target: >95%)
- **Time to match**: p50, p95, p99 latency from request to driver acceptance (target: p95 <30s)
- **Retry rate**: % of rides requiring >1 dispatch attempt (target: <30%)
- **Radius expansion rate**: % of rides requiring expanded search radius (target: <10%)

If match success rate drops below 90%, I'm paging the on-call engineer, because this directly impacts revenue.

## Deep-Dive: Trip Lifecycle, State Management, and Payments

Once a driver accepts a ride, I need to orchestrate the entire trip lifecycle, from pickup through completion and payment. This requires careful state management and consistency guarantees.

![Trip lifecycle: rider and driver app actions drive the Trip Management state machine, which makes atomic transitions in PostgreSQL (row-locked with FOR UPDATE) and emits events to Kafka; those events fan out through the WebSocket notification service back to both apps for live updates; on completion the Fare Calculation service computes the fare and charges an idempotent Stripe payment](https://d5osvdbc8um23.cloudfront.net/static-asset/blog_images/system-design-ride-hailing/05-trip-lifecycle.png)

### The Trip State Machine

Here's the complete state machine I'm implementing:

```
requested → matched → driver_en_route → arrived → 
rider_on_board → in_progress → completed → paid

                    ↓ (cancellation paths)
              canceled_by_rider
              canceled_by_driver
              canceled_timeout
```

**State Definitions:**

- **requested**: Rider submitted request, matching in progress
- **matched**: Driver assigned, notified, and accepted
- **driver_en_route**: Driver heading to pickup location
- **arrived**: Driver at pickup location, waiting for rider
- **rider_on_board**: Rider confirmed pickup, trip started
- **in_progress**: Trip active, tracking location
- **completed**: Rider reached destination
- **paid**: Payment processed successfully

### State Persistence and Consistency

I'm using PostgreSQL for trip state because I need **ACID guarantees**:

```sql
CREATE TABLE trips (
    trip_id BIGINT PRIMARY KEY,
    rider_id BIGINT NOT NULL,
    driver_id BIGINT,
    status VARCHAR(50) NOT NULL,
    
    pickup_lat DECIMAL(10, 8) NOT NULL,
    pickup_lng DECIMAL(11, 8) NOT NULL,
    pickup_address TEXT,
    
    dropoff_lat DECIMAL(10, 8),
    dropoff_lng DECIMAL(11, 8),
    dropoff_address TEXT,
    
    requested_at TIMESTAMP NOT NULL,
    matched_at TIMESTAMP,
    started_at TIMESTAMP,
    completed_at TIMESTAMP,
    
    estimated_fare_cents INT,
    final_fare_cents INT,
    surge_multiplier DECIMAL(3, 2) DEFAULT 1.0,
    
    cancellation_reason TEXT,
    
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),
    
    INDEX idx_rider_id (rider_id),
    INDEX idx_driver_id (driver_id),
    INDEX idx_status (status),
    INDEX idx_requested_at (requested_at)
);

CREATE TABLE trip_locations (
    id BIGINT PRIMARY KEY,
    trip_id BIGINT NOT NULL,
    lat DECIMAL(10, 8) NOT NULL,
    lng DECIMAL(11, 8) NOT NULL,
    recorded_at TIMESTAMP NOT NULL,
    
    INDEX idx_trip_id (trip_id),
    FOREIGN KEY (trip_id) REFERENCES trips(trip_id)
);
```

**Why PostgreSQL over NoSQL?**
- **Transactions**: I need atomic state transitions (can't have a trip in two states)
- **Foreign key constraints**: Ensure referential integrity between trips, payments, and users
- **Complex queries**: Reporting and analytics require JOINs
- **Financial data**: ACID is non-negotiable for payment records

### State Transition Logic

Every state transition goes through a central service:

```python
class TripStateMachine:
    VALID_TRANSITIONS = {
        "requested": ["matched", "canceled_timeout"],
        "matched": ["driver_en_route", "canceled_by_driver", "canceled_by_rider"],
        "driver_en_route": ["arrived", "canceled_by_driver", "canceled_by_rider"],
        "arrived": ["rider_on_board", "canceled_by_driver", "canceled_by_rider"],
        "rider_on_board": ["in_progress"],
        "in_progress": ["completed"],
        "completed": ["paid"],
    }
    
    def transition(self, trip_id, new_status, **metadata):
        with db.transaction():
            # Lock the row for update
            trip = db.query("SELECT * FROM trips WHERE trip_id = %s FOR UPDATE", trip_id)
            
            # Validate transition
            if new_status not in self.VALID_TRANSITIONS.get(trip.status, []):
                raise InvalidTransitionError(
                    f"Cannot transition from {trip.status} to {new_status}"
                )
            
            # Update trip
            db.execute(
                "UPDATE trips SET status = %s, updated_at = NOW(), %s WHERE trip_id = %s",
                new_status,
                metadata,
                trip_id
            )
            
            # Publish event for downstream consumers
            event_bus.publish("trip.status_changed", {
                "trip_id": trip_id,
                "old_status": trip.status,
                "new_status": new_status,
                "timestamp": time.time(),
                **metadata
            })
            
            # Trigger side effects
            self._handle_side_effects(trip, new_status, metadata)
    
    def _handle_side_effects(self, trip, new_status, metadata):
        if new_status == "matched":
            # Send notifications
            notification_service.notify_rider(trip.rider_id, "Driver found!")
            notification_service.notify_driver(trip.driver_id, "New ride assigned")
            
        elif new_status == "rider_on_board":
            # Start tracking location for trip
            tracking_service.start_trip_tracking(trip.trip_id)
            
        elif new_status == "completed":
            # Calculate final fare
            fare_service.calculate_final_fare(trip.trip_id)
            
        elif new_status == "paid":
            # Release driver back to available pool
            driver_service.mark_available(trip.driver_id)
```

**The `FOR UPDATE` lock is crucial**: It prevents concurrent state transitions. If two processes try to update the same trip simultaneously, one will block until the other commits.

### Real-Time Location Tracking During Trip

Once the trip starts, I need to:
1. Collect location updates from the driver's phone
2. Store them for the trip record
3. Push them to the rider's app in real-time

**Location Collection:**

```python
def handle_trip_location_update(trip_id, driver_id, lat, lng):
    # 1. Verify driver is on this trip
    trip = db.get_trip(trip_id)
    if trip.driver_id != driver_id or trip.status not in ["in_progress", "driver_en_route"]:
        return
    
    # 2. Store location in time-series DB (async)
    kafka_producer.send("trip_locations", {
        "trip_id": trip_id,
        "lat": lat,
        "lng": lng,
        "timestamp": time.time()
    })
    
    # 3. Push to rider via WebSocket
    websocket_service.send_to_user(trip.rider_id, {
        "type": "driver_location_update",
        "trip_id": trip_id,
        "lat": lat,
        "lng": lng
    })
    
    # 4. Update ETA if significantly changed
    current_eta = estimate_eta_to_destination(lat, lng, trip.dropoff_location)
    if abs(current_eta - trip.estimated_eta) > 2:  # minutes
        trip.estimated_eta = current_eta
        websocket_service.send_to_user(trip.rider_id, {
            "type": "eta_update",
            "eta_minutes": current_eta
        })
```

**WebSocket Connection Management:**

I'm using a dedicated WebSocket service to maintain persistent connections:

```python
class WebSocketService:
    def __init__(self):
        self.connections = {}  # user_id -> WebSocket connection
    
    async def handle_connection(self, websocket, user_id):
        # Register connection
        self.connections[user_id] = websocket
        
        try:
            # Keep connection alive
            while True:
                # Receive heartbeat pings
                await websocket.receive()
        except WebSocketDisconnect:
            # Clean up on disconnect
            del self.connections[user_id]
    
    def send_to_user(self, user_id, message):
        if user_id in self.connections:
            asyncio.create_task(
                self.connections[user_id].send_json(message)
            )
```

**Scaling WebSockets:**

With 2.5M concurrent connections (2M drivers + 500K active riders), I need multiple WebSocket servers:

- **Sticky sessions**: Use consistent hashing to route a user to the same WebSocket server
- **Redis pub/sub**: Broadcast messages across WebSocket servers

```python
# When publishing a message
redis_pubsub.publish(f"user:{user_id}", json.dumps(message))

# Each WebSocket server subscribes
async def redis_subscriber():
    pubsub = redis.pubsub()
    pubsub.psubscribe("user:*")
    
    async for message in pubsub.listen():
        user_id = extract_user_id(message.channel)
        if user_id in local_connections:
            await local_connections[user_id].send_json(message.data)
```

### Fare Calculation

When the trip completes, I need to calculate the final fare:

```python
def calculate_final_fare(trip_id):
    trip = db.get_trip(trip_id)
    
    # Get pricing configuration for city
    pricing = get_pricing_config(trip.city)
    
    # Calculate distance and duration
    locations = db.query(
        "SELECT lat, lng, recorded_at FROM trip_locations WHERE trip_id = %s ORDER BY recorded_at",
        trip_id
    )
    
    total_distance_km = 0
    for i in range(len(locations) - 1):
        total_distance_km += haversine_distance(locations[i], locations[i+1])
    
    duration_minutes = (locations[-1].recorded_at - locations[0].recorded_at).total_seconds() / 60
    
    # Base fare calculation
    base_fare_cents = pricing.base_fare_cents
    distance_fare_cents = total_distance_km * pricing.per_km_cents
    time_fare_cents = duration_minutes * pricing.per_minute_cents
    
    subtotal_cents = base_fare_cents + distance_fare_cents + time_fare_cents
    
    # Apply surge multiplier
    surge_multiplier = trip.surge_multiplier  # Captured at request time
    total_cents = int(subtotal_cents * surge_multiplier)
    
    # Apply minimum fare
    total_cents = max(total_cents, pricing.minimum_fare_cents)
    
    # Store breakdown
    db.execute("""
        UPDATE trips 
        SET final_fare_cents = %s,
            distance_km = %s,
            duration_minutes = %s
        WHERE trip_id = %s
    """, total_cents, total_distance_km, duration_minutes, trip_id)
    
    return total_cents
```

**Important**: I capture the `surge_multiplier` at **request time**, not completion time. This ensures riders know the price upfront, with no surprises.

### Payment Processing

I'm integrating with Stripe for payment processing:

```python
def process_payment(trip_id):
    trip = db.get_trip(trip_id)
    rider = db.get_user(trip.rider_id)
    
    try:
        # Charge the rider's payment method
        charge = stripe.Charge.create(
            amount=trip.final_fare_cents,
            currency="usd",
            customer=rider.stripe_customer_id,
            description=f"Ride {trip_id}",
            idempotency_key=f"trip_{trip_id}"  # Prevent double-charging
        )
        
        # Record payment
        db.execute("""
            INSERT INTO payments (trip_id, stripe_charge_id, amount_cents, status)
            VALUES (%s, %s, %s, 'succeeded')
        """, trip_id, charge.id, trip.final_fare_cents)
        
        # Transition trip to paid
        trip_state_machine.transition(trip_id, "paid")
        
        # Calculate driver payout (e.g., 75% of fare)
        driver_payout_cents = int(trip.final_fare_cents * 0.75)
        db.execute("""
            INSERT INTO driver_payouts (driver_id, trip_id, amount_cents, status)
            VALUES (%s, %s, %s, 'pending')
        """, trip.driver_id, trip_id, driver_payout_cents)
        
        return PaymentResult.SUCCESS
        
    except stripe.error.CardError as e:
        # Payment failed (insufficient funds, etc.)
        db.execute("""
            INSERT INTO payments (trip_id, amount_cents, status, error_message)
            VALUES (%s, %s, 'failed', %s)
        """, trip_id, trip.final_fare_cents, str(e))
        
        # Notify rider
        notification_service.notify_rider(trip.rider_id, 
            "Payment failed. Please update your payment method.")
        
        return PaymentResult.FAILED
```

**Idempotency**: The `idempotency_key` ensures that if I retry the payment (e.g., due to a timeout), Stripe won't charge the rider twice.

**Payment Timing**: I process payment asynchronously after trip completion. This means:
- Riders can end the trip immediately (no waiting for payment)
- If payment fails, I can retry with exponential backoff
- Drivers still get marked as available (payment failure doesn't block them)

### Cancellation Handling

Cancellations are tricky because they can happen at any point:

```python
def cancel_trip(trip_id, canceled_by, reason):
    trip = db.get_trip(trip_id)
    
    # Determine cancellation fee
    cancellation_fee_cents = 0
    
    if canceled_by == "rider":
        if trip.status == "driver_en_route":
            # Driver already driving to pickup
            time_since_match = (datetime.now() - trip.matched_at).total_seconds()
            if time_since_match > 120:  # 2 minutes
                cancellation_fee_cents = 500  # $5 fee
        
        elif trip.status in ["arrived", "rider_on_board"]:
            # Driver waiting or trip started
            cancellation_fee_cents = 1000  # $10 fee
    
    # Transition to canceled state
    trip_state_machine.transition(
        trip_id,
        f"canceled_by_{canceled_by}",
        cancellation_reason=reason,
        cancellation_fee_cents=cancellation_fee_cents
    )
    
    # Charge cancellation fee if applicable
    if cancellation_fee_cents > 0:
        process_cancellation_fee(trip_id, cancellation_fee_cents)
    
    # Compensate driver
    if trip.status in ["driver_en_route", "arrived"] and canceled_by == "rider":
        # Pay driver for wasted time
        driver_compensation_cents = min(cancellation_fee_cents, 500)
        db.execute("""
            INSERT INTO driver_payouts (driver_id, trip_id, amount_cents, payout_type)
            VALUES (%s, %s, %s, 'cancellation_compensation')
        """, trip.driver_id, trip_id, driver_compensation_cents)
    
    # Release driver back to available pool
    driver_service.mark_available(trip.driver_id)
```

### Consistency Guarantees

Here's where I need strong consistency vs. where eventual consistency is acceptable:

**Strong Consistency (PostgreSQL transactions):**
- Trip state transitions
- Driver assignment (via `FOR UPDATE` lock)
- Payment records
- Cancellation fees

**Eventual Consistency (acceptable lag):**
- Driver location updates in Redis (stale by a few seconds is fine)
- Analytics and reporting
- Driver rating aggregates
- Trip history in rider app

This separation lets me scale the high-throughput parts (location updates) while maintaining correctness for the critical parts (payments, state).

## Deep-Dive: Surge Pricing and Demand Forecasting

Surge pricing is how ride-hailing platforms balance supply and demand in real-time. When demand exceeds supply, prices go up to incentivize more drivers to come online and to reduce frivolous requests. Here's how I would implement it.

![Surge pricing and forecasting: Kafka's trip and location streams feed a supply/demand monitor that measures each H3 zone; a surge calculator turns the ratio into a smoothed, capped multiplier written to a Redis surge cache keyed by zone, which the rider app reads for price and which nudges drivers toward high-demand zones; an XGBoost demand-forecast model trained on the historical warehouse pre-positions supply ahead of predicted spikes](https://d5osvdbc8um23.cloudfront.net/static-asset/blog_images/system-design-ride-hailing/06-surge-forecasting.png)

### The Supply-Demand Problem

At 5 PM on a Friday in downtown San Francisco:
- **Demand**: 1,000 ride requests in 10 minutes
- **Supply**: 200 available drivers in the area
- **Ratio**: 5:1 demand-to-supply

Without surge pricing:
- Riders wait 20+ minutes for a match
- Some riders never get matched
- Drivers in nearby areas don't know there's high demand

With surge pricing:
- Fare increases by 1.5x-2.5x
- Higher fares attract drivers from nearby areas
- Some riders defer their trip or choose alternatives
- Supply-demand ratio improves to 2:1
- Wait times drop to 5 minutes

### Geographic Zones: Uber H3

I need to divide the city into zones for surge pricing. I'm using **Uber's H3 hexagonal grid** because:

1. **Uniform area**: Every hexagon has the same area, so surge calculations are fair
2. **No edge artifacts**: Unlike squares, hexagons have consistent neighbor distances
3. **Hierarchical**: I can aggregate to coarser resolutions for sparse areas

```python
import h3

def get_surge_zone(lat, lng, resolution=8):
    # Resolution 8 = ~0.46 km² per hexagon (good for urban areas)
    return h3.geo_to_h3(lat, lng, resolution)

# Example: San Francisco downtown
zone = get_surge_zone(37.7749, -122.4194, resolution=8)
# Returns: '8828308281fffff'
```

**Resolution levels:**
- Resolution 7: ~5 km² (suburban areas, low density)
- Resolution 8: ~0.46 km² (urban areas, standard)
- Resolution 9: ~0.1 km² (very dense areas like airports)

### Real-Time Supply-Demand Monitoring

I run a background service that continuously monitors each zone:

```python
class SurgePricingMonitor:
    def __init__(self):
        self.update_interval_seconds = 30
    
    async def monitor_loop(self):
        while True:
            await asyncio.sleep(self.update_interval_seconds)
            await self.update_all_zones()
    
    async def update_all_zones(self):
        # Get all active zones (zones with recent activity)
        active_zones = self.get_active_zones()
        
        for zone_id in active_zones:
            metrics = self.calculate_zone_metrics(zone_id)
            surge_multiplier = self.calculate_surge_multiplier(metrics)
            
            # Store in Redis for fast lookup
            redis.hset(f"surge:{zone_id}", mapping={
                "multiplier": surge_multiplier,
                "demand": metrics.demand,
                "supply": metrics.supply,
                "updated_at": time.time()
            })
            
            # If surge changed significantly, notify nearby drivers
            if abs(surge_multiplier - metrics.previous_multiplier) > 0.2:
                self.notify_drivers_in_area(zone_id, surge_multiplier)
    
    def calculate_zone_metrics(self, zone_id):
        # Count pending ride requests in this zone
        pending_requests = db.query("""
            SELECT COUNT(*) FROM trips
            WHERE h3_zone = %s
              AND status IN ('requested', 'matched')
              AND requested_at > NOW() - INTERVAL '5 minutes'
        """, zone_id).count
        
        # Count available drivers in this zone
        zone_center = h3.h3_to_geo(zone_id)
        available_drivers = redis.georadius(
            f"drivers:available:{city}",
            zone_center[1],  # lng
            zone_center[0],  # lat
            radius_km=1.5,  # Approximate hexagon radius
            count=True
        )
        
        # Count in-progress trips (reduces available supply)
        active_trips = db.query("""
            SELECT COUNT(*) FROM trips
            WHERE h3_zone = %s
              AND status IN ('in_progress', 'driver_en_route', 'arrived')
        """, zone_id).count
        
        # Historical baseline
        baseline_supply = self.get_baseline_supply(zone_id, hour=datetime.now().hour)
        baseline_demand = self.get_baseline_demand(zone_id, hour=datetime.now().hour)
        
        return ZoneMetrics(
            demand=pending_requests,
            supply=available_drivers,
            active_trips=active_trips,
            baseline_supply=baseline_supply,
            baseline_demand=baseline_demand
        )
```

### Surge Multiplier Calculation

Here's my algorithm for calculating the surge multiplier:

```python
def calculate_surge_multiplier(self, metrics):
    # Base ratio: pending requests / available drivers
    if metrics.supply == 0:
        ratio = 10.0  # No drivers available
    else:
        ratio = metrics.demand / metrics.supply
    
    # Adjust for active trips (they reduce effective supply)
    effective_supply = max(1, metrics.supply - metrics.active_trips * 0.5)
    adjusted_ratio = metrics.demand / effective_supply
    
    # Compare to baseline
    baseline_ratio = metrics.baseline_demand / max(1, metrics.baseline_supply)
    anomaly_factor = adjusted_ratio / max(0.1, baseline_ratio)
    
    # Calculate multiplier
    if adjusted_ratio < 1.0:
        # Supply exceeds demand, no surge
        multiplier = 1.0
    elif adjusted_ratio < 2.0:
        # Moderate demand, small surge
        multiplier = 1.0 + (adjusted_ratio - 1.0) * 0.5  # 1.0 to 1.5x
    elif adjusted_ratio < 4.0:
        # High demand, significant surge
        multiplier = 1.5 + (adjusted_ratio - 2.0) * 0.5  # 1.5 to 2.5x
    else:
        # Extreme demand, maximum surge
        multiplier = min(2.5 + (adjusted_ratio - 4.0) * 0.25, 5.0)  # 2.5 to 5.0x (capped)
    
    # Apply anomaly factor (e.g., sudden event)
    if anomaly_factor > 2.0:
        multiplier *= 1.2  # 20% boost for unexpected demand spike
    
    # Smooth changes (don't jump too quickly)
    previous_multiplier = self.get_previous_multiplier(metrics.zone_id)
    max_change = 0.3  # Maximum 0.3x change per 30-second update
    multiplier = np.clip(
        multiplier,
        previous_multiplier - max_change,
        previous_multiplier + max_change
    )
    
    # Round to nearest 0.1
    multiplier = round(multiplier, 1)
    
    return multiplier
```

**Key design decisions:**

1. **Capped at 5.0x**: Prevents price gouging, maintains rider trust
2. **Smooth transitions**: Prevents jarring price jumps
3. **Baseline comparison**: Accounts for normal variations (rush hour is expected)
4. **Anomaly detection**: Reacts quickly to unexpected events (concerts, emergencies)

### Trigger Conditions and Event Detection

I'm monitoring for events that typically cause demand spikes:

```python
class EventDetector:
    def detect_events(self):
        # 1. Weather events
        weather = weather_api.get_current_conditions(city)
        if weather.precipitation > 0.1:  # inches/hour
            self.trigger_weather_surge(severity=weather.precipitation)
        
        # 2. Time-based patterns
        hour = datetime.now().hour
        day_of_week = datetime.now().weekday()
        
        if hour in [8, 9, 17, 18] and day_of_week < 5:  # Rush hour, weekday
            self.apply_rush_hour_multiplier(1.2)
        
        if hour >= 22 or hour <= 2:  # Late night
            self.apply_late_night_multiplier(1.3)
        
        # 3. Special events (calendar integration)
        events = event_calendar.get_events_now(city)
        for event in events:
            if event.attendance > 10000:
                zones = self.get_zones_near_venue(event.venue)
                for zone in zones:
                    self.apply_event_surge(zone, multiplier=1.5)
        
        # 4. Traffic congestion
        traffic = traffic_api.get_congestion_level(city)
        if traffic.congestion_index > 0.7:  # 0 to 1 scale
            self.apply_traffic_multiplier(1.1)
        
        # 5. Airport zones (always higher demand)
        airport_zones = self.get_airport_zones(city)
        for zone in airport_zones:
            self.apply_minimum_multiplier(zone, 1.3)
```

### Demand Forecasting with Machine Learning

To proactively position drivers, I'm building a demand forecasting model:

```python
class DemandForecaster:
    def __init__(self):
        self.model = self.load_trained_model()
    
    def forecast_demand(self, zone_id, forecast_horizon_minutes=30):
        # Features for ML model
        features = self.extract_features(zone_id)
        
        # Predict demand for next N minutes
        forecast = self.model.predict(features)
        
        return forecast
    
    def extract_features(self, zone_id):
        # Historical demand patterns
        historical = self.get_historical_demand(zone_id, lookback_hours=24)
        
        # Time features
        now = datetime.now()
        time_features = {
            "hour": now.hour,
            "day_of_week": now.weekday(),
            "is_weekend": now.weekday() >= 5,
            "is_holiday": self.is_holiday(now.date()),
        }
        
        # Weather features
        weather = weather_api.get_forecast(zone_id)
        weather_features = {
            "temperature": weather.temperature,
            "precipitation": weather.precipitation_probability,
            "conditions": weather.conditions  # sunny, rainy, etc.
        }
        
        # Event features
        events = event_calendar.get_upcoming_events(zone_id, hours=2)
        event_features = {
            "has_major_event": any(e.attendance > 10000 for e in events),
            "event_end_time": min([e.end_time for e in events], default=None)
        }
        
        # Traffic features
        traffic = traffic_api.get_forecast(zone_id)
        traffic_features = {
            "congestion_index": traffic.congestion_index,
            "average_speed": traffic.average_speed_mph
        }
        
        # Combine all features
        return {
            **historical,
            **time_features,
            **weather_features,
            **event_features,
            **traffic_features
        }
```

**Model Training:**

I train a gradient boosting model (XGBoost) on historical data:

```python
# Training data: past 6 months of ride requests
# Target: number of ride requests in next 30 minutes per zone
# Features: time, weather, events, historical patterns

import xgboost as xgb

def train_demand_model():
    # Load training data
    df = load_historical_data(months=6)
    
    # Feature engineering
    X = extract_features_batch(df)
    y = df['demand_next_30min']
    
    # Train-test split (time-based, not random)
    split_date = df['timestamp'].max() - timedelta(days=14)
    X_train = X[df['timestamp'] < split_date]
    y_train = y[df['timestamp'] < split_date]
    X_test = X[df['timestamp'] >= split_date]
    y_test = y[df['timestamp'] >= split_date]
    
    # Train model
    model = xgb.XGBRegressor(
        objective='reg:squarederror',
        n_estimators=100,
        max_depth=6,
        learning_rate=0.1
    )
    model.fit(X_train, y_train)

    # Evaluate on the held-out (most recent) window
    mae = mean_absolute_error(y_test, model.predict(X_test))
    print(f"Validation MAE: {mae:.1f} requests / 30 min")

    return model
```

**Serving the forecast:** I run the model every few minutes per zone and use its output for two things. First, **driver pre-positioning**: I nudge idle drivers toward zones that are about to spike (with incentives, never hard commands), so supply arrives *before* demand instead of chasing it. Second, **smoothing surge**: if the forecast says a spike is transient, I damp the multiplier so riders don't get whipsawed by a 30-second blip. Forecasting doesn't replace the reactive supply/demand loop above; it front-runs it.

### How Other Platforms Differ

**Lyft** uses a comparable zone-based surge ("Prime Time") but has historically leaned on coarser zones and simpler multipliers than Uber's H3-based model. **Food-delivery platforms** (DoorDash, Uber Eats) surge on a different axis (courier scarcity and restaurant prep time rather than point-to-point ETA) and can batch multiple orders per trip, which changes the supply math entirely. **Traditional taxi metering** has no surge at all: fixed regulated rates, which is exactly the rigidity dynamic pricing exists to fix.

## Conclusion: Bringing It All Together

Designing a ride-hailing platform comes down to one theme I kept returning to: **everything is a real-time, geographically-partitioned problem.** The numbers force it. 2M drivers pinging every few seconds is 500K writes/second, and a 30-second match SLA means proximity queries have to come back in single-digit milliseconds. That rules out the naive relational approach and pushes me toward Redis Geo, geohash/H3 indexing, and partitioning by city so no query ever needs global coordination.

The pieces fit together like this:

1. **Location & geospatial index:** high-volume GPS updates land in Redis Geo keyed by city and vehicle type, with a short TTL so stale drivers fall out automatically and sub-10ms `GEORADIUS` proximity search feeds matching.
2. **Matching & dispatch:** proximity search → eligibility filter → multi-factor scoring (ETA, acceptance, rating, detour) → dispatch behind an atomic `SET NX EX` Redis lock so a driver is only ever offered one ride at a time, with radius expansion and retries when offers lapse.
3. **Trip lifecycle & payments:** a strict state machine in PostgreSQL with `FOR UPDATE` locks for correctness, Kafka + WebSockets for real-time updates to both apps, surge captured at request time, and idempotent Stripe charges so a retry never double-bills.
4. **Surge & forecasting:** H3 zones measure supply/demand every 30 seconds into a smoothed, capped multiplier, while an ML forecast pre-positions drivers ahead of predicted spikes.

The principles underneath are consistent across all four: **partition by geography** so work stays local, **split strong from eventual consistency** (ACID for money and trip state, best-effort for location), **make critical operations idempotent and atomic** (dispatch locks, payment keys), and **push work off the request path** (async streaming of locations and analytics). Get those right and the system scales the way it needs to, city by city with no global bottleneck, which is exactly how the real platforms grew.