Low-Level Design: Ride-Sharing App (Uber/Lyft) — Driver Matching, Surge Pricing, and Trip State Machine

Core Entities

Rider: rider_id, name, phone, email, payment_method_id, rating, total_trips. Driver: driver_id, name, phone, license_number, vehicle_id, status (OFFLINE, AVAILABLE, ON_TRIP), current_lat, current_lng, rating, acceptance_rate. Vehicle: vehicle_id, driver_id, make, model, year, license_plate, type (ECONOMY, COMFORT, XL, BLACK). Trip: trip_id, rider_id, driver_id, vehicle_id, status, pickup_lat, pickup_lng, dropoff_lat, dropoff_lng, requested_at, accepted_at, picked_up_at, completed_at, base_fare, surge_multiplier, total_fare, distance_meters, duration_seconds. TripEvent: event_id, trip_id, event_type, timestamp, lat, lng (GPS breadcrumbs).

Driver Matching Algorithm

class MatchingService:
    def find_driver(self, rider_lat: float, rider_lng: float,
                    vehicle_type: str) -> Optional[Driver]:
        # Find available drivers within 5km using Redis geo index
        nearby = self.geo.find_nearby_drivers(
            lat=rider_lat, lng=rider_lng,
            radius_km=5.0,
            vehicle_type=vehicle_type
        )
        if not nearby:
            return None

        def score(d: Driver) -> float:
            distance = haversine(rider_lat, rider_lng, d.lat, d.lng)
            eta_minutes = distance / AVG_SPEED_KM_H * 60
            # Lower score = better: prefer closer, higher-rated drivers
            return eta_minutes * 0.7 + (5 - d.rating) * 0.3

        candidates = sorted(nearby[:20], key=score)

        for driver in candidates:
            # Offer the trip; driver has 15 seconds to accept
            accepted = self.offer_trip(driver, timeout=15)
            if accepted:
                return driver

        return None  # no driver accepted

Driver location is stored in a Redis geo index: GEOADD driver_locations lng lat driver_id. Updated every 4 seconds from the driver app. GEORADIUS queries find all drivers within the radius in O(log n + k) time. The scoring function balances proximity (ETA) and driver quality (rating). Offer-then-retry: always try the best candidate first, then fall back — this gives good drivers more trip opportunities (improving retention) while ensuring the rider gets matched quickly.

Trip State Machine

Valid states and transitions: REQUESTED → ACCEPTED (driver accepts within 15s) or CANCELLED (timeout or rider cancels). ACCEPTED → EN_ROUTE_TO_PICKUP (driver heads to pickup). EN_ROUTE_TO_PICKUP → ARRIVED_AT_PICKUP (driver arrives at pickup location). ARRIVED_AT_PICKUP → IN_PROGRESS (rider boards). IN_PROGRESS → COMPLETED (driver ends trip at destination) or CANCELLED (rare, with special handling). Each transition stores a timestamp. Invalid transitions are rejected. The state machine is enforced in the TripService: before any state transition, verify the current state is valid for the attempted transition.

Surge Pricing

Surge pricing balances supply and demand in real time. Surge multiplier = f(demand / supply) in a geographic zone. Zones: divide the city into H3 hexagonal cells (Uber uses H3 library). For each cell: count active trip requests (demand) and available drivers (supply) in the last 5 minutes. Multiplier = max(1.0, demand / (supply * TARGET_UTILIZATION)). Cap at 5.0x to prevent extreme pricing. Implementation: a pricing job runs every 60 seconds, recomputes multipliers per cell, stores results in Redis with a 2-minute TTL. On trip request: look up the rider’s cell and destination cell, take the max multiplier. Display the surge multiplier prominently in the app before the rider confirms (regulatory requirement in many markets). Store surge_multiplier on the Trip at the time of request — the rider is not charged more if surge increases during the trip.

Fare Calculation

