Design a Ride-Hailing System
Two Seconds to Find a Driver
A rider opens the app, taps "Request Ride," and expects a driver to appear on their screen almost instantly. Behind that expectation is a system that must ingest 500,000 GPS updates per second from drivers around the world, find the best available driver within a 2-second window, send them an offer over an unreliable mobile network, handle the case where that driver does not respond, try the next candidate, and β once a driver accepts β maintain a synchronized real-time view of the trip for both rider and driver, all while ensuring that no two riders are assigned the same driver and no trip enters an impossible state.
Ride-hailing is fundamentally different from the systems we have designed so far. A URL shortener serves a static mapping. A chat system delivers messages to known recipients. A notification system pushes content through channels. Ride-hailing makes real-time decisions under uncertainty: the driver's location is already stale by the time you query it, the best candidate might reject the offer, and the rider might cancel while you are still searching. Every component must be designed for speed, correctness under concurrency, and graceful degradation when data is imperfect.
This post walks through designing an Uber-style ride-hailing system from first principles. We will build progressively: a correct trip state machine first, then fast geospatial matching, then real-time delivery, and finally reliability hardening. Every decision will be justified by capacity math and grounded in how geospatial systems and mobile networks actually behave.
Let us begin.
1. Requirements β What the System Must Do in Two Seconds
Before drawing any boxes, we need to agree on the four actions the system must support and the performance constraints that shape every choice.
Functional Requirements
- Rider requests a ride. A rider specifies pickup and drop-off locations and submits a request. The system provides an estimated fare and begins searching for a driver.
- Nearby drivers are matched. The system identifies available drivers near the pickup point, ranks them, and assigns the best one. This must happen within 2 seconds in dense cities.
- Real-time trip updates. Both rider and driver see each other's location on a live map, with trip state changes (driver arriving, ride started, approaching destination) reflected immediately.
- Trip lifecycle tracking. Every trip follows a strict state machine:
REQUESTED β MATCHED β DRIVER_EN_ROUTE β ARRIVED β ON_TRIP β COMPLETED(orCANCELLEDfrom certain states). State transitions must be atomic β no duplicate assignments, no impossible state jumps.
Non-Functional Requirements
- Match decision p95 under 2 seconds in active cities. This is the total budget from ride request to driver assignment β including geospatial search, candidate ranking, offer delivery, and driver response.
- High availability for dispatch and trip state. A rider who tapped "Request" must get a match attempt; a trip in progress must not lose its state.
- Location ingestion at 500,000 writes per second. Every online driver sends a GPS update every 3 seconds. This is the highest-throughput path in the system and must not bottleneck matching.
- Idempotent request and accept flows. Mobile networks are lossy β riders double-tap "Request," drivers double-tap "Accept." The system must produce exactly one trip per intent.
- Graceful handling of stale data and disconnects. Drivers lose signal, go through tunnels, or background the app. The system must detect and quarantine stale data rather than dispatching to phantom drivers.
Scope Control
In scope: matching, location ingestion, trip state machine, dispatch offers, basic ETA, fare estimation.
Out of scope: detailed fraud scoring, ML surge pricing model internals, payment ledger, driver onboarding.
Now that we know what we are building, we need to understand the scale. The numbers will determine whether we can search all drivers or must use spatial indexing, whether we can store locations in a database or need an in-memory store, and how many WebSocket connections we must support.
Login to continue reading
You reached the preview limit. Sign in to unlock the remaining sections.
2. Capacity Estimation β The Numbers Behind Every Decision
Ride-hailing has a unique load profile: extremely high-frequency location writes (the "firehose"), moderate trip creation, and latency-critical matching that must query the firehose data in real time.
Throughput
- Daily active users (DAU): 30 million (riders + drivers combined)
- Daily trips: 25 million
- Peak trip requests: ~30,000/s (during city-level rush hours and event surges)
- Active online drivers at peak: 1.5 million globally
- Driver location update frequency: every 3 seconds
- Driver location writes: 1.5M Γ· 3 = 500,000 updates/s
Concurrent Connections
Both drivers and riders maintain persistent WebSocket connections for real-time updates:
- Drivers: 1.5 million concurrent connections at peak (all online drivers)
- Riders during peak: approximately 2-3 million (riders in active trips + riders with the app open browsing/requesting). Let us estimate 2.5 million concurrent rider connections at peak.
- Total WebSocket connections: ~4 million at peak
If each gateway server handles ~50,000 concurrent connections, we need at least 80 gateway instances at peak. We round to ~100 instances for deployment headroom.
Storage
Driver session record stores current status, cell ID, coordinates, last heartbeat, vehicle type, and connection metadata. Breakdown: driverId (16 bytes), status enum (2 bytes), currentCell H3 index (8 bytes), lat/lon (16 bytes), lastLocTs (8 bytes), vehicleType (4 bytes), socketId (32 bytes), serverId (16 bytes), lastHeartbeatTs (8 bytes), plus overhead (~90 bytes). Total: ~200 bytes per driver session.
- Active driver state: 1.5M Γ 200 bytes = ~300 MB β easily fits in memory.
Trip record stores the full lifecycle: IDs, state, timestamps for each transition, pickup/dropoff coordinates, fare estimate, driver/rider references, and metadata. Breakdown: tripId + requestId + riderId + driverId (64 bytes), state + stateVersion (8 bytes), coordinates for pickup/dropoff (32 bytes), fare fields (24 bytes), timestamps for each state transition (48 bytes), cancel reason + metadata (~100 bytes), plus index overhead (~724 bytes for Postgres row). Total: ~1 KB per trip.
- Daily trip storage: 25M Γ 1 KB = ~25 GB/day
- Annual: ~9 TB before replication
What These Numbers Force
500,000 location writes per second β this is the defining constraint. No traditional database can absorb this write rate for mutable location data with sub-millisecond query access. We need an in-memory geospatial store that can ingest updates and serve radius queries simultaneously.
30,000 peak trip requests per second β each requiring a geospatial candidate search, ranking, and offer delivery within 2 seconds. Brute-force nearest-neighbor search across 1.5 million drivers is not feasible at this query rate. We need spatial indexing to reduce the search space.
4 million concurrent WebSocket connections β manageable with a ~100-instance gateway fleet, but requires a session registry so the dispatch service can find which gateway hosts each driver.
300 MB of hot driver state β small enough for in-memory storage. This data is ephemeral (drivers go online/offline constantly), high-churn (updates every 3 seconds), and must be separate from the durable trip lifecycle store.
With these numbers, the architecture is taking shape. Let us define the data that flows through it.
3. Core Entities (v1) β Modeling Dispatch and Trip Lifecycle
A ride-hailing system has two fundamentally different data categories: ephemeral real-time state (driver locations, session status β high churn, in-memory) and durable lifecycle state (trips, requests β transactional, persistent). Mixing these in one store would be a mistake: the location firehose would overwhelm a transactional database, and trip state machines need ACID guarantees that in-memory stores do not provide.
Driver and DriverLocation
Driver(driverId, vehicleType, status, rating, currentCell, lastLocTs)
DriverLocation(driverId, lat, lon, speed, heading, ts, cellId)Driver is the relatively stable profile β vehicle type, rating, current status (OFFLINE, ONLINE, ON_TRIP). DriverLocation is the firehose: 500,000 updates per second, each carrying fresh GPS coordinates, speed, heading, and a timestamp. The cellId is an H3 hexagonal cell index computed from the coordinates β this is the key that makes fast geospatial search possible (more on this in Step B).
Why separate Driver from DriverLocation? Because they change at wildly different rates. A driver's vehicle type changes once a year. Their location changes every 3 seconds. Storing both in the same row would mean 500,000 full-row rewrites per second for data that is 90% unchanged.
Rider
Rider(riderId, rating, defaultPaymentRef?)Relatively simple β the rider's profile. Payment details are referenced but managed by a separate payment service (out of scope).
RideRequest and Trip
RideRequest(requestId, riderId, pickupLat, pickupLon, dropoffLat, dropoffLon,
fareEstimate, requestedAt, status, idempotencyKey)
Trip(tripId, requestId, riderId, driverId, state, stateVersion,
createdAt, matchedAt, arrivedAt, startedAt, completedAt, cancelReason?)RideRequest captures the rider's intent. Trip captures the assignment and lifecycle. They are separate because a request might never become a trip (no driver found, rider cancels before match). The idempotencyKey on RideRequest prevents double-tap duplicates: if the rider's app sends the request twice due to network lag, the system detects the duplicate key and returns the existing request.
stateVersion on Trip is a monotonically increasing counter used for optimistic concurrency control. When a driver accepts, the system atomically increments stateVersion and sets state = MATCHED. If a concurrent cancel attempt arrives with the old version, it fails β the accept won, the cancel is rejected with 409 Conflict. This prevents the most dangerous race condition in the system: a driver accepting a ride that the rider just cancelled.
DispatchAttempt
DispatchAttempt(requestId, candidateDriverId, attemptNo, rankScore,
offerTs, expiryTs, outcome, responseTs)Every time the dispatch service offers a ride to a candidate driver, it records the attempt. This serves two purposes: operational debugging (why was this driver offered? why did they reject?) and matching quality tuning (which ranking factors predict acceptance?).
ClientSession
ClientSession(userId, deviceId, serverId, role, lastHeartbeatTs, connState)Tracks where each user (driver or rider) is connected. The dispatch service uses this to route dispatchOffer events to the correct WebSocket gateway.
Connecting Entities to Requirements
- FR1 (rider requests ride) β
RideRequestcaptures the intent with idempotency;Triptracks the lifecycle. - FR2 (nearby driver match) β
DriverLocation+Driver.currentCellfeed the geospatial index;DispatchAttemptrecords the offer cascade. - FR3 (real-time updates) β
DriverLocationstreams to rider clients;ClientSessionroutes events to the right gateway. - FR4 (trip lifecycle) β
Trip.state+Trip.stateVersionenforce the state machine with atomic transitions.
These are v1 entities. After the architecture reveals new needs β cell-level supply tracking, stale driver flags β the model will evolve.
With entities defined, we can design the APIs.
4. API Design β Contracts for a Two-Sided Marketplace
Ride-hailing APIs serve two distinct clients β riders and drivers β plus internal dispatch logic. The rider submits a request; the system finds a driver; the driver accepts or rejects; both sides see real-time updates. The API must handle the concurrency of this multi-party interaction cleanly.
Rider APIs
POST /v1/ride-requests β the rider submits a ride request:
{
"idempotencyKey": "req-abc-123",
"pickupLat": 37.7749, "pickupLon": -122.4194,
"dropoffLat": 37.7849, "dropoffLon": -122.4094,
"vehicleType": "standard"
}The response is 202 Accepted β matching happens asynchronously. The system returns a requestId immediately; the rider subscribes to trip updates via WebSocket. Why 202 and not 200 with a matched driver? Because matching takes up to 2 seconds and involves multiple offer attempts. Holding the HTTP connection open while iterating through candidates would couple the rider's experience to provider latency. The WebSocket channel delivers the match result as soon as it is available.
GET /v1/trips/{tripId} β returns current trip state, driver location, and ETA.
POST /v1/trips/{tripId}/cancel β cancels a trip. Only valid from states REQUESTED, MATCHED, and DRIVER_EN_ROUTE. Attempting to cancel from ON_TRIP returns 409 Conflict (must use a different flow for mid-ride issues).
Driver APIs
Driver location stream β over WebSocket, the driver app sends location updates every 3 seconds:
{ "cmd": "locationUpdate", "lat": 37.7750, "lon": -122.4180, "speed": 12.5, "heading": 45, "ts": 1710412345 }Why WebSocket and not HTTP POST for location? At 500,000 updates/s, the overhead of HTTP request/response headers (~500 bytes each) would add 250 MB/s of unnecessary network traffic. WebSocket frames carry the same payload with 2-6 bytes of framing overhead β a 100Γ reduction. This also eliminates the connection setup cost of repeated HTTP calls over mobile networks.
POST /v1/dispatch/{requestId}/accept β the driver accepts a dispatch offer. Includes an offerToken that ties the acceptance to a specific DispatchAttempt β if the offer has expired and been reassigned, the stale accept is rejected with 409.
POST /v1/dispatch/{requestId}/reject β the driver explicitly declines.
Real-Time WebSocket Events
- dispatchOffer β pushed to driver when selected as a candidate: includes pickup location, rider rating, estimated trip details, and an expiry countdown.
- tripUpdate β pushed to both rider and driver on state changes: matched, driver en route, arrived, trip started, completed.
- driverLocationUpdate β pushed to rider during active trip: shows driver's live position on the map.
Mapping APIs to Requirements
- FR1 β
POST /v1/ride-requestswith idempotency key. - FR2 β dispatch offer/accept/reject flow resolves driver assignment.
- FR3 β WebSocket events for
tripUpdate,driverLocationUpdate,dispatchOffer. - FR4 β
GET /v1/trips/{tripId}and cancel endpoint expose lifecycle state.
With the API contract clear, we can build the architecture.
5. High-Level Design β Building the Dispatch Engine Step by Step
Step A: The Trip State Machine
Before we can match drivers, we need a correct trip lifecycle. Race conditions in ride-hailing are not edge cases β they are the normal operating mode. A rider cancels while a driver accepts. Two drivers accept the same request. A driver goes offline mid-offer. If the state machine is not atomic, these races produce duplicate trips, ghost assignments, and support tickets.
ββββββββββββ ββββββββββββββββ βββββββββββββββ
β Rider ββββββΆβ Ride API ββββββΆβ Postgres β
β App ββββββ Service βββββββ (requests, β
ββββββββββββ ββββββββββββββββ β trips) β
βββββββββββββββTech decision: Postgres for trip lifecycle storage.
Trip state transitions are inherently transactional: "set state to MATCHED only if current state is REQUESTED and stateVersion equals N" is a compare-and-set operation that requires ACID guarantees. Postgres handles this naturally with UPDATE trip SET state='MATCHED', stateVersion=stateVersion+1 WHERE tripId=X AND state='REQUESTED' AND stateVersion=N β if the row was already updated by a concurrent cancel, the WHERE clause matches zero rows, and the accept fails cleanly.
Why not DynamoDB? DynamoDB supports conditional writes, but our trip data benefits from relational queries: "find all active trips for a driver," "count trips in state X per city," "join trip with dispatch attempts for debugging." These secondary access patterns are natural in Postgres and awkward in DynamoDB without GSI proliferation.
Why not Cassandra? Cassandra's eventually consistent model would require application-level conflict resolution for concurrent state transitions. At 30,000 trip requests/s, even a 0.1% race rate means 30 conflicting transitions per second β each one a potential duplicate assignment. For trip lifecycle, we trade Cassandra's write scalability for Postgres's transactional correctness. Postgres with connection pooling (PgBouncer) and a primary + read replica setup handles 30,000 writes/s comfortably for small transactional rows.
The state machine:
REQUESTED βββΆ MATCHED βββΆ DRIVER_EN_ROUTE βββΆ ARRIVED βββΆ ON_TRIP βββΆ COMPLETED
β β β
βΌ βΌ βΌ
CANCELLED CANCELLED CANCELLED Each transition is guarded by stateVersion β a monotonically increasing counter. Any transition attempt that carries a stale version is rejected. This handles every race: cancel vs. accept, double-accept, and late state updates from disconnected clients.
What this step gives us: a correct trip lifecycle that handles concurrent mutations safely. But we have no way to find a driver β that is next.
Step B: Geospatial Matching
When a ride request arrives, the dispatch service must find nearby available drivers. With 1.5 million online drivers, brute-force distance calculation against every driver for every one of 30,000 requests/s would require 45 billion distance computations per second. That is not feasible.
The solution is spatial indexing: divide the world into cells, assign each driver to a cell based on their coordinates, and search only the cells near the pickup point.
ββββββββββββ ββββββββββββββββ ββββββββββββββββ βββββββββββββββ
β Driver ββββββΆβ Location ββββββΆβ Redis β β Dispatch β
β App β WS β Ingestion β β (GEOSEARCH + βββββββ Service β
β β β Service β β H3 cells) β β β
ββββββββββββ ββββββββββββββββ ββββββββββββββββ βββββββββββββββ
500k updates/s 30k queries/sTech decision: Redis with GEOSEARCH for the hot driver location index.
Redis GEOSEARCH provides radius and bounding-box queries on geospatially indexed keys with sub-millisecond latency. At 500,000 writes/s (location updates) and 30,000 reads/s (candidate searches), Redis handles both comfortably β it is designed for exactly this: high-throughput in-memory reads and writes with geospatial indexing.
Why not PostGIS? PostGIS provides excellent geospatial query capabilities (KNN search, ST_DWithin radius filtering). But it is a disk-based system. At 500,000 mutable location updates per second, PostGIS would face massive write amplification β each update modifies an indexed row, triggering reindexing. Redis, being in-memory, absorbs these writes without disk I/O. We use PostGIS for historical location analysis (trip route storage, heatmaps), not for the real-time hot path.
Why not Elasticsearch? Elasticsearch supports geo queries but is optimized for search workloads with less frequent updates. At 500,000 writes/s, Elasticsearch's near-real-time indexing (typically 1-second refresh interval) would mean candidate searches could be working with 1-second-stale data β an eternity in ride-hailing where a driver at 60 km/h moves 17 meters per second.
H3 hexagonal cell indexing: alongside Redis GEOSEARCH, we assign each driver an H3 cell ID (Uber's open-source hexagonal hierarchical spatial index). H3 divides the world into hexagons at various resolutions. At resolution 7, each cell is roughly 5 kmΒ² β appropriate for urban matching. When a ride request arrives, the dispatch service identifies the pickup point's H3 cell and its neighboring cells (the "ring"), then queries Redis for available drivers in those cells. This reduces the candidate pool from 1.5 million global drivers to typically 10-100 nearby candidates before ranking.
The ranking function considers multiple factors: estimated pickup time (primary β the rider wants the fastest arrival), driver rating (secondary β higher-rated drivers preferred), acceptance rate (tertiary β drivers who frequently reject offers are ranked lower), and vehicle type match (filter β must match requested type). The dispatch service computes a composite score for each candidate and sorts descending.
What this step gives us: fast geospatial candidate discovery. A ride request triggers a cell-scoped search, returns a ranked candidate list, and feeds into the offer pipeline. But we have not yet built the offer delivery mechanism β how does the system send the offer to the driver and handle their response? That is next.
Step C: The Dispatch Offer Loop
The dispatch service has a ranked list of candidate drivers. It must send an offer to the top candidate, wait for their response, and β if they reject or timeout β move to the next candidate. All within the 2-second match SLA.
ββββββββββββββββ ββββββββββββββββ
β Dispatch βββdispatchOfferββββΆβ WS Gateway βββpushβββΆ Driver App
β Service β β β β
β βββββββaccept/rejectββ ββββββββββββββββ
β β ββββββββββββββββ
β β
β Candidate β If timeout (10s) or reject:
β list: β β offer next candidate
β 1. Driver A β β record DispatchAttempt
β 2. Driver B β If all reject:
β 3. Driver C β β widen search radius
ββββββββββββββββ β retry or notify rider "no drivers"Offer timeout and cascade: each offer has a 10-second expiry. If the driver does not respond within 10 seconds, the dispatch service records a DispatchAttempt with outcome TIMEOUT and offers to the next candidate. With a 2-second match SLA, you might wonder: how can we afford 10-second offer timeouts? The answer is that the 2-second target is the median case β top candidate accepts quickly. The SLA allows for degradation: p50 under 2 seconds, p95 under 10 seconds, p99 under 30 seconds. A three-candidate cascade at 10 seconds each would hit the p99 ceiling.
Offer tokens: each DispatchAttempt carries a unique offerToken. When the driver taps "Accept," their client sends POST /v1/dispatch/{requestId}/accept with this token. The dispatch service validates that the token matches the current active offer β if the offer expired and was reassigned to another driver, the stale accept is rejected with 409. This prevents the "two drivers both think they accepted" scenario.
How offers reach the driver: the dispatch service looks up the driver's session in Redis (driverId β serverId), then pushes the dispatchOffer event to the appropriate WebSocket gateway, which delivers it to the driver's app. If the push fails (socket disconnected, gateway unreachable), the dispatch service immediately marks the attempt as FAILED and moves to the next candidate β no point waiting 10 seconds for a response from a disconnected driver.
Rider experience during the cascade: while the dispatch service iterates through candidates, the rider's app shows "Finding your driver..." with a spinner. The WebSocket channel pushes progress updates: "Offer sent to nearby driver" (generic, no driver details exposed). When a driver accepts, the rider immediately receives a tripUpdate event with the driver's name, photo, vehicle details, and live location.
What this step gives us: a complete dispatch pipeline from ride request to driver assignment. But we have assumed everything works β fresh location data, healthy WebSocket connections, responsive drivers. Reality is messier. That is the final step.
Step D: Reliability and Graceful Degradation
The dispatch engine depends on three inputs: fresh driver locations, healthy WebSocket delivery, and responsive ETA estimates. Any of these can degrade: drivers lose signal, gateways crash, the ETA service times out. The system must continue matching β perhaps with reduced quality β rather than failing entirely.
Stale driver quarantine: the location ingestion service tracks each driver's last update timestamp. If now - lastLocTs > 15 seconds, the driver is flagged as stale in Redis and excluded from candidate searches. At 500,000 updates/s, if a regional network issue causes 5% of drivers (75,000) to stop updating, the stale quarantine removes them from the candidate pool within 15 seconds β preventing thousands of wasted offers to unreachable drivers.
Adaptive radius expansion: in sparse areas (suburbs, airports), the standard search radius (1-2 km) might yield zero candidates. Rather than immediately telling the rider "no drivers available," the dispatch service widens the search: ring-1 cells (200 ms budget), then ring-2 (300 ms), then ring-3 (remaining time up to 1.5 seconds). Each expansion searches a larger area but also increases pickup ETA. The rider is notified: "Searching for drivers in a wider area..."
ETA fallback: the ETA service estimates pickup time based on road network routing. If the ETA service is slow or unavailable, the dispatch service falls back to straight-line distance with a conservative speed estimate (e.g., 20 km/h in urban areas). The fare estimate uses this fallback ETA with a wider confidence band. This is less accurate but keeps dispatch operational.
Stuck trip reconciliation: a background worker scans for trips stuck in intermediate states. If a trip has been in MATCHED for more than 5 minutes (driver never moved toward pickup), the system sends a warning to the driver and prepares a reassignment. If a trip is in DRIVER_EN_ROUTE for more than 30 minutes (far exceeding any reasonable ETA), it flags the trip for support review.
FINAL ARCHITECTURE:
ββββββββββββ ββββββββββββββββ βββββββββββββββ
β Rider βββWSββ WS Gateway β β Postgres β
β App βββββββΆβ (100 fleet) ββββββΆβ (trips, β
ββββββββββββ ββββββββ¬ββββββββ β requests) β
β βββββββββββββββ
ββββββββββββ β
β Driver βββWSβββββββββ€
β App ββββββββββββββ€ βββββββββββββββ
ββββββββββββ ββββββββ΄ββββββββ β Redis β
β Location ββββββΆβ (driver β
β Ingestion β β locations, β
β (500k/s) β β sessions) β
ββββββββββββββββ ββββββββ¬βββββββ
β
ββββββββ΄βββββββ
β Dispatch β
β Service βββoffersβββΆ WS Gateway βββΆ Driver
β (matching, β
β ranking, βββaccept/reject
β offers) β
ββββββββ¬βββββββ
β
ββββββββ΄βββββββ
β ETA Svc β
β (routing, β
β fare est.) β
βββββββββββββββThe Life of a Ride Request β One Tap, Start to Finish
Let us trace exactly what happens when Sarah opens the Uber app in San Francisco and requests a ride to the airport:
- Sarah taps "Request UberX." Her app sends
POST /v1/ride-requestswith pickup coordinates (her current location), dropoff (SFO airport), vehicle type (UberX), and an idempotency key.
- Ride API validates and persists. Checks the idempotency key (not a duplicate). Computes a fare estimate using the ETA service (estimated 25 min, $35-42 based on current demand). Writes a
RideRequestto Postgres with status REQUESTED. Returns 202 Accepted with arequestId. Latency: ~20 ms.
- Dispatch service begins matching. Identifies Sarah's pickup H3 cell and queries Redis GEOSEARCH for available UberX drivers within ring-1 (nearby cells). Finds 8 candidates. Ranks them by estimated pickup time (primary), driver rating, and acceptance rate. Top candidate: Driver Marcus, 3 minutes away, 4.92 rating.
- Dispatch sends offer to Marcus. Looks up Marcus's session in Redis β connected to WS Gateway #7. Pushes a
dispatchOfferevent to Gateway #7 with pickup details, estimated trip duration, fare range, and a 10-second offer token. Records aDispatchAttemptrow.
- Marcus taps Accept. His app sends
POST /v1/dispatch/{requestId}/acceptwith the offer token. The dispatch service validates the token (not expired, not reassigned), then atomically updates the Trip:state = MATCHED, driverId = Marcus, stateVersion++. The compare-and-set succeeds. Marcus is now assigned.
- Sarah sees "Driver Marcus is on the way." A
tripUpdateevent is pushed to Sarah's WebSocket with Marcus's name, photo, vehicle details (silver Toyota Camry, plate 7ABC123), and live ETA. Her map shows Marcus's car moving toward her.
- Marcus drives to Sarah. Every 3 seconds, Marcus's app sends a location update. The location ingestion service writes it to Redis. Sarah's app receives
driverLocationUpdateevents showing Marcus's position on the map.
- Marcus arrives. His app detects proximity to the pickup point and sends a state transition. Trip state:
DRIVER_EN_ROUTE β ARRIVED. Sarah gets a notification: "Your driver has arrived."
- Trip begins. Sarah gets in, Marcus starts the trip. State:
ARRIVED β ON_TRIP. Both apps show the route to the airport with live ETA.
- Trip completes. Marcus arrives at SFO and ends the trip. State:
ON_TRIP β COMPLETED. Fare calculated based on actual distance and time: $38.50. Payment is charged (handled by payment service, out of scope).
Total latency from "Request" tap to "Driver matched" notification (steps 1-6): ~1.5 seconds in a dense city with good candidate availability. The bulk of the time is the offer delivery and driver response (~1 second); geospatial search and ranking take ~50-100 ms.
Now that the architecture is complete, the data model needs to catch up.
6. Core Entities (v2) β How the Architecture Changed Our Data
Driver(driverId, vehicleType, status, rating, currentCell, lastLocTs, staleFlag, regionId)
DriverLocation(driverId, lat, lon, speed, heading, ts, cellId)
RideRequest(requestId, riderId, pickup, dropoff, fareEstimate, vehicleType, status, idempotencyKey, requestedAt)
Trip(tripId, requestId, riderId, driverId, state, stateVersion, fareActual,
createdAt, matchedAt, arrivedAt, startedAt, completedAt, cancelReason?)
DispatchAttempt(requestId, candidateDriverId, attemptNo, rankScore, offerToken, offerTs, expiryTs, outcome, responseTs)
DriverSession(driverId, deviceId, socketId, serverId, lastHeartbeatTs, connState)
CellSupply(cellId, regionId, availableDriverCount, pendingRequestCount, surgeMultiplier, updatedAt)Driver gained staleFlag and regionId. Step D's stale quarantine logic needs a flag to exclude stale drivers from candidate searches. regionId supports multi-region dispatch β drivers and requests are scoped to a geographic region.
DispatchAttempt gained offerToken and expiryTs. Step C's offer token mechanism requires these fields to validate that an accept corresponds to the current active offer and has not expired.
CellSupply is new. It did not exist in v1 because we had not yet designed supply-demand visibility. Each H3 cell tracks how many available drivers and pending requests it contains, updated every few seconds by a background aggregation job. This data feeds two consumers: the surge pricing model (high demand + low supply β higher surge multiplier) and the dispatch service's adaptive radius logic (if local supply is zero, widen search immediately without waiting through ring-1 timeout).
Trip gained fareActual. The fare estimate is computed at request time; the actual fare is computed at trip completion based on real distance and time. Both live on the Trip record.
7. Deep Dives β Stress-Testing the Dispatch Engine
Deep Dive 1: Geospatial Search β Speed vs. Coverage
A rider requests a ride from a quiet residential street at 11 PM. The nearest driver is 4 km away β well outside the default ring-1 search radius of 1-2 km. The system searches ring-1, finds zero candidates, and must decide: tell the rider "no drivers available" (bad experience) or widen the search (slower match but successful)?
The naive approach: fixed radius, fail fast. Search 1 km, find nothing, return "no drivers." The rider retries. Multiple retries create artificial demand spikes that further destabilize matching. In suburban and airport areas, this approach fails for 15-30% of requests.
The good approach: adaptive radius expansion with time budget. The dispatch service allocates a total search budget (say, 1.5 seconds for the geospatial phase) and expands progressively: ring-1 (cells within ~1 km, 200 ms), ring-2 (~2-3 km, 300 ms), ring-3 (~5 km, remaining budget). Each expansion queries Redis GEOSEARCH with a wider radius. In dense cities, ring-1 almost always yields candidates and the search completes in 200 ms. In sparse areas, the search widens but still completes within budget.
The key metric: match rate. Adaptive radius typically improves match rate from ~70% (fixed radius) to ~90%+ (adaptive), because the extra rings capture drivers that are slightly farther but still willing to pick up.
The great approach: adaptive radius + supply-aware pre-routing. Before even running the search, the dispatch service checks CellSupply for the pickup cell. If availableDriverCount = 0, it skips ring-1 entirely and starts at ring-2 β saving 200 ms. If the surrounding rings also show low supply, the system proactively notifies the rider: "Drivers are farther away. Estimated pickup: 8-12 minutes. Confirm?" This manages expectations and reduces cancellations.
Additionally, the ranking function weights estimated pickup time more heavily than raw distance. A driver 3 km away on a highway (5-minute ETA) is better than a driver 1.5 km away in gridlocked traffic (12-minute ETA). The ETA service provides routing-based time estimates; when it is unavailable, the system falls back to straight-line distance Γ· 20 km/h as a conservative estimate.
Deep Dive 2: Stale Locations β The Phantom Driver Problem
A driver's phone loses cellular signal in a parking garage. Their last GPS update β pinned at the garage entrance β remains in Redis. For the next 30 seconds, every ride request near that garage considers this phantom driver a nearby candidate. The dispatch service sends an offer, waits 10 seconds for a response that will never come, times out, and tries the next candidate. One stale driver has wasted 10 seconds of a rider's match budget.
Now multiply this across a city. If 5% of 1.5 million drivers (75,000) are stale at any moment due to tunnels, dead zones, backgrounded apps, and poor signal, and each handles ~2 ride requests before being quarantined, that is 150,000 wasted offer attempts β each burning 10 seconds of rider wait time.
The naive approach: use last known location indefinitely. The geospatial index is polluted with stale entries. Match quality degrades. Rider wait times increase. Cancellation rates rise.
The good approach: hard freshness cutoff. Any driver whose lastLocTs is older than 15 seconds is flagged as stale and excluded from candidate searches. The location ingestion service runs a background sweep every 5 seconds, checking timestamps and setting staleFlag = true for expired entries. When a stale driver reconnects and sends a fresh location update, the flag is cleared and they re-enter the candidate pool.
Why 15 seconds? A 3-second update interval means 5 missed updates = 15 seconds. At 60 km/h, a driver moves ~250 meters in 15 seconds β still within a reasonable search radius. Beyond 15 seconds, the driver's true position is too uncertain for meaningful matching.
The great approach: freshness-weighted ranking + hard cutoff. In addition to the 15-second hard cutoff, the ranking function applies a freshness penalty: drivers whose last update is 8-12 seconds old receive a lower rank score than drivers updated within the last 3 seconds. This prioritizes the most spatially accurate candidates while still considering slightly older data rather than discarding it entirely.
The tradeoff: in areas with poor cellular coverage (rural roads, underground transit), an aggressive staleness threshold might remove too many drivers from the pool, reducing match rates. The system should track quarantine rates per region and adjust thresholds accordingly β 15 seconds in downtown, 30 seconds on highways where signal gaps are expected.
Deep Dive 3: The Accept Race β When Everyone Taps at Once
The dispatch service sends an offer to Driver A with a 10-second timeout. After 8 seconds with no response, a network glitch resolves and Driver A's accept arrives. Meanwhile, the dispatch service β having given up on A at the 10-second mark β already sent an offer to Driver B, who accepted in 3 seconds. Two drivers now believe they have the ride.
The naive approach: first accept wins with no coordination. Both accepts hit the backend. If there is no atomic guard, both might succeed β creating two trips for one request. The rider sees two drivers approaching. Chaos.
The good approach: atomic compare-and-set on trip state. The dispatch service uses Postgres's conditional update: UPDATE trip SET state='MATCHED', driverId=B, stateVersion=stateVersion+1 WHERE requestId=X AND state='REQUESTED' AND stateVersion=N. Driver B's accept succeeds because the trip is still in REQUESTED state. When Driver A's late accept arrives, the same query matches zero rows (state is now MATCHED, version has incremented). A returns 409 Conflict. One trip, one driver, no ambiguity.
The great approach: CAS + offer tokens + expiry enforcement. Each DispatchAttempt has a unique offerToken with an expiryTs. When Driver A accepts, the system first checks: is this token still the active offer? Is it expired? If the token expired (the 10-second window passed), the accept is rejected regardless of the trip state. This prevents a subtle edge case: Driver A's accept arrives at 10.5 seconds (after expiry) but before Driver B has been offered. Without token validation, A's accept would succeed (trip is still REQUESTED). With tokens, A is rejected, and B gets a clean offer.
The broader trip state machine handles additional races: rider cancels during the offer cascade (cancel sets state to CANCELLED; any subsequent accept fails the CAS), driver goes offline mid-offer (session heartbeat expires, dispatch skips to next candidate without waiting for timeout), and driver accepts but then goes offline (trip enters MATCHED but driver never moves β the stuck trip reconciliation worker detects this after 5 minutes and reassigns).
Deep Dive 4: WebSocket Delivery Under Mobile Network Churn
A driver is on a highway, passing between cell towers. Their WebSocket connection drops for 3 seconds, reconnects to a different gateway, and the dispatch service sends an offer to the old gateway. The offer is lost. The driver never sees it, the 10-second timeout burns, and the rider waits an extra 10 seconds.
The naive approach: send to last known gateway, wait for timeout. Every disconnected driver wastes 10 seconds of the rider's match budget. In cities with aggressive mobile network handoffs, this could affect 5-10% of offer deliveries.
The good approach: detect delivery failure fast. The WebSocket gateway returns a delivery acknowledgment to the dispatch service within 300 ms. If no ack arrives (gateway is unreachable or driver socket is gone), the dispatch service immediately marks the attempt as FAILED and moves to the next candidate β burning 300 ms instead of 10 seconds.
Why 300 ms? This is the typical round-trip time for an intra-region call from the dispatch service to the gateway plus the gateway's internal routing to the socket. If the socket is healthy, the ack returns in ~50 ms. If it is not, 300 ms is long enough to rule out normal network jitter without wasting too much of the 2-second match budget.
The great approach: multi-path delivery with push backup. For high-priority dispatch offers, the system sends via WebSocket (primary) and simultaneously queues a push notification (FCM/APNs) as a wakeup backup. If the driver's app is backgrounded (common on iOS), the push notification wakes it, the app reconnects via WebSocket, and the dispatch offer is re-delivered from a pending offers queue. This covers the gap between "socket dropped" and "app is still installed and driver is willing."
The tradeoff: sending push notifications for every dispatch offer adds provider cost and battery drain. Reserve this for cases where the WebSocket delivery fails β not as a default for every offer.
Deep Dive 5: Multi-Region Dispatch
A rider in Mumbai requests a ride. If the dispatch service runs in a single US-East region, the request must travel ~200 ms to US-East, query drivers (who are streaming locations to the same distant region), and send an offer back through ~200 ms of return latency. The geospatial search alone might be fast, but the network round-trips consume 400+ ms of the 2-second match budget before any processing.
The good approach: region-local dispatch. Deploy independent dispatch fleets per region (US-East, EU-West, AP-South, etc.). Each region has its own Redis cluster with local driver locations, its own Postgres for trip lifecycle, and its own dispatch service. A Mumbai rider's request stays within AP-South. Geospatial search, offer delivery, and driver accept all happen with ~10-30 ms intra-region latency.
Trip data is replicated asynchronously across regions for analytics and global support visibility, but real-time dispatch is entirely local. Cross-region latency only matters for data replication, not for the user-facing match flow.
The great approach: region-local dispatch + cross-region supply sharing. In border zones (e.g., a rider in a city near two region boundaries), local supply might be thin but the neighboring region has available drivers. The dispatch service's adaptive radius expansion can include a cross-region query as the final ring β with a higher latency budget (500 ms for the cross-region call) and only used when local supply is exhausted.
The tradeoff: cross-region queries are slower and add architectural complexity (region-aware routing, cross-region Redis access or data mirroring). For most cities, local supply is sufficient. Cross-region is a controlled fallback, not the default path.
8. Tying It All Together β From a State Machine to a Global Dispatch Engine
We started with a single API writing trips to Postgres. Let us look at what we built and why each piece was added:
| Step | Problem Solved | Components Added | Number That Forced It |
|---|---|---|---|
| A | Correct trip lifecycle under concurrency | Ride API + Postgres (CAS transitions) | 30k req/s; even 0.1% race rate = 30 conflicts/s |
| B | Fast nearby driver discovery | Redis GEOSEARCH + H3 cell indexing + Location Ingestion | 500k location writes/s; brute-force = 45B distance calcs/s |
| C | Offer delivery and accept loop | WS Gateway fleet + Dispatch Service + Offer tokens | 2s match SLA; 10s offer timeout Γ cascading candidates |
| D | Resilience under real-world failures | Stale quarantine + Adaptive radius + ETA fallback + Reconciliation | 5% stale drivers = 75k phantom candidates without filtering |
The data model evolved:
| Entity | In v1? | In v2? | What Triggered the Change |
|---|---|---|---|
| Driver | Yes | Yes (+ staleFlag, regionId) | Stale quarantine; multi-region dispatch |
| Trip | Yes | Yes (+ stateVersion, fareActual) | CAS for race safety; actual vs estimated fare |
| DispatchAttempt | Yes | Yes (+ offerToken, expiryTs) | Offer token mechanism for accept race prevention |
| CellSupply | No | Yes | Supply-demand visibility for surge and adaptive radius |
| DriverSession | No (was ClientSession) | Yes (dedicated) | Gateway crash handling; dispatch routing |
Every component traces to a throughput number or a failure mode. Nothing is here because "Uber uses it" β everything is here because the math or the race condition demanded it.
9. Common Mistakes β What Gets Candidates Rejected
Brute-force geospatial search. Scanning 1.5 million drivers for every ride request is not a design β it is a denial-of-service on your own system. H3/S2 cell indexing is required, and you must explain why.
No stale location handling. If your design dispatches to drivers whose last GPS update is 60 seconds old, you are sending offers to ghosts. Interviewers will probe this.
Missing idempotency for ride request and driver accept. Mobile networks lose packets. Users double-tap. Without idempotency keys and offer tokens, you will create duplicate trips.
No atomic state transition guard. "First accept wins" is not a mechanism β it is a hope. UPDATE ... WHERE state='REQUESTED' AND stateVersion=N is a mechanism.
Coupling dispatch to hard dependencies. If the ETA service goes down and your dispatch stops entirely, you have a brittle system. Fallback to straight-line distance estimates keeps matching alive.
Ignoring the offer cascade experience. Matching is not just finding a driver β it is managing the rider's 2-30 second wait while drivers are offered and respond. The system must handle timeouts, rejections, and re-offers gracefully, not just return "no drivers" on the first failure.
10. What the Interviewer Is Actually Evaluating
Did you separate ephemeral state from durable state? Driver locations (500k writes/s, in-memory, ephemeral) must live in a different store than trip lifecycle (30k writes/s, transactional, durable). Putting both in Postgres or both in Redis would be wrong for different reasons.
Did you map throughput to indexing choices? 500k location writes/s + 30k candidate queries/s β Redis with geospatial indexing. Not PostGIS, not Elasticsearch, not brute-force. The choice must be grounded in numbers.
Did you handle the accept race explicitly? CAS + offer tokens + stateVersion. Interviewers will create race scenarios and expect you to trace through the mechanism.
Did you design for degraded operation? Stale drivers, slow ETAs, dropped WebSockets, sparse supply areas β the system must handle each gracefully, not collapse.
Did you show the offer cascade? Dispatch is not a single query-and-assign. It is a multi-step loop: search β rank β offer β wait β timeout/reject β re-offer β eventually succeed or expand radius. This loop, with its time budgets and fallback strategies, is the heart of the system.
Final Thought
A ride-hailing system makes hundreds of real-time decisions per second under imperfect information. The driver's location is 3 seconds old. The ETA is an estimate. The mobile network might drop the offer. The rider might cancel mid-match.
We started with a state machine and a database. We ended with a geospatial dispatch engine backed by H3 cell indexing, in-memory location storage, an offer cascade with token-based race protection, stale driver quarantine, adaptive radius expansion, and multi-region deployment. Not because we drew the final architecture on day one, but because each problem β throughput limits, race conditions, stale data, network failures β demanded exactly one targeted solution.
The discipline remains the same across every system we have designed: start simple, let numbers force decisions, and always know what breaks first.
Continue Learning
Location Services
PRODesign a Nearby Discovery Platform
It is 7:14 PM on a Friday evening. Maya opens her app in downtown Manhattan to find nearby friends, restaurants, and events. Within 180 milliseconds, she sees: 4 friends "nearby" (one within 500m, ...
Location Services
PRODesign a Google Maps-like Platform
It is 5:47 PM on a Friday. Traffic is building across the city. In the next 60 seconds, your platform will process: 4 million tile requests from people panning their screens, 180,000 route computat...
Web Services
Design a URL Shortener
A URL shortener looks trivial. Accept a long URL, return a short one, redirect anyone who clicks it. You could build a working prototype in an afternoon with a hash map and a web server.