Designing a Ride-Hailing Platform: How I Would Build the Next Uber
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:
- Driver Location Tracking: Continuously ingest and store GPS coordinates from active drivers
- Proximity Search: Find available drivers near a rider's pickup location within seconds
- Ride Matching: Assign the optimal driver to a ride request based on distance, ETA, and other factors
- Real-time Updates: Push location updates to riders and drivers during active trips
- Trip State Management: Track the complete lifecycle from request through completion
- Fare Calculation: Compute ride costs based on distance, time, and surge multipliers
- Payment Processing: Handle secure payment transactions
- 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:

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.

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.

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:
- Full table scan: The WHERE clause calculates distance for every single row. With 2 million drivers, that's 2 million distance calculations per query.
- 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.
- 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:
- Convert latitude and longitude to binary using iterative bisection
- Interleave the bits: lat[0], lng[0], lat[1], lng[1], ...
- Encode the resulting bit string in base32
Example for San Francisco (37.7749, -122.4194):
- Geohash:
9q8yyk8ytpxr - Precision levels:
9q(~1700km) - California region9q8y(~80km) - San Francisco Bay Area9q8yyk(~5km) - Downtown SF9q8yyk8ytpxr(~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.

The Matching Pipeline
My matching service follows this flow:
- Proximity Search: Find candidate drivers within radius
- Filter: Apply eligibility criteria
- Score: Rank candidates by multiple factors
- Dispatch: Send offer to top candidate(s)
- Wait: Give driver 15 seconds to respond
- 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.

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:
- Collect location updates from the driver's phone
- Store them for the trip record
- 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 UPDATElock) - 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.

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:
- Uniform area: Every hexagon has the same area, so surge calculations are fair
- No edge artifacts: Unlike squares, hexagons have consistent neighbor distances
- 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:
- Capped at 5.0x: Prevents price gouging, maintains rider trust
- Smooth transitions: Prevents jarring price jumps
- Baseline comparison: Accounts for normal variations (rush hour is expected)
- 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:
- 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
GEORADIUSproximity search feeds matching. - Matching & dispatch: proximity search → eligibility filter → multi-factor scoring (ETA, acceptance, rating, detour) → dispatch behind an atomic
SET NX EXRedis lock so a driver is only ever offered one ride at a time, with radius expansion and retries when offers lapse. - Trip lifecycle & payments: a strict state machine in PostgreSQL with
FOR UPDATElocks 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. - 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.