Base fare = base_amount + (per_minute_rate * duration) + (per_km_rate * distance). Surge fare = base_fare * surge_multiplier. Service fee = fixed percentage (e.g., 25% platform commission). Rider pays: surge_fare + service_fee + tip (optional). Driver earns: surge_fare * (1 – commission_rate). Minimum fare: max(calculated_fare, minimum_fare_for_vehicle_type). Cancellation fee: if the driver has waited > 5 minutes at pickup and the rider cancels, charge the rider a cancellation fee. Special pricing: airport flat rate, tolls (pass-through to rider), peak-hour rate per city rules.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How does Uber’s driver matching work at scale?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Driver matching must complete in under 2 seconds (rider expectation). Steps: (1) Spatial query: find available drivers within a radius using a geospatial index. Uber uses a custom supply partitioning called “Supply Grid” — city is divided into hexagonal cells (H3). Each cell maintains a list of available driver IDs. Query = find all cells within radius, union their driver lists. Redis GEORADIUS or a purpose-built geo index serves this. (2) Estimate ETA for top candidates: use the routing API to compute accurate ETAs for the ~20 closest drivers. Not all candidates — only the top N by approximate distance. (3) Score and rank: balance ETA (shorter is better), driver acceptance rate (avoid offering to drivers likely to decline), and business rules (surge zones, vehicle type match). (4) Offer sequentially until accepted. Real Uber insight: driver-destination feature — drivers heading to a specific area are offered trips in that direction, improving efficiency.”
}
},
{
“@type”: “Question”,
“name”: “How does surge pricing get computed and applied?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Surge pricing is computed per geographic zone every 60 seconds. Zone = H3 hexagonal cell at a specific resolution (roughly 1 km^2). For each zone: supply = count of available drivers in the zone, demand = count of open trip requests in the last 5 minutes. Surge multiplier = demand / (supply * target_utilization), clamped to [1.0, MAX_SURGE]. This is stored in Redis with a 2-minute TTL. On trip request: look up the zones for the pickup and dropoff locations, take the max multiplier across both zones. Apply to the fare at request time. Regulatory compliance: show the surge multiplier clearly before the rider confirms. Record the surge at request time on the trip — the rider pays the surge at the time they requested, not when they board (protects riders if surge drops while waiting). Price guarantee: “lock in your price” UX pattern.”
}
},
{
“@type”: “Question”,
“name”: “How does the state machine prevent invalid trip transitions?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “The TripService enforces valid transitions using a state machine map: VALID_TRANSITIONS = {REQUESTED: [ACCEPTED, CANCELLED], ACCEPTED: [EN_ROUTE_TO_PICKUP, CANCELLED], EN_ROUTE_TO_PICKUP: [ARRIVED_AT_PICKUP, CANCELLED], ARRIVED_AT_PICKUP: [IN_PROGRESS], IN_PROGRESS: [COMPLETED, CANCELLED]}. Before any state update: assert current_state in VALID_TRANSITIONS and new_state in VALID_TRANSITIONS[current_state]. Store the transitions with timestamps in TripEvents for the customer receipt and for dispute resolution. Atomic transition: UPDATE trips SET status = new_state, transition_at = NOW() WHERE trip_id = :id AND status = :current_state. If rows_affected = 0: a concurrent update changed the status — retry or return a conflict error. This optimistic check prevents concurrent state updates from putting the trip in an inconsistent state.”
}
},
{
“@type”: “Question”,
“name”: “How do you calculate the fare and split it between the driver and platform?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Fare components: base_fare (flat pickup fee, e.g., $1.00) + time_fare (per_minute_rate * trip_duration_minutes) + distance_fare (per_km_rate * trip_distance_km). Surge multiplier applied to the total: total_fare = (base_fare + time_fare + distance_fare) * surge_multiplier. Minimum fare enforced: max(total_fare, minimum_fare). Service fee (platform commission): platform_fee = total_fare * commission_rate (e.g., 25%). Rider pays: total_fare + tip. Driver earns: total_fare – platform_fee + tip = total_fare * (1 – commission_rate) + tip. All amounts stored as integers (cents) to avoid floating-point errors. Tax: in many jurisdictions, sales tax or GST applies to the fare — itemized separately on the receipt. Distance and duration are computed from GPS breadcrumbs (TripEvents) rather than the routing API estimate, to account for real-world deviations (traffic, route changes).”
}
},
{
“@type”: “Question”,
“name”: “How do you handle driver GPS location updates at scale?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Drivers send GPS pings every 4 seconds from the mobile app. At 1 million active drivers: 250,000 location updates/second. Architecture: driver app u2192 location service (REST or gRPC) u2192 Redis GEOADD + Kafka publish. Redis GEOADD updates the driver’s position in the geo index (O(log n) per update). Kafka receives the raw event for downstream consumers: trip tracking (update rider’s map view), analytics (heat maps, demand forecasting), and compliance logging (GPS trail for insurance/dispute). Redis geo index stores only available drivers (status=AVAILABLE). Drivers on trips are removed from the index (no point in routing trips to them). On trip completion: re-add the driver to the Redis geo index with their current position. Memory: 1M drivers * ~50 bytes per geo entry = 50MB — easily fits in Redis.”
}
}
]
}

Asked at: Uber Interview Guide

Asked at: Lyft Interview Guide

Asked at: DoorDash Interview Guide

Asked at: Snap Interview Guide

Scroll to Top