Designing a Ride Sharing Platform (Uber / Lyft)
Difficulty: Advanced Topics: Real-Time Matching, Location Tracking, WebSocket, Surge Pricing, Geohash Asked at: Uber, Lyft, Ola, Grab, Google, Amazon Prerequisites:Geospatial Indexing, WebSockets, and Message Queues
1. Understanding the Problem
A ride-sharing platform connects riders who need a ride with nearby drivers who have a car. The rider opens the app, enters a destination, and within seconds gets matched with the closest available driver. The driver navigates to the pickup, completes the trip, and both parties are charged/paid. The hard part? Millions of drivers are sending GPS pings every few seconds, and you need to find the nearest available ones in real-time while guaranteeing no two riders get matched to the same driver.
2. Naive First Cut
flowchart LR
Rider["Rider App"]:::client
API["API Server"]:::service
DB["Postgres DB"]:::data
Driver["Driver App"]:::client
Rider --> API
Driver --> API
API --> DB
classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
| Color | Meaning |
|---|---|
| π Purple-Orange | Client apps |
| π΅ Blue | Edge / Gateway |
| π’ Green | Backend services |
| π‘ Yellow | Data stores |
| π£ Purple | Async (Kafka) |
| π΄ Pink | External services |
How this breaks:
- Querying βnearest driverβ in Postgres with lat/lng is a full table scan - wonβt work with 2M active drivers
- Single API server canβt handle 500K+ location pings per second from all active drivers
- No way to push ride offers to drivers - polling is too slow for a 10-second matching window
- If two riders request simultaneously and the same driver is βnearest,β both get assigned β double-booking
- No real-time tracking - rider has no idea where the driver is after matching
- Single DB is a write bottleneck for millions of location updates per second
The rest of the doc evolves this into a production-grade real-time matching and tracking system.
3. Prior Art Weβre Drawing From
- Uber Ringpop - Consistent hash ring for partitioning driver locations by geohash region. Each node owns a geographic shard, enabling local proximity queries without global coordination. (Uber Engineering Blog)
- Uber H3 - Hierarchical hexagonal grid system for geospatial indexing. Replaces traditional geohash with uniform-area hexagons that avoid edge distortion. Used for surge pricing zones and supply-demand balancing. (Uber H3)
- Lyft Marketplace Dispatch - Scoring-based matching that considers not just distance but ETA, driver heading, and predicted rider wait time. Two-phase: filter nearby candidates, then rank by composite score. (Lyft Engineering)
- Grab GrabNearby - Redis Geo + geohash for sub-10ms proximity queries on 2M+ active drivers in Southeast Asia. Location TTL ensures stale drivers auto-expire. (Grab Engineering)
- Google S2 Geometry - Hilbert curve-based spatial indexing used internally at Google Maps and adopted by multiple ride-sharing platforms for region-based sharding and proximity search.
4. Functional Requirements
Core (Top 3)
- Riders request a ride - enter pickup and destination, get matched with the nearest available driver within 30 seconds
- Real-time location tracking - drivers send GPS updates every 3-5 seconds; riders see live driver position during ride
- Drivers accept/decline rides - receive ride offers with pickup details, navigate to rider, start/complete trip
Below the Line
- Ride fare estimation before booking
- Payment processing and driver payouts
- Rating system (rider rates driver and vice versa)
- Ride history and receipts
- Scheduled rides (book for later)
- Ride sharing / carpooling (UberPool)
5. Non-Functional Requirements
Core
| NFR | Target |
|---|---|
| Matching latency | Rider matched with driver in < 30 seconds |
| Location ingestion | Handle 2M+ drivers sending pings every 3-5 seconds (500K-700K writes/sec) |
| Availability | 99.99% during peak hours - a down matching service means no rides |
| Consistency | Strong consistency on driver assignment - no double-booking (one driver = one active ride) |
Below the Line
- ETA accuracy within 2 minutes of actual arrival
- Sub-second location update delivery to riders (live tracking)
- Multi-region deployment for global coverage
6. Scale Estimation (Back-of-Envelope)
- Users: 30M DAU, 500K+ concurrent rides at peak
- Write QPS: 500K location writes/sec (2M active drivers Γ 1 ping every 4 seconds)
- Read QPS: 50K matching queries/sec (each ride request triggers geo-radius lookups)
- Storage: ~1TB ride data/year (10M rides/day Γ ride metadata + location history)
- Bandwidth: ~5 Gbps at peak (location ingestion + WebSocket tracking fan-out)
7. Core Entities
- Rider - account, payment methods, saved addresses, current location
- Driver - account, vehicle info, availability status (online/offline/on-trip), current location
- Ride - pickup, destination, status, assigned driver, fare, timestamps (state machine)
- Location - driverId, lat/lng, heading, speed, timestamp (ephemeral - lives in Redis)
- Fare - base fare, distance component, time component, surge multiplier, total
- Zone - geographic region for surge pricing (H3 hexagon or geohash prefix)
8. API / System Interface
POST /api/v1/rides/request
Body: { pickupLat, pickupLng, destLat, destLng, rideType: "STANDARD"|"PREMIUM" }
Response: { rideId, estimatedFare, estimatedETA, status: "MATCHING" }
Auth: JWT Bearer token (rider)
Note: Idempotency via clientRequestId header
POST /api/v1/rides/{rideId}/accept
Body: { offerId }
Response: { status: "DRIVER_ENROUTE", pickup: { lat, lng }, rider: { name, rating } }
Auth: JWT Bearer token (driver)
Note: driverId comes from the JWT sub claim, never the body - the client is not
trusted for identity. offerId must match the outstanding dispatch offer for
this ride, so a driver cannot accept a ride they were never offered.
POST /api/v1/rides/{rideId}/complete
Body: { endLat, endLng }
Response: { fare, receipt }
Auth: JWT Bearer token (driver)
PUT /api/v1/drivers/location
Body: { lat, lng, heading, speed, timestamp }
Response: 204 No Content
Auth: JWT Bearer token (driver)
Note: Called every 3-5 seconds by driver app. Fire-and-forget.
WebSocket /ws/v1/rides/{rideId}/track
Pushes: { driverLat, driverLng, heading, eta, updatedAt }
Auth: JWT ticket in connection params
9. High-Level Design
FR1: Riders Request a Ride and Get Matched
The first thing that happens when a rider opens the app is they enter a destination and tap βRequest Ride.β The system needs to find the nearest available driver within seconds and assign them to this ride - without double-booking.
Build the simplest thing that satisfies the requirement. Four services and one database, with no cache and no spatial index yet. Each of those is a response to a non-functional requirement, so each belongs in a deep dive where we can show why it is needed instead of asserting it up front.
New components we need:
- API Gateway - Entry point for all client requests. Handles JWT auth, rate limiting, and request routing.
- Ride Service - Manages the ride lifecycle (state machine). Creates the ride, assigns drivers, tracks state transitions.
- Matching Service - Finds nearby available drivers, ranks them, and assigns one.
- Location Service - Owns driver GPS coordinates. For now it just reads and writes a
driver_locationstable in the primary database.
flowchart LR
Rider["Rider App"]:::client
GW["API Gateway"]:::edge
RS["Ride Service"]:::service
MS["Matching Service"]:::service
LS["Location Service"]:::service
DB["Postgres rides and driver_locations"]:::data
Rider -->|"1. POST ride request"| GW
GW -->|"2. Forward to ride logic"| RS
RS -->|"3. Find nearest driver"| MS
MS -->|"4. Query available drivers"| LS
LS -->|"5. Filter driver_locations by lat and lng"| DB
RS -->|"6. Persist ride record"| DB
classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
classDef edge fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0
classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
Step-by-step flow:
- Rider taps βRequest Rideβ with pickup (current GPS) and destination β request hits API Gateway
- Gateway validates JWT, checks rate limits, forwards to Ride Service
- Ride Service creates a ride record in Postgres with status
MATCHINGand calls Matching Service - Matching Service asks Location Service: βGive me all available drivers within 3km of this pickup pointβ
- Location Service queries
driver_locationswith a bounding box on lat/lng, then computes real distance per row - Matching Service filters for availability (is the driver already on a trip?) and ranks the survivors by ETA
- Matching Service picks the best driver and assigns them by writing
driver_idonto the ride row
Why not just pick the closest driver?
Distance alone isnβt enough. A driver 500m away stuck in traffic might have a 12-minute ETA, while a driver 1.2km away on a highway has a 3-minute ETA. The matching service uses ETA (from the mapping API) as the primary ranking signal, not raw distance.
What we have deliberately left broken. This design is honest for one city on launch day, and it has two holes. Step 5 is a table scan that no B-tree index makes fast, because a B-tree orders on one dimension and proximity is two. Step 7 has a race: two requests can read the same free driver and both assign them. Neither is a missing feature, both are non-functional failures, so both are earned back in the deep dives β proximity search in Deep Dive 1, the double-assignment race in Deep Dive 2.
FR2: Riders See Their Driverβs Live Position
Two things have to happen. Drivers report where they are, and riders on an active trip see that position move. Both are satisfiable with the components we already have.
New components we need: none. The Location Service already owns driver_locations.
Drivers write to it, and the riderβs client reads the driverβs row for their active ride.
flowchart LR
Driver["Driver App"]:::client
LS["Location Service"]:::service
DB["Postgres driver_locations"]:::data
Rider["Rider App"]:::client
Driver -->|"1. PUT GPS coordinates"| LS
LS -->|"2. Upsert driver row"| DB
Rider -->|"3. Poll driver position"| LS
LS -->|"4. Read row for this ride"| DB
classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
Step-by-step flow:
- Driver app sends a GPS ping every 3-5 seconds:
PUT /drivers/locationwith lat, lng, heading, speed - Location Service upserts the driverβs row in
driver_locations, one row per driver, overwritten in place - The riderβs app polls
GET /rides/{rideId}/driver-locationevery few seconds while the trip is active - Location Service reads the assigned driverβs row and returns it
Resist the urge to reach for a websocket here. Polling satisfies the requirement, and until we have said why it is not good enough there is nothing to justify the extra machinery. Two facts will force our hand later, and both are non-functional: this is the highest-throughput write path in the system at roughly 500K pings/sec, and polling burns a request per rider per interval whether or not the car actually moved. The write path is Deep Dive 1; the push channel is Deep Dive 4.
One thing genuinely belongs here, because it is correctness rather than scale: a driver
who crashes or loses signal must stop being matched. Give each location row a
last_seen_at and treat anything older than 15 seconds as offline, so a dead app cannot
sit in the candidate pool as a ghost driver.
FR3: Drivers Accept Rides and Complete Trips
Once matched, the driver gets a push notification with ride details. They have 15 seconds to accept. If they decline or timeout, the system moves to the next-best driver. After acceptance, the ride moves through a state machine: driver_enroute β arrived β trip_started β trip_completed.
New components we need:
- Notification Service - Pushes ride offers to drivers and status updates to riders, via APNs on iOS and FCM on Android. A phone with the app closed still has to be woken up, which is exactly what these gateways are for.
- Pricing Service - Calculates fares based on distance, time, and surge multiplier. Called at ride request (estimate) and ride completion (final fare).
- ETA Service - Computes estimated time of arrival using mapping APIs (Google Maps, Mapbox, OSRM). Called during matching and continuously during the ride.
flowchart LR
MS["Matching Service"]:::service
NS["Notification Service"]:::service
Driver["Driver App"]:::client
RS["Ride Service"]:::service
PS["Pricing Service"]:::service
ETA["ETA Service"]:::service
Maps["Maps API"]:::external
DB["Postgres rides"]:::data
MS -->|"1. Push ride offer"| NS
NS -->|"2. Alert driver via FCM"| Driver
Driver -->|"3. POST accept ride"| RS
RS -->|"4. Calculate final fare"| PS
RS -->|"5. Compute arrival ETA"| ETA
ETA -->|"6. Query route distance"| Maps
RS -->|"7. Persist ride state change"| DB
classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
classDef external fill:#4a1942,stroke:#f472b6,color:#e2e8f0
classDef async fill:#3b1f5e,stroke:#c084fc,color:#e2e8f0
Step-by-step flow:
- Matching Service selects the best driver and hands the offer to the Notification Service
- Notification Service wakes the driverβs phone through APNs or FCM: βNew ride: Pickup at MG Road, 1.2km away, est. fare βΉ350β
- Driver has 15 seconds to accept. Timer starts (managed by Ride Service)
- If driver accepts β Ride Service sets ride status to
DRIVER_ENROUTE, marks the driver unavailable so matching skips them, and tells the rider - If driver declines or the timer expires β Matching Service picks the next-best driver from the ranked list (retry up to 3 drivers)
- During the trip the Ride Service advances the state machine as the driver reports arrival, trip start and trip end
- On completion: Pricing Service computes fare = base + (distance Γ rate) + (time Γ rate) Γ surge_multiplier. Ride Service persists final state.
Note what is missing: there is no event bus here. The Ride Service calls the services it needs directly and writes state to Postgres. That is the right starting point β an event bus is how we stop dropping requests under load and how we survive a matching worker dying mid-ride, which is Deep Dive 3, not a High-Level Design decision.
Why a 15-second timeout?
If we wait too long, the riderβs experience suffers. If too short, drivers canβt react. 15 seconds is the industry standard. After 3 failed attempts (45 seconds total), expand the search radius and try again. If still no match after 60 seconds, notify rider βNo drivers available.β
10. Technology Choices
| Tier | Purpose | Stores | Access Pattern | Primary | Alternatives |
|---|---|---|---|---|---|
| Location Store (hot) | Real-time driver positions | lat/lng per active driver | Geo-radius queries + point updates | Redis Geo (GEOADD/GEORADIUS) | PostGIS, ElasticSearch geo_point |
| Ride DB | Ride lifecycle state | Rides, assignments, fares | Read/write by rideId and userId | Postgres | CockroachDB, TiDB |
| Event Bus | Ride events and location fan-out | Events: requested, matched, started, completed | Pub/sub + ordered per ride | Kafka or Redpanda | Kinesis, Pub/Sub |
| Cache | Driver availability, ETAs | Driver status, precomputed ETAs | High-QPS reads, TTL-based expiry | Redis Cluster | Memcached |
| Real-time Delivery | Push location to riders | WebSocket messages | Fan-out per active ride | WebSocket Gateway + Redis Pub/Sub | SSE, gRPC streaming |
| Analytics Store | Trip history, pricing signals | Historical rides, demand patterns | Batch reads, time-series | ClickHouse or BigQuery | Redshift, Druid |
| Object Store | Receipts, invoices | PDFs | Batch generation, rare reads | S3 | GCS, MinIO |
Why Redis Geo, not Postgres with PostGIS? Weβre handling 500K+ location updates per second. Redis Geo uses an in-memory sorted set with geohash encoding - O(log N) inserts and O(N+log M) radius queries where M is total items and N is results. PostGIS would require disk I/O on every update and query. Redis gives us sub-millisecond proximity search, which is essential for the 30-second matching SLA.
Why Kafka for ride events? A single ride generates 8-10 state transitions (requested β matched β driver_enroute β arrived β started β completed). Multiple services consume these: billing, notifications, analytics, ETA. Kafkaβs consumer groups let each service process independently without blocking others.
11. Data Modeling
Redis Geo (Location Store β real-time driver positions):
Key: "drivers:active:{city}" β Geo Set
Member: driverId
Score: geohash-encoded lat/lng
Key: "driver:status:{driverId}" β Hash { status, vehicleType, heading, speed, lastPing }
TTL: 30s (auto-expires if driver stops pinging)
Postgres (Ride DB β ride lifecycle and state machine):
CREATE TABLE rides (
ride_id UUID PRIMARY KEY,
rider_id UUID NOT NULL,
driver_id UUID,
status VARCHAR(20) NOT NULL, -- MATCHING, DRIVER_ENROUTE, ARRIVED, IN_PROGRESS, COMPLETED, CANCELLED
pickup_lat DECIMAL(10,7),
pickup_lng DECIMAL(10,7),
dest_lat DECIMAL(10,7),
dest_lng DECIMAL(10,7),
fare_amount DECIMAL(10,2),
surge_multiplier DECIMAL(3,2) DEFAULT 1.00,
requested_at TIMESTAMP,
matched_at TIMESTAMP,
started_at TIMESTAMP,
completed_at TIMESTAMP,
idempotency_key UUID UNIQUE
);
CREATE INDEX idx_rides_rider ON rides(rider_id, requested_at DESC);
CREATE INDEX idx_rides_driver ON rides(driver_id, status);
Kafka (Event Bus β ride state transitions):
Topic: ride-events (partitioned by ride_id)
Key: ride_id
Value: { ride_id, event_type, driver_id, rider_id, location, timestamp, metadata }
Events: REQUESTED, MATCHED, DRIVER_ENROUTE, ARRIVED, STARTED, COMPLETED, CANCELLED
Access Patterns:
| Query | Data Source | How |
|---|---|---|
| Find nearest drivers | Redis Geo | GEOSEARCH drivers:active:{city} FROMLONLAT lng lat BYRADIUS 3 km COUNT 20 ASC |
| Update driver location | Redis Geo | GEOADD drivers:active:{city} lng lat driverId every 3-5s |
| Claim driver (prevent double-booking) | Redis | SET driver:lock:{driverId} rideId NX EX 30 β atomic CAS |
| Track ride state | Postgres | SELECT * FROM rides WHERE ride_id = ? |
| Live tracking (push to rider) | Redis Pub/Sub | Subscribe to channel ride:track:{rideId}, driver pings publish location |
How the Nearest Driver Query Works with Redis Geo:
- Rider requests ride at (lat, lng) β Matching Service calls
GEOSEARCH drivers:active:{city} FROMLONLAT lng lat BYRADIUS 3 km COUNT 20 ASC - Redis returns up to 20 nearest available drivers sorted by distance (O(N+log M) where N=results, M=total drivers in set)
- For each candidate: check
driver:status:{driverId}hash β filter out on-trip or busy drivers - Score remaining candidates: distance Γ ETA weight Γ heading factor (driver facing toward pickup ranks higher)
- Attempt atomic lock:
SET driver:lock:{topDriver} rideId NX EX 30β if SET succeeds, driver is claimed; if fails, try next candidate - Send ride offer to claimed driver via WebSocket. If declined/timeout (30s), release lock and try next
- On accept: write to Postgres
ridestable, publish MATCHED event to Kafka
12. Deep Dives
1) How do we handle 500K location writes a second and still answer βwho is near meβ in under 50ms?
Problem: the high-level design writes every driver ping to a driver_locations row
and finds candidates with a bounding-box query. Both halves fall over. 2M active drivers
pinging every 3-5 seconds is ~500K-700K writes/sec, and matching needs the proximity
query back in under 50ms.
Two separate things are broken, and it is worth naming them separately because they have
different fixes. The write volume is the obvious one. The query is the subtler
one: a B-tree index sorts on a single dimension, and proximity is inherently
two-dimensional, so an index on lat and an index on lng each narrow one axis and
leave the other to a scan. There is no B-tree that answers βnear this pointβ directly.
Bad: what we built in the high-level design β plain rows plus a bounding-box query. Put a number on it before dismissing it. 500K writes/sec is 43 billion writes a day; on DynamoDB on-demand at $1.25 per million write units that is about $54,000 a day for the location tier alone, and the write volume is the cheap half of the problem. The query is still a scan of every driver in the box.
Good: Redis Geo with a single instance per city. GEOADD for writes, GEORADIUS for queries.
π‘ Redis Geo stores coordinates using geohash encoding in a sorted set, so nearby points sit near each other in one dimension and a radius query becomes a range scan. Learn more β
In-memory writes and an index built for the query shape solve both halves at once. The limit is a single instance: roughly 100K ops/sec, which is short of our 500K.
Great: Sharded Redis Geo, partitioned by geohash prefix.
π‘ Geohash encodes a 2D coordinate into a 1D string where nearby points share a common prefix. A geohash like βtdr1wβ covers a ~5kmΒ² cell. We shard by the first 3-4 characters, so each shard owns a geographic region. Learn more β
In simple terms: Divide the city into geographic regions. Each region gets its own Redis instance. When looking for nearby drivers, query only the relevant region (+ its neighbors). This spreads the 500K writes/sec across 8 machines instead of one.
flowchart LR
LI["Location Service"]:::service
ROUTER["Geo Router"]:::service
R1["Redis Shard: North"]:::data
R2["Redis Shard: South"]:::data
R3["Redis Shard: East"]:::data
MS["Matching Service"]:::service
LI -->|"1. Compute geohash prefix"| ROUTER
ROUTER -->|"2. Write to north shard"| R1
ROUTER -->|"3. Write to south shard"| R2
ROUTER -->|"4. Write to east shard"| R3
MS -->|"5. Find nearby drivers"| ROUTER
classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
Mechanism (borrowing from Grabβs GrabNearby):
- Driver sends location β Ingestion Service computes geohash prefix (4 chars = ~20kmΒ² cell)
- Geo Router maps prefix to the owning Redis shard (consistent hashing - like Uberβs Ringpop)
GEOADD city:available {lng} {lat} {driverId}- O(log N) insert- Each entry has a TTL of 15 seconds. If no fresh ping arrives, the driver auto-expires (handles crashes, offline)
- When Matching Service needs nearby drivers: computes geohash of pickup, queries the owning shard + adjacent shards (to handle cell boundary cases)
GEORADIUS city:available {lng} {lat} 3000 m COUNT 20 ASC- returns 20 nearest within 3km
Scaling math: With 2M drivers across 8 shards, each shard holds ~250K entries (~500MB RAM). Each shard handles ~80K ops/sec. Total capacity: 640K ops/sec - comfortably above our 500K requirement.
Edge case - cell boundaries: A rider at the edge of geohash cell βtdr1β might have the nearest driver in cell βtdr2.β Solution: always query the target shard + 8 neighboring cells. This means up to 3 shard queries per match request, but they run in parallel (sub-10ms total).
2) How do we stop two riders being matched to the same driver?
Problem: Two riders request simultaneously. Both see Driver X as the nearest available. Without protection, both rides get assigned to Driver X β double-booking.
Bad: Check-then-act in application code (if driver.available then assign). Race condition between the check and the assignment. Two processes both see βavailableβ and both assign.
Good: Database-level optimistic locking with a version column. UPDATE drivers SET ride_id = ? WHERE id = ? AND ride_id IS NULL. Only one UPDATE succeeds (returns rowcount=1). The other gets rowcount=0 and retries with next driver. Works but adds a DB round-trip in the hot path.
Great: Keep the cheap Redis lease, but put the invariant in Postgres. SET NX EX 20 de-duplicates offers without a DB round-trip per candidate, and a unique constraint on the driverβs active ride makes a double-assignment impossible even when the lease misbehaves. That combination is stronger than either half: the lease keeps contention off the database in the common case, the constraint is what you can actually rely on.
π‘ A distributed lock lets only one process βholdβ a key at a time. With a TTL it is really a lease β bounded exclusivity β and the holder gets no notification when it lapses. A fence token is the stronger primitive: a monotonically increasing number that the resource being written to checks, rejecting any write carrying a token lower than the highest it has seen. Redis does not give you one for free, which is why the guarantee here rests on the DB constraint rather than on the lock.
In simple terms: When we pick a driver for a ride, we βlockβ that driver for 20 seconds using Redis. If another ride request also picks the same driver, the lock fails and it moves to the next candidate. Auto-expires after 20 seconds so drivers donβt stay locked forever if something crashes.
flowchart LR
MS1["Matcher Instance 1"]:::service
MS2["Matcher Instance 2"]:::service
LOCK["Redis Lock driver:123"]:::data
RS["Ride Service"]:::service
MS1 -->|"1. SET driver:123:lock NX EX 20"| LOCK
MS2 -->|"2. SET driver:123:lock NX EX 20"| LOCK
LOCK -->|"3. OK lock acquired"| MS1
LOCK -->|"4. Nil lock denied"| MS2
MS1 -->|"5. Assign driver to ride"| RS
classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
Mechanism:
- Matching Service picks Driver X as best candidate
- Attempts
SET driver:{driverId}:lock {rideId} NX EX 20in RedisNX= only set if not exists (atomic check-and-set)EX 20= auto-expire in 20 seconds (prevents deadlock if matcher crashes)
- If lock acquired (OK response) β proceed with assignment, notify driver
- If lock not acquired (nil response) β skip this driver, try next candidate
- When driver accepts: Ride Service confirms the assignment in Postgres (strong consistency) and removes the lock
- If driver declines or 15s timeout: lock is explicitly released, driver returns to available pool
Backstop: Even if Redis lock fails (network partition), the Postgres UPDATE rides SET driver_id = ? WHERE id = ? AND status = 'MATCHING' acts as a second gate. The DB has a unique constraint on (driver_id, status=βACTIVEβ) preventing any double-assignment at the persistence layer.
π‘ Get the ordering of those two right and you have the whole lesson. The Redis entry is a lease, not a fencing token β SET NX EX gives you mutual exclusion only for as long as the TTL holds, and a matcher that stalls past 20 seconds can wake up still believing it owns the driver and write anyway. A true fencing token is a monotonically increasing number that the resource being written to enforces, rejecting any write carrying a lower token than the highest it has seen; Redlock does not provide one (see Fencing Tokens β). So the lease is an optimization β it stops us offering the same driver to two riders in the common case and buys a cheap accept window without touching Postgres per candidate β while the unique constraint is the actual invariant. An invariant is only safe in the store that owns the data.
Why not Redlock?
Redlock (Redis distributed lock across N nodes) adds latency and complexity. For ride matching, a single Redis lock with short TTL + Postgres backstop is sufficient. The worst case of a stale lock is a 20-second delay (lock expires), not a correctness violation.
3) How do we avoid dropping ride requests when 100K people request at once?
Problem: Friday night, 10PM. Demand spikes 5x but driver supply stays constant. Without intervention: matching times spike to 5+ minutes, riders rage-quit, drivers get overwhelmed with pings.
Bad: First-come-first-served with no price adjustment. All riders compete for the same small pool of drivers. Most riders wait indefinitely. Drivers in adjacent zones donβt know thereβs demand nearby.
Good: Simple surge multiplier (2x, 3x) shown to riders before confirming. Discourages some demand, incentivizes nearby drivers to relocate. But: how do you calculate the surge factor? Fixed zones break at city boundaries.
Great: Dynamic surge pricing using H3 hexagonal zones with real-time supply-demand signals. (Borrowing from Uberβs H3 system.)
Mechanism:
- City is divided into H3 hexagons (resolution 7 = ~5kmΒ² cells).
π‘ H3 is Uberβs open-source hexagonal grid system. Unlike square geohash cells, hexagons have uniform adjacency (every neighbor is equidistant) - better for spatial analysis. Learn more β - Every 30 seconds, a Surge Calculator job runs per zone:
demand_score= ride requests in last 2 minutes / available drivers in zone- If demand_score > 1.5 β surge multiplier = 1.0 + (demand_score - 1.0) Γ 0.5 (capped at 3.0x)
- Surge multiplier is shown to rider before they confirm. Some riders wait, reducing demand naturally.
- Drivers see a heat map of surge zones β incentivized to drive toward high-demand areas (supply redistribution)
- When demand drops, surge decays gradually (not instantly) to prevent oscillation
Queue management during extreme peaks:
- If no driver is available within 5km even with surge: ride enters a waiting queue
- Rider is shown position in queue and estimated wait time
- As drivers complete trips in the zone, theyβre immediately offered queued rides (priority over new requests)
- If wait exceeds 5 minutes, expand search radius to 8km with surge pricing as incentive for farther drivers
Backstop: Circuit breaker on the matching service. If matching failure rate exceeds 80% for > 60 seconds in a zone, temporarily halt new ride requests in that zone and show βNo drivers available - try again in a few minutesβ rather than queuing indefinitely.
4) How do we push the driverβs position to the rider without polling?
Problem: During an active ride, the rider needs to see the driverβs position update every 3-5 seconds on the map. With 1M concurrent rides, thatβs 1M WebSocket connections each receiving 200-300 messages per ride.
In simple terms: During your Uber ride, you watch the driverβs car move on the map. With 1M concurrent rides, thatβs 1M persistent connections each receiving updates every 3-5 seconds.
Bad: Rider polls GET /rides/{id}/driver-location every 2 seconds. At 1M rides, thatβs 500K HTTP requests/sec just for tracking. Wastes bandwidth, adds 2-second latency, burns server CPU on connection setup.
Good: One WebSocket per rider, server pushes location. But: how does the location event (arriving at Location Ingestion Service) get routed to the correct WebSocket Gateway instance holding that riderβs connection?
Great: Kafka-partitioned fan-out + Redis Pub/Sub for last-mile delivery.
π‘ Fan-out = delivering one event to multiple subscribers. Here the βfanβ is narrow (one rider per ride), but the routing is the challenge - which server holds the connection? Learn more β
In simple terms: Driver sends GPS ping β it goes to Kafka (durable queue). From Kafka, a router figures out which WebSocket server holds the riderβs connection and pushes the location there. The rider sees the driver move on the map within 200-400ms.
flowchart LR
Driver["Driver App"]:::client
LI["Location Service"]:::service
KF["Kafka"]:::async
ROUTER["WS Router"]:::service
RPS["Redis Pub/Sub"]:::data
WSG1["WS Gateway 1"]:::service
WSG2["WS Gateway 2"]:::service
Rider["Rider App"]:::client
Driver -->|"1. PUT GPS coordinates"| LI
LI -->|"2. Publish location event"| KF
KF -->|"3. Route by rideId"| ROUTER
ROUTER -->|"4. Publish to ride channel"| RPS
RPS -->|"5. Deliver to gateway 1"| WSG1
RPS -->|"6. Deliver to gateway 2"| WSG2
WSG1 -->|"7. Push location"| Rider
classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
classDef async fill:#3b1f5e,stroke:#c084fc,color:#e2e8f0
Mechanism:
- When a rider connects via WebSocket for ride tracking, the gateway registers
(rideId β gatewayInstanceId)in Redis - Driver sends location ping β Location Ingestion β publishes to Kafka topic
ride.tracking, partitioned by rideId - WS Router consumes from Kafka, looks up which gateway instance owns the riderβs connection (from Redis registry)
- Publishes to Redis Pub/Sub channel
ride:{rideId}:location - The specific gateway instance subscribed to that channel receives it, pushes to riderβs WebSocket
- Total latency: driver GPS β rider map = 200-400ms (network + Kafka + Redis Pub/Sub + WebSocket push)
Scaling WebSocket connections:
- Each gateway instance holds ~100K concurrent connections (limited by file descriptors and RAM)
- 10 gateway instances = 1M concurrent rides covered
- Horizontal scaling: add more gateway instances behind a load balancer (sticky sessions by rideId)
- Connection registry in Redis enables any gateway to find any connection
Fallback for disconnection:
- If rider WebSocket drops, buffer last 5 location points in Redis (LIST with LTRIM)
- On reconnect, replay buffered points for smooth map animation
- If disconnected > 30 seconds, rider app switches to HTTP polling as graceful degradation
5) How do we compute an ETA that is accurate enough to show a rider?
Problem: The rider needs accurate ETA both at matching time (βdriver arrives in 4 minutesβ) and during the trip (βarriving at destination in 12 minutesβ). Calling Google Maps API for every location update (500K/sec) would cost $millions/month and add latency.
In simple terms: The app says βdriver arriving in 4 minutes.β This needs to be accurate - not just distance/speed calculation but actual road conditions, one-way streets, and live traffic.
Bad: Straight-line distance / average speed. β2km away = 4 minutes.β Completely wrong in urban areas with one-way streets, traffic, and construction.
Good: Call Google Maps Directions API for each match request. Accurate but expensive ($5-10 per 1000 requests). At 100K rides/day, thatβs $500-1000/day just for matching ETAs. Plus it adds 200-400ms latency per call.
Great: Precomputed ETA grid + real-time traffic adjustment + selective API calls.
Mechanism:
- Precomputed ETA grid: Divide city into H3 cells. Pre-calculate travel time between every pair of adjacent cells at different times of day (morning rush, afternoon, night). Store in Redis as a lookup table. Cost: one-time batch computation.
- On match request: Look up source cell β destination cell ETA from grid. Adjust by real-time traffic multiplier (from driver speed data in the last 5 minutes).
- Selective external API calls: Only call Google Maps / Mapbox for:
- Long trips (> 10km) where grid approximation is inaccurate
- Rides that cross city boundaries (precomputed grid doesnβt cover)
- Initial rider-facing ETA estimate (user-visible, accuracy matters)
- During ride: Update ETA every 30 seconds using remaining distance on route / average speed of last 2 minutes. No external API call needed.
Traffic adjustment from driver data:
- Every driver ping includes speed. Aggregate average speed per road segment per 5-minute window.
- If drivers on MG Road are averaging 8 km/h (instead of the usual 30 km/h), multiply ETA for routes through MG Road by 3.75x.
- This gives us live traffic data for free - from our own driver fleet.
Cost comparison:
- Naive (Google Maps for everything): ~$1500/day for a city with 100K rides/day
- Hybrid approach: ~$150/day (API calls only for 10% of rides) - 90% cost reduction
13. Design Self-Audit
| Question | Answer |
|---|---|
| Dedicated search index? | Not needed - riders donβt text-search for drivers. Geo-proximity is handled by Redis Geo |
| Stale reads after writes? | Driver availability is eventually consistent (3-5s lag from location ping frequency). Acceptable - matching retry handles it |
| Single points of failure? | Redis Geo shards have replicas with automatic failover. Matching Service is stateless, horizontally scaled. Ride DB uses Postgres primary + synchronous standby |
| Dead-letter / reconciliation? | Rides stuck in MATCHING > 60s are re-processed by reconciler. Failed notifications go to DLQ with 3 retries |
| Data freshness across caches? | Location in Redis has 15s TTL - stale drivers auto-expire. Ride status propagates via Kafka events (200-500ms lag) |
| Cost at scale? | Redis Geo (8 shards Γ r6g.large) β $2000/month. Kafka (6 brokers) β $3000/month. WebSocket Gateways (10 instances) β $2000/month. Google Maps API (reduced 90%) β $4500/month. Total hot-path infra: ~$12K/month for a city with 2M drivers |
14. Core Flows
Flow 1: Ride Request and Matching End-to-End
sequenceDiagram
participant Rider
participant GW as API Gateway
participant RS as Ride Service
participant MS as Matching Service
participant LS as Location Service
participant Redis as Redis Geo
participant NS as Notification Svc
participant Driver
Rider->>GW: POST /rides/request
GW->>GW: Auth + Rate Limit
GW->>RS: Create ride
RS->>RS: Persist ride (MATCHING)
RS->>MS: Find driver for ride
MS->>LS: GEORADIUS 3km from pickup
LS->>Redis: GEORADIUS query
Redis-->>LS: 15 nearby drivers
LS-->>MS: Candidate list
MS->>MS: Filter available + rank by ETA
MS->>RS: Assign driver (lock)
RS-->>Rider: 200 OK rideId + ETA
RS->>NS: Notify driver of offer
NS->>Driver: Push ride offer
alt Driver accepts within 15s
Driver->>RS: POST /rides/{id}/accept
RS->>RS: Status = DRIVER_ENROUTE
RS->>NS: Notify rider
NS->>Rider: Driver assigned + ETA
else Driver declines or timeout
RS->>MS: Try next driver
MS->>NS: Offer to next driver
end
Non-obvious failure path: What if the Matching Service crashes after locking a driver but before notifying them? The lock has a TTL of 20 seconds. If no acceptance arrives, the lock auto-releases and the driver becomes available again. The Ride Serviceβs reconciler detects rides stuck in MATCHING for > 30 seconds and re-triggers matching.
Flow 2: Real-Time Location Streaming to Rider
sequenceDiagram
participant Driver
participant LI as Location Ingestion
participant Redis as Redis Geo
participant Kafka
participant WSG as WebSocket Gateway
participant Rider
Driver->>LI: PUT /drivers/location (every 3-5s)
LI->>Redis: GEOADD update position
LI->>Kafka: Publish location event
Kafka->>WSG: Consume (partition: rideId)
WSG->>Rider: Push via WebSocket
Note over Rider: Map updates in real-time
loop Every 30 seconds
WSG->>WSG: Recalculate ETA
WSG->>Rider: Updated ETA
end
Non-obvious failure path: If the WebSocket connection drops (rider enters a tunnel), the gateway buffers the last 3 location updates. When the rider reconnects, it replays these so the map doesnβt jump. If disconnected > 60 seconds, the rider app falls back to polling GET /rides/{id}/status.
Ride Lifecycle State Machine
stateDiagram-v2
[*] --> MATCHING : Rider requests
MATCHING --> DRIVER_ENROUTE : Driver accepts
MATCHING --> NO_DRIVERS : All drivers decline
DRIVER_ENROUTE --> ARRIVED : Driver at pickup
ARRIVED --> TRIP_STARTED : Rider picked up
TRIP_STARTED --> COMPLETED : Arrived at destination
DRIVER_ENROUTE --> CANCELLED_RIDER : Rider cancels
DRIVER_ENROUTE --> CANCELLED_DRIVER : Driver cancels
ARRIVED --> CANCELLED_RIDER : Rider no-show
COMPLETED --> [*]
CANCELLED_RIDER --> [*]
CANCELLED_DRIVER --> [*]
NO_DRIVERS --> [*]
Each transition emits a Kafka event consumed by: Billing (fare calculation), Notifications (user updates), Analytics (supply-demand metrics), and the ETA service (recalculate).
15. Final Architecture
flowchart TB
RA(["Rider App"]):::client
DA(["Driver App"]):::client
GW["API Gateway<br>auth and rate limiting"]:::edge
RS["Ride Service<br>fare and ride lifecycle"]:::service
MS["Matching Service<br>picks and locks a driver"]:::service
LSV["Location Service<br>pings and proximity"]:::service
NS["Notification Service"]:::service
WSG["WebSocket Gateway<br>live tracking"]:::edge
KF[["Kafka<br>match queue"]]:::async
PG[("Postgres<br>rides and fares")]:::data
RG[("Redis Geo<br>driver locations")]:::data
RD[("Redis<br>driver lock")]:::data
MAPS[/"Maps API"/]:::external
RA -->|"request ride"| GW
DA -->|"send location"| GW
GW --> RS
GW --> LSV
RS --> PG
RS -->|"needs a driver"| KF
KF --> MS
MS -->|"who is nearby"| LSV
LSV --> RG
MS -->|"claim driver"| RD
MS -->|"offer ride"| NS
NS -->|"push"| DA
RS -->|"route and ETA"| MAPS
RS -->|"ride updates"| KF
KF --> WSG
WSG -->|"driver moved"| RA
classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
classDef edge fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0
classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
classDef async fill:#3b1f5e,stroke:#c084fc,color:#e2e8f0
classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
classDef external fill:#4a1942,stroke:#f472b6,color:#e2e8f0
How it works end-to-end (ride request path):
- Rider requests ride β hits Load Balancer β API Gateway β Ride Service
- Pricing Service calculates fare β checks Redis Cache for surge multiplier (H3 zone-based supply/demand)
- Matching Service finds driver β queries Location Service which searches Redis Geo shards for nearest available drivers
- Driver leased in Redis, assignment enforced in Postgres β
SET NXwith a TTL reserves the candidate; the DB constraint is what actually prevents double-assignment (see Deep Dive 2) - Offer sent to driver β Kafka event consumed by Notification Service, push via FCM/APNs and WebSocket
- Driver accepts β Ride Service persists to Postgres, ride state transitions to ACCEPTED
How it works end-to-end (live tracking path):
- Driver streams location β GPS pings sent to Location Ingestion, written to Redis Geo, events emitted to Kafka
- Kafka fans out to WebSocket Gateway β via Redis Pub/Sub, rider sees real-time movement on map
- ETA updated β ETA Service calls Maps API periodically, pushes updated arrival time through WebSocket
- A background sweeper catches the edge cases β expired locks, stale driver entries, and zombie rides
Want a deep dive on carpooling matching (UberPool), payment splitting, or driver incentive algorithms? Drop a comment below π
Key Technologies
| Term | What it is |
|---|---|
| Redis Geo | In-memory geospatial index using sorted sets with geohash encoding - handles 500K+ location writes/sec with sub-ms proximity queries. |
| Geohash | Encoding scheme that maps 2D coordinates into a 1D string where nearby points share a common prefix, enabling geographic sharding. |
| H3 Hexagonal Grid | Uberβs hierarchical hex grid system providing uniform-area cells for surge pricing zones and supply-demand balancing without edge distortion. |
| WebSocket | Persistent bidirectional connection streaming real-time driver location updates to riders during active rides. |
| Kafka | Event bus carrying ride lifecycle events and location streams, decoupling the write path from downstream consumers. |
| Distributed Lease | Redis SET NX with a TTL, reserving a driver for the length of the offer window. It is a de-duplication optimization, not the correctness boundary β a lease can expire while its holder is still working, so the unique constraint in Postgres is what enforces one active ride per driver. |
| ETA Service | Component that computes estimated arrival times using mapping APIs, used as the primary matching signal over raw distance. |
| Surge Pricing | Dynamic fare multiplier calculated per H3 zone based on real-time supply-demand ratio, incentivizing driver redistribution. |
Whatβs Expected at Each Level
This section helps you calibrate your depth. You donβt need to cover everything - just know whatβs expected for your level.
Mid-level
Produce a high-level design with ride request, driver matching, and trip management. Understand why proximity queries need a specialized store rather than vanilla Postgres. Propose Redis or similar for location data with prompting. You should be able to sketch the ride lifecycle state machine and explain why polling for βnearest driverβ doesnβt scale.
Senior
Drive the conversation around consistent hashing for geo-sharding, distributed locking for double-booking prevention, and WebSocket for live tracking. Articulate the fan-out strategy for location updates and the read/write asymmetry (500K writes/sec vs 50K match queries/sec). Proactively propose the TTL-based expiry pattern for stale driver entries without being asked.
Staff+
Discuss multi-region dispatch, surge pricing zone calculations using H3 hexagons, counter-based range allocation for IDs across regions, and graceful degradation during peak demand. Proactively address what happens when the matching service crashes mid-offer - explain lease expiry, why a lease is not a fencing token, where the real invariant lives (the DB constraint), and the reconciler pattern. Show cost awareness of Redis Geo at 2M drivers and articulate why Redlock is overkill here.
π― Key Takeaways
- Redis Geo with geohash sharding handles 500K location pings/sec
- A Redis lease de-duplicates offers; the Postgres unique constraint is what prevents double-booking β the invariant has to live in the store that owns the data
- WebSocket + Kafka for real-time location streaming to riders
- Surge pricing uses H3 hexagonal zones with supply/demand signals
Related Designs
- Zomato / Uber Eats - similar real-time dispatch and location tracking
- Notification System - multi-channel push delivery for ride updates
- Job Scheduler - distributed task scheduling for timeout handling
- Stock Broker - similar distributed locking and exactly-once patterns
Related Concepts
Understand the building blocks used in this design:
- Geospatial Indexing β β how driver-rider matching runs sub-50ms proximity queries with Redis Geo and geohash
- Distributed Locking β β locks a candidate driver during assignment so two riders never get the same car
- WebSockets vs SSE β β pushes live driver position to the riderβs map during an active ride
- Consistent Hashing β β shards driver locations across Redis instances by geohash region (Ringpop-style)
Discussion
Newest first