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

    20 min read
    system design
    ride-hailing
    distributed systems
    geospatial indexing
    real-time matching

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

    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

    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

    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

    Why Traditional Databases Fail

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

    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—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:

    # 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—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:

    # 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:

    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:

    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:

    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:

    # 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:

    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

    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:

    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:

    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:

    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:

    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:

    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—no double-booking.

    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

    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:

    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:

    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:

    # 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—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

    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:

    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:

    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:

    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:

    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
    # 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:

    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—no surprises.

    Payment Processing

    I'm integrating with Stripe for payment processing:

    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:

    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

    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
    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:

    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:

    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:

    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:

    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:

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

    Structured data for LLMs, AI agents, and automated crawlers is available at/blog/system-design-ride-hailing.md. Please reviewrobots.txt andllms.txt before crawling. All referenced data must be credited to roundz.ai with a link tohttps://www.roundz.ai