Design a Ride Matching System (Uber / Ola)
An end-to-end interview-ready walkthrough — from capacity math through deep dives on geospatial indexing, real-time location ingestion, matching, surge pricing, ETA, and the ride state machine. Structured to mirror a 45-minute system design interview.
Requirements
A ride-matching system is deceptively complex. On the surface it's "connect rider to driver" — but underneath it's a real-time geospatial system with sub-second latency targets, millions of concurrent location updates, a multi-state lifecycle, and dynamic pricing that must respond to demand in real time. Anchor the scope before drawing a single box.
Functional Requirements
Core business logic & features
- 01.Ride Request & MatchingRider requests a ride from pickup to destination. System finds the best nearby available driver and sends a ride offer.
- 02.Real-time Driver LocationTrack all active drivers' GPS positions in real time. Update every 3–5 seconds via persistent connections.
- 03.ETA CalculationShow estimated time of arrival for both pickup (driver → rider) and trip (rider → destination).
- 04.Surge PricingDynamically adjust pricing based on demand/supply ratio per geographic region.
- 05.Ride Lifecycle ManagementManage ride states: REQUESTED → MATCHED → ACCEPTED → EN_ROUTE → IN_PROGRESS → COMPLETED / CANCELLED.
- 06.Driver Acceptance FlowSend ride offer to driver with a timeout. On rejection or timeout, re-match to next best driver.
Non-Functional
System constraints
Matching Latency
Match rider to driver in < 1 second. The matching decision is the product experience.
Scale
10M rides/day globally. 5M concurrent active drivers sending location every 4 seconds.
Availability
99.99% for ride requests. Downtime means stranded riders and lost revenue.
Consistency
A driver must never be double-booked. Ride state transitions must be strongly consistent.
🎯 Clarifying questions worth asking
Each one changes the design:
- Single rider or ride-pooling? (pooling changes matching from 1:1 to N:1 — fundamentally different algorithm)
- How many vehicle types? (economy, premium, XL — affects supply segmentation)
- Global or single-city? (multi-region adds geo-routing and data residency)
- How accurate must ETA be? (road-graph routing vs straight-line approximation)
- Is surge pricing transparent? (riders see multiplier before confirming — affects UX flow)
In scope vs out of scope
| In Scope | Out of Scope | Why |
|---|---|---|
| Single-rider matching | Ride pooling (shared rides) | Pooling is a separate optimization problem — different matching algorithm entirely |
| Surge pricing (demand/supply) | ML-based dynamic pricing models | Interview scope — rule-based surge is sufficient to demonstrate the architecture |
| ETA estimation | Full turn-by-turn navigation | Navigation is a separate product (Google Maps API). We estimate, not navigate. |
| Driver acceptance + re-matching | Driver scheduling / shift management | Operational concern, not a distributed-systems one |
| Basic fraud detection | ML-based fraud scoring pipeline | Mention as a callout, don't design the ML system |
| Payment hold before matching | Full payment processing system | Payment is a separate bounded context — we just call a hold API |
💡 Interviewer signal
Candidates who say "I'll use a geospatial index" in the first minute lose credibility. State the constraints first: "matching must be under 1 second, location updates are 1M writes/sec, and a driver can never be double-booked" — that frames every decision that follows.
Back-of-Envelope Estimation
The numbers for a ride-matching system are dominated by one thing: location updates. Rides themselves are relatively low-volume (tens of thousands per second), but tracking millions of drivers in real time generates write traffic that dwarfs everything else. Derive every number out loud — interviewers reward the reasoning.
Traffic: Rides
Start with the headline number — 10M rides/day — and derive the per-second rates. Peak traffic is typically 3× average (rush hour in major cities), and each ride involves multiple API calls across its lifecycle.
Rides:
10M rides/day ÷ 86,400s ≈ 115 rides/sec (average)
Peak ≈ 3× average ≈ 350 rides/sec (rush hour)
Each ride triggers:
1 ride request
1 match attempt (may retry 2–3 times on timeout)
1 acceptance confirmation
~5 state transitions (requested → completed)
─────────────────────────────
~8 write operations per ride
Total ride-related writes:
350 × 8 = ~2,800 writes/sec at peak
Implication:
→ Ride writes are NOT the bottleneck. A single Postgres handles this.
→ The real pressure is location updates (next section).
Traffic: Location updates (the hot path)
This is where the system gets interesting. Every active driver sends a GPS update every 3–5 seconds. With 5M concurrent drivers, this creates a write volume that no single database can handle — it must be served from an in-memory geospatial index.
Active drivers: 5M concurrent (globally)
Update frequency: every 4 seconds (average)
Location writes:
5,000,000 ÷ 4 = 1,250,000 writes/sec (1.25M/sec)
Each update payload:
driver_id (8B) + lat (8B) + lng (8B) + timestamp (8B) + heading (4B) + speed (4B)
≈ 40 bytes per update
Bandwidth (ingress):
1.25M × 40B = 50 MB/sec ingress (just GPS data)
With WebSocket framing overhead: ~80 MB/sec
Implication:
→ 1.25M writes/sec is the defining constraint.
→ Must be in-memory (Redis Geo or custom spatial index).
→ Cannot be relational DB — Postgres maxes at ~50K writes/sec.
→ WebSocket connections: 5M persistent connections across the fleet.
Traffic: Matching queries (reads from the spatial index)
Every ride request triggers a geospatial query: "find the N nearest available drivers within R km of this pickup point." At 350 requests/sec peak, this is modest — but each query must complete in under 50ms to keep total matching latency under 1 second.
Matching queries:
350/sec at peak (one per ride request)
Each query: radius search within 3–5 km, return top 10 drivers
Target: < 50ms per query (leaves budget for ranking + offer)
Surge pricing queries:
Computed per geo-cell, updated every 30 seconds
~100K cells globally ÷ 30s = ~3,300 cell updates/sec
ETA queries:
~700/sec (one for pickup ETA + one for trip ETA per ride)
Each hits a road-graph service — heavier compute (~100ms)
Implication:
→ Spatial reads are low-volume but latency-critical.
→ Surge is a background computation, not on the hot path.
→ ETA is the slowest component — budget 100ms, parallelize with matching.
Storage
Ride-matching has two storage profiles: hot state (driver locations, active rides) that lives entirely in memory, and cold state (ride history, analytics) that grows linearly with rides completed.
Hot state (in-memory):
Driver locations: 5M × 40B = 200 MB
Driver metadata (availability, vehicle type): 5M × 200B = 1 GB
Active rides (state machine): ~500K concurrent × 500B = 250 MB
Total hot state: ~1.5 GB — fits in a single Redis instance
Cold state (persistent):
Ride history: 10M rides/day × 2KB per ride = 20 GB/day
Location traces (for disputes/analytics): 10M rides × 50 points × 40B = 20 GB/day
→ ~40 GB/day → ~15 TB/year
Implication:
→ Hot state is trivially small. Memory is not the constraint — write throughput is.
→ Cold storage needs time-partitioned tables (by month) with archival to S3.
WebSocket connections
Each active driver maintains a persistent connection for sending location updates and receiving ride offers. Managing 5M concurrent connections requires a fleet of connection servers.
Connections per server:
A well-tuned Linux box handles ~500K WebSocket connections
(with epoll, 64GB RAM, tuned file descriptors)
Fleet size:
5M connections ÷ 500K per box = 10 connection servers (minimum)
With 2× headroom for failover: 20 connection servers
Memory per connection:
~10KB (socket buffers + session state)
500K × 10KB = 5 GB per server — comfortable on 64GB boxes
Implication:
→ Connection servers are stateful (sticky sessions via driver_id hash).
→ Losing a server = 500K drivers temporarily disconnected → must reconnect.
→ Use consistent hashing for driver → server assignment.
🧮 The numbers that drive the design
Quick Revision Cheat Sheet
Ride requests (peak): ~350/sec
Location updates: ~1.25M writes/sec — THE bottleneck
Matching queries: ~350/sec, < 50ms each
WebSocket connections: 5M concurrent, 20 servers
Hot state (memory): ~1.5 GB total — trivially small
Cold storage (yearly): ~15 TB ride history + traces
Location payload: ~40 bytes per update
Bandwidth (location): ~80 MB/sec ingress
API Design
A ride-matching system has two distinct API surfaces: a REST API for ride lifecycle operations (request, cancel, complete) and a WebSocket API for real-time bidirectional communication (location streaming, ride offers, status updates). Define both before the HLD so every component has a concrete contract.
REST Endpoints
The REST surface covers all rider-initiated actions and driver responses. Each endpoint maps to a state transition in the ride lifecycle. Latency targets vary — requesting a ride must feel instant, while fetching ride history can tolerate more.
| Method | Path | Purpose | Latency target |
|---|---|---|---|
POST | /api/v1/rides | Request a ride (pickup, destination, vehicle type) | < 200ms p99 |
POST | /api/v1/rides/:rideId/accept | Driver accepts the ride offer | < 100ms p99 |
POST | /api/v1/rides/:rideId/cancel | Rider or driver cancels | < 100ms p99 |
POST | /api/v1/rides/:rideId/start | Driver starts the trip (rider picked up) | < 100ms p99 |
POST | /api/v1/rides/:rideId/complete | Driver completes the trip | < 100ms p99 |
GET | /api/v1/rides/:rideId | Get ride details and current status | < 50ms p99 |
GET | /api/v1/eta?pickup_lat=...&pickup_lng=... | Get ETA estimate before requesting | < 300ms p99 |
GET | /api/v1/surge?lat=...&lng=... | Get current surge multiplier for location | < 50ms p99 |
Ride request payload
The ride request is the most important endpoint — it kicks off the entire matching pipeline. The payload must include everything needed to compute pricing, find nearby drivers of the right type, and validate the rider's payment method before matching begins.
POST /api/v1/rides HTTP/1.1
Host: api.rideco.com
Authorization: Bearer <jwt>
Content-Type: application/json
Idempotency-Key: ride-req-8f4b3c-...
{
"pickup": { "lat": 12.9716, "lng": 77.5946 },
"destination": { "lat": 12.9352, "lng": 77.6245 },
"vehicle_type": "economy",
"payment_method_id": "pm_abc123",
"rider_id": "usr_xyz789"
}
--- 202 Accepted ---
{
"ride_id": "ride_a1b2c3",
"status": "MATCHING",
"surge_multiplier": 1.5,
"estimated_fare": { "min": 180, "max": 220, "currency": "INR" },
"estimated_pickup_eta_seconds": 240,
"created_at": "2026-05-15T09:12:00Z"
}
--- 402 Payment Required ---
{ "error": "PAYMENT_HOLD_FAILED", "message": "Card declined" }
--- 503 Service Unavailable ---
{ "error": "NO_DRIVERS_AVAILABLE", "message": "No drivers in your area" }
🔑 Why 202 Accepted (not 201 Created)
The ride isn't "created" in the traditional sense — it's entered a matching pipeline. The response returns immediately with a ride_id, but matching happens asynchronously. The rider receives real-time updates via WebSocket as the match progresses. This decouples the API response time from matching latency.
WebSocket API (real-time channel)
Both riders and drivers maintain persistent WebSocket connections for real-time communication. Drivers stream location updates upstream; the server pushes ride offers, status changes, and navigation hints downstream. This avoids the overhead of polling and gives sub-second delivery of critical events.
--- Driver → Server (upstream) ---
LOCATION_UPDATE:
{ "type": "location", "lat": 12.9716, "lng": 77.5946,
"heading": 45, "speed": 30, "ts": 1715760000 }
Frequency: every 3–5 seconds while online
DRIVER_STATUS:
{ "type": "status", "available": true }
Sent when driver goes online/offline or completes a ride
--- Server → Driver (downstream) ---
RIDE_OFFER:
{ "type": "ride_offer", "ride_id": "ride_a1b2c3",
"pickup": { "lat": 12.97, "lng": 77.59 },
"destination": { "lat": 12.93, "lng": 77.62 },
"fare_estimate": 200, "timeout_seconds": 15 }
RIDE_CANCELLED:
{ "type": "ride_cancelled", "ride_id": "ride_a1b2c3",
"reason": "rider_cancelled" }
--- Server → Rider (downstream) ---
MATCH_FOUND:
{ "type": "match_found", "ride_id": "ride_a1b2c3",
"driver": { "name": "Raj", "vehicle": "KA-01-AB-1234",
"rating": 4.8, "eta_seconds": 180 } }
DRIVER_LOCATION:
{ "type": "driver_location", "lat": 12.97, "lng": 77.59 }
Frequency: every 3 seconds while en route to pickup
STATUS_UPDATE:
{ "type": "status", "ride_id": "ride_a1b2c3",
"status": "EN_ROUTE", "ts": 1715760120 }
Driver acceptance flow
When the matching service finds a suitable driver, it sends a RIDE_OFFER via WebSocket with a 15-second timeout. The driver responds via REST (simpler for idempotency and retry handling). If the driver doesn't respond or rejects, the system automatically re-matches to the next candidate.
POST /api/v1/rides/ride_a1b2c3/accept HTTP/1.1
Authorization: Bearer <driver_jwt>
--- 200 OK ---
{
"ride_id": "ride_a1b2c3",
"status": "ACCEPTED",
"pickup": { "lat": 12.9716, "lng": 77.5946, "address": "MG Road Metro" },
"destination": { "lat": 12.9352, "lng": 77.6245 },
"rider": { "name": "Priya", "rating": 4.9 },
"fare_estimate": 200
}
--- 409 Conflict ---
{ "error": "RIDE_ALREADY_MATCHED", "message": "Another driver accepted first" }
--- 410 Gone ---
{ "error": "OFFER_EXPIRED", "message": "Acceptance timeout exceeded" }
💡 Why REST for acceptance, WebSocket for offers
Offers are time-critical and push-based — WebSocket is ideal. Acceptance is a state mutation that needs idempotency guarantees, conflict detection (409), and clear HTTP semantics. Mixing the two protocols plays to each one's strength. This is exactly how Uber's system works in production.
Data Model & State Machine
The data model splits cleanly into two worlds: hot state that lives in memory (driver locations, active ride state machines) and cold state that lives in a relational database (ride history, user profiles, payment records). Design for the access pattern first — the matching service needs sub-millisecond reads from the spatial index, while ride history queries can tolerate 10ms.
Ride state machine
Every ride is a finite state machine with well-defined transitions. Invalid transitions must be rejected — a ride cannot go from REQUESTED directly to COMPLETED. The state machine is the consistency boundary: all state transitions are serialized per ride to prevent race conditions (e.g., rider cancels while driver accepts).
REQUESTED ──→ MATCHING ──→ OFFERED ──→ ACCEPTED ──→ EN_ROUTE ──→ IN_PROGRESS ──→ COMPLETED
│ │ │ │ │ │
└── CANCELLED └── NO_MATCH └── EXPIRED └── CANCEL └── CANCELLED └── CANCELLED
(timeout)
Valid transitions:
REQUESTED → MATCHING (payment hold succeeded)
MATCHING → OFFERED (candidate driver found)
MATCHING → NO_MATCH (no drivers available after retries)
OFFERED → ACCEPTED (driver accepts within timeout)
OFFERED → EXPIRED (driver timeout → re-match)
ACCEPTED → EN_ROUTE (driver confirms heading to pickup)
EN_ROUTE → IN_PROGRESS (driver confirms rider picked up)
IN_PROGRESS → COMPLETED (driver confirms drop-off)
Any active state → CANCELLED (rider or driver cancels)
Invariant: exactly ONE active ride per driver at any time.
Rides table (PostgreSQL)
The rides table is the source of truth for ride lifecycle. It stores the full history of every ride including fare, route, and timestamps for each state transition. The status column uses a constrained enum to enforce valid states at the database level.
CREATE TYPE ride_status AS ENUM (
'REQUESTED', 'MATCHING', 'OFFERED', 'ACCEPTED',
'EN_ROUTE', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'NO_MATCH'
);
CREATE TABLE rides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rider_id UUID NOT NULL REFERENCES users(id),
driver_id UUID REFERENCES users(id), -- NULL until matched
status ride_status NOT NULL DEFAULT 'REQUESTED',
vehicle_type VARCHAR(20) NOT NULL,
pickup_lat DOUBLE PRECISION NOT NULL,
pickup_lng DOUBLE PRECISION NOT NULL,
dest_lat DOUBLE PRECISION NOT NULL,
dest_lng DOUBLE PRECISION NOT NULL,
surge_multiplier DECIMAL(3,2) NOT NULL DEFAULT 1.00,
fare_estimate INTEGER, -- in smallest currency unit
fare_final INTEGER, -- set on completion
payment_method_id VARCHAR(50) NOT NULL,
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
matched_at TIMESTAMPTZ,
started_at TIMESTAMPTZ, -- rider picked up
completed_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
cancel_reason VARCHAR(50),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Active rides for a driver (enforce single active ride)
CREATE UNIQUE INDEX idx_rides_active_driver
ON rides (driver_id)
WHERE status IN ('ACCEPTED', 'EN_ROUTE', 'IN_PROGRESS');
-- Rider's active ride lookup
CREATE INDEX idx_rides_rider_active
ON rides (rider_id, status)
WHERE status NOT IN ('COMPLETED', 'CANCELLED', 'NO_MATCH');
-- History queries (rider dashboard)
CREATE INDEX idx_rides_rider_history
ON rides (rider_id, requested_at DESC);
🔑 The partial unique index is the key insight
idx_rides_active_driver is a — it enforces at the database level that a driver can only have ONE active ride. Any attempt to assign a second ride to the same driver fails with a unique constraint violation — no application-level locking needed. This is the strongest guarantee against double-booking.
Driver location (Redis — hot state)
Driver locations are NOT stored in PostgreSQL. They change every 4 seconds — writing 1.25M updates/sec to a relational DB is impossible. Instead, locations live in sorted sets, which support both high-throughput writes and radius queries natively.
Key pattern: drivers:available:{vehicle_type}
Type: Sorted Set (Geo)
Members: driver_id
Score: geohash-encoded lat/lng
Commands used:
GEOADD drivers:available:economy <lng> <lat> <driver_id>
GEORADIUS drivers:available:economy <lng> <lat> 5 km COUNT 20 ASC
Supplementary hash for driver metadata:
HSET driver:{driver_id} status available vehicle_type economy
heading 45 speed 30 last_update 1715760000 rating 4.8
TTL strategy:
If no location update received in 30 seconds → driver considered offline.
Background job removes stale entries every 10 seconds.
Why this split (Postgres + Redis)
The two stores serve fundamentally different access patterns. Trying to use one for both would fail — Postgres can't handle 1.25M writes/sec, and Redis can't provide ACID transactions for ride state. The split is not optional; it's forced by the physics of the workload.
| Concern | Store | Why |
|---|---|---|
| Driver locations | Redis Geo (sorted set) | 1.25M writes/sec, radius queries, in-memory — only option at this throughput |
| Ride state machine | PostgreSQL | ACID transactions, partial unique indexes, audit trail — correctness matters |
| Driver metadata | Redis Hash | Sub-ms reads during matching (rating, vehicle type, heading) |
| Ride history | PostgreSQL (partitioned by month) | Relational queries for dashboards, disputes, analytics |
| Location traces | Object storage (S3) + metadata in Postgres | Write-once, read-rarely — too large for Postgres long-term |
Supporting tables
Beyond rides and locations, the system needs tables for users (riders + drivers), vehicles, and surge pricing snapshots. These are straightforward relational tables with standard indexing.
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
role VARCHAR(10) NOT NULL, -- 'rider' | 'driver'
name VARCHAR(100) NOT NULL,
phone VARCHAR(20) NOT NULL UNIQUE,
rating DECIMAL(2,1) DEFAULT 5.0,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE vehicles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
driver_id UUID NOT NULL REFERENCES users(id),
type VARCHAR(20) NOT NULL, -- 'economy', 'premium', 'xl'
plate VARCHAR(20) NOT NULL,
model VARCHAR(50),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE surge_snapshots (
id BIGSERIAL PRIMARY KEY,
cell_id VARCHAR(20) NOT NULL, -- S2 cell token
multiplier DECIMAL(3,2) NOT NULL,
demand INTEGER NOT NULL,
supply INTEGER NOT NULL,
computed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_surge_cell_time ON surge_snapshots (cell_id, computed_at DESC);
💡 Shard key choice (when sharding rides)
Shard by rider_id for the rides table — the primary access pattern is "show me my ride history." Active ride lookups by driver_id use the partial unique index on a single shard (since driver_id is known at query time, a scatter to the correct shard is acceptable for the low-volume acceptance path).
High-Level Architecture
The architecture splits into four independent paths, each with its own latency target, consistency model, and scaling axis. This separation is forced by the workload: location ingestion at 1.25M/sec cannot share infrastructure with ride state transitions that require ACID guarantees. Coupling them would mean a surge in location traffic degrades ride acceptance — unacceptable.
Path 1: Location Ingestion (write-heavy, fire-and-forget)
Drivers stream GPS coordinates every 3–5 seconds over persistent WebSocket connections. The connection servers receive these updates, validate them minimally, and write directly to the geospatial index. This path must handle 1.25M writes/sec with no backpressure to the driver app — a dropped update is acceptable (the next one arrives in 4 seconds), but blocking the connection is not.
Driver App
GPS every 4s
Connection Server
WebSocket termination
Location Service
Validate + route
Redis Geo
GEOADD (spatial index)
Redis Hash
Driver metadata
The connection servers are stateful — each driver is pinned to one server via on driver_id. The Location Service is stateless and horizontally scaled — it simply validates coordinates (reject impossible jumps like 500km in 4 seconds) and fans out the write to Redis.
Path 2: Matching (latency-critical, read from spatial index)
When a rider requests a ride, the Matching Service queries the geospatial index for nearby available drivers, ranks them by ETA and rating, and sends a ride offer to the best candidate. The entire pipeline — from request to offer sent — must complete in under 1 second. This is the product experience.
Rider App
Request ride
API Gateway
Auth + rate limit
Ride Service
Create ride record
Matching Service
Find + rank drivers
Redis Geo
GEORADIUS query
Offer via WS
Push to driver
The Matching Service is the brain. It reads from Redis Geo (spatial query), enriches with driver metadata (Redis Hash), computes ETA (road-graph service), applies business rules (driver acceptance rate, rating threshold), and selects the winner. If the chosen driver rejects or times out, it retries with the next candidate — up to 3 attempts before returning NO_MATCH.
Path 3: Ride Lifecycle (strongly consistent, low volume)
Every state transition (accept, start, complete, cancel) is a write to PostgreSQL with ACID guarantees. This path handles ~2,800 writes/sec at peak — well within a single Postgres primary. The critical invariant: a driver can never be assigned two rides simultaneously. The partial unique index enforces this at the DB level.
Driver/Rider
State action
API Gateway
Auth
Ride Service
Validate transition
PostgreSQL
UPDATE + constraint
Notification
Push status to other party
After each state transition, the Ride Service publishes an event to for downstream consumers: billing (trigger payment on completion), analytics (ride metrics), driver scoring (acceptance rate), and fraud detection.
Path 4: Surge & ETA (async computation, eventually consistent)
Surge pricing and ETA are computed asynchronously and cached. Surge runs every 30 seconds per geo-cell, comparing demand (ride requests) to supply (available drivers). ETA uses a road-graph service that pre-computes travel times. Neither blocks the matching path — they provide pre-computed values that matching reads from cache.
Demand Counter
Requests per cell
Supply Counter
Drivers per cell
Surge Calculator
Ratio → multiplier
Redis Cache
surge:{cell_id}
Component responsibilities
Each component has a single, clear job. If you can't describe what it does in one sentence, it's doing too much.
Connection Servers
Terminate 5M WebSocket connections. Route location updates to Location Service. Push ride offers and status updates to drivers/riders. Stateful — sticky by driver_id.
Location Service
Validate GPS updates (reject impossible jumps). Write to Redis Geo + Hash. Stateless, scaled on throughput. Handles 1.25M writes/sec.
Matching Service
Find nearby drivers (GEORADIUS), rank by ETA + rating, send offer, handle timeout/rejection/re-match. The core business logic.
Ride Service
Owns the ride state machine. Validates transitions, writes to Postgres, publishes events to Kafka. Enforces the no-double-booking invariant.
Surge Service
Computes demand/supply ratio per S2 cell every 30 seconds. Writes multipliers to Redis. Read by Matching Service and rider app.
ETA Service
Computes road-distance travel time using a weighted graph (road network + live traffic). Pre-computes popular routes. ~100ms per query.
PostgreSQL
Source of truth for rides, users, vehicles. ACID transactions for state changes. Partial unique index prevents double-booking.
Redis Cluster
Geo sorted sets for driver locations. Hashes for driver metadata. Cached surge multipliers. The hot-path data store — all matching reads come here.
Why four separate paths matter
The location ingestion SLA (handle 1.25M writes/sec without backpressure) is incompatible with the ride lifecycle SLA (ACID transactions, never lose a state change). Coupling them means a Redis hiccup during location writes could block ride acceptance — stranding a rider who already has a matched driver.
- Consistency model — location is best-effort (lose one update, next arrives in 4s); ride state is CP (never lose a transition)
- SLO — location: 99.9% (brief gaps acceptable); matching: 99.99%; ride state: 99.99%; surge: 99%
- Scaling axis — location scales on write throughput; matching on query latency; ride state on transaction rate; surge on compute
- Failure isolation — Redis down? Matching degrades but active rides continue. Postgres down? New rides fail but location tracking continues.
Total budget: 1000ms (1 second)
0ms ─ API Gateway auth + rate limit check
50ms ─ Ride Service: create ride record in Postgres
80ms ─ Payment Service: hold authorization (async, non-blocking)
100ms ─ Matching Service: GEORADIUS query (Redis) → top 10 drivers
150ms ─ Matching Service: fetch driver metadata (Redis HMGET, pipelined)
200ms ─ ETA Service: compute pickup ETA for top 5 candidates
400ms ─ Matching Service: rank candidates, select winner
450ms ─ Send RIDE_OFFER to driver via WebSocket
───────────────────────────────────
Total: ~450ms ✅ (well under 1s budget)
Remaining 550ms: buffer for driver acceptance timeout handling
💡 What to say to the interviewer
"The four-path split lets us pick different consistency and availability targets per path. Location is AP best-effort, ride state is CP with ACID, matching is latency-optimized with degradation fallbacks, and surge is eventually consistent with 30-second staleness." That single sentence signals senior-level thinking.
Geospatial Indexing
How do we handle frequent driver location updates and efficient proximity searches on location data? This is the hardest infrastructure problem in the entire system, and our high-level design from the previous section doesn't solve it yet. There are two fundamental problems we need to address:
The two problems with the naive approach
Problem 1: High frequency of writes. With 5 million active drivers sending GPS updates every 4 seconds, that's ~1.25 million writes per second. Whether you choose DynamoDB or PostgreSQL (both great choices for ride state), neither can handle this write volume at reasonable cost. PostgreSQL maxes out at ~50K writes/sec on a single primary. DynamoDB could technically handle it, but at on-demand pricing ($1.25 per million WRUs), 1.25M writes/sec of ~100 bytes each would cost over $135,000 per day — $49M per year just for location storage. This is a non-starter for any company.
Problem 2: Query efficiency. Without spatial indexing, finding nearby drivers requires a full table scan — calculating the distance between the rider's location and every driver's location. With 5 million rows, that's 5 million distance calculations per ride request. Even with B-tree indexes on lat/lng columns, traditional indexes are not designed for multi-dimensional proximity queries. A B-tree can efficiently find "lat between X and Y" but cannot efficiently answer "all points within 5km of this coordinate" without scanning a large range and post-filtering.
🎯 These two problems define the section
Everything that follows is about solving these two constraints simultaneously: handle 1.25M writes/sec AND answer radius queries in under 50ms. The solution must do both — solving one without the other is insufficient.
Bad Solution: Direct database writes + proximity queries
The naive approach — what we have coming out of the high-level design — writes each driver's location update directly to PostgreSQL or DynamoDB as it arrives, and performs proximity searches on this raw data. This is what most candidates propose initially, and it fails on both dimensions:
- Write throughput: 1.25M inserts/sec overwhelms any relational database. Even with connection pooling and batching, Postgres can't sustain this.
- Query latency: Full table scans or poorly-indexed range queries take seconds, not milliseconds. Matching becomes unusable.
- Cost: DynamoDB at this scale costs $135K+/day. Postgres would need dozens of shards with custom routing logic.
- Storage waste: Writing every 4-second update to durable storage creates massive tables of ephemeral data that's stale within seconds.
This approach would lead to system overload, high latency, and poor user experience. It's unsuitable for any ride-sharing application at scale.
Good Solution: Batch processing + specialized geospatial database
Instead of writing each location update directly, aggregate updates over a short interval (e.g., 2 seconds) and batch-process them. This reduces write operations by 4–8×. For proximity searches, use a specialized geospatial database with appropriate indexing — PostgreSQL with the PostGIS extension supports spatial indexes (R-trees) that can answer "find all points within 5km" efficiently.
PostGIS uses indexes that partition space hierarchically, allowing proximity queries to prune large portions of the dataset without scanning every row. A query like ST_DWithin(driver_location, pickup_point, 5000) uses the spatial index to return results in milliseconds rather than seconds.
Challenges: The batch interval introduces staleness — a 2-second batch means driver positions can be up to 2 seconds old. At 30 km/h city speed, that's ~17 meters of drift. More critically, even with batching, 1.25M/4 = ~312K writes/sec still exceeds what a single PostGIS instance can handle. You'd need multiple shards with geographic partitioning, adding significant operational complexity.
Great Solution: Real-time in-memory geospatial data store (chosen)
We can address all limitations of the previous solutions by using an in-memory data store like , which supports geospatial data types and commands natively. This handles real-time location updates AND proximity searches with high throughput and low latency, while minimizing storage costs through automatic data overwriting.
Redis uses internally to encode lat/lng into a 52-bit integer score within a . Each driver is a member associated with their geohash score. GEOADD overwrites the previous position on every update — so we always have the most recent location without accumulating stale data. No batch processing needed. GEOSEARCH (or the older GEORADIUS) finds all members within a given radius, returning results sorted by distance in sub-millisecond time.
For stale data from drivers who go offline, we run a periodic cleanup process every 10 seconds that removes drivers whose last update timestamp exceeds 30 seconds. One approach: maintain a companion sorted set keyed by timestamp, and periodically remove entries older than the threshold from both the timestamp set and the geo set.
Challenges — durability: Redis is in-memory, so there's a risk of data loss on crash. However, this risk is mitigated by:
- Redis persistence ( ): Enable RDB snapshots or Append-Only File to periodically save state to disk.
- Redis Sentinel / Cluster: Automatic failover promotes a replica to primary if the master goes down.
- Ephemeral data advantage: Even with total data loss, drivers send fresh updates every 4 seconds. The system fully recovers within ~30 seconds as all active drivers re-populate the index. This is fundamentally different from losing ride state (which would be catastrophic) — location data is inherently self-healing.
💡 Why this is the 'great' solution
Redis Geo solves both problems simultaneously: 1.25M GEOADD/sec is well within Redis's throughput ceiling (~500K ops/sec per node, sharded across 3 nodes), and GEOSEARCH returns sorted results in <1ms. No batching delay, no spatial index maintenance, no expensive database writes. The data is ephemeral by nature — perfect for an in-memory store.
Now that we've established Redis Geo as the storage layer, the remaining question is: what spatial cell system do we use for region management (surge pricing, supply counting, city sharding)? This is where the choice between GeoHash, QuadTree, S2, and H3 matters.
Option A: GeoHash (for region management)
GeoHash encodes a latitude/longitude pair into a string by interleaving the bits of each coordinate and base32-encoding the result. Nearby points share a common prefix — so "find nearby drivers" becomes a prefix scan on a sorted index. A 6-character GeoHash covers a ~1.2km × 0.6km cell.
The appeal is simplicity: store drivers keyed by their GeoHash prefix, and a radius query becomes "scan this cell and its 8 neighbors." But GeoHash has a fundamental flaw for ride matching: edge effects. Two points 10 meters apart can have completely different GeoHash prefixes if they straddle a cell boundary. This means a driver 50m away might not appear in your query because they're in an adjacent cell you forgot to check.
function findNearbyDrivers(lat: number, lng: number, radiusKm: number) {
const centerHash = geohash.encode(lat, lng, precision=6); // ~1.2km cell
const neighbors = geohash.neighbors(centerHash); // 8 adjacent cells
const cellsToSearch = [centerHash, ...neighbors]; // 9 cells total
const drivers: Driver[] = [];
for (const cell of cellsToSearch) {
// Prefix scan on sorted index
drivers.push(...await redis.zrangebylex(`drivers:geo`, `[${cell}`, `[${cell}\xff`));
}
// Post-filter: actual distance check (GeoHash cells are rectangular, not circular)
return drivers.filter(d => haversine(lat, lng, d.lat, d.lng) <= radiusKm);
}
// Problem: 9-cell search misses drivers in cells beyond immediate neighbors
// for larger radii. Must expand to 25+ cells for 5km radius.
❌ Why GeoHash alone is insufficient
For small radii (500m), 9-cell search works. But ride matching needs 3–5km radius in suburban areas. That requires searching 25+ cells, each with a separate index scan. The variable cell sizes at different precisions also make it hard to tune — too coarse and you scan too many drivers, too fine and you miss nearby ones across boundaries.
Option B: QuadTree
A QuadTree recursively divides 2D space into four quadrants. Each node splits when it contains more than N points (e.g., 100 drivers). Dense areas (city centers) get deeply subdivided; sparse areas (highways) stay as large cells. A radius query traverses the tree, pruning branches that don't intersect the search circle.
QuadTrees are excellent for static or slowly-changing point sets. The problem for ride matching: drivers move every 4 seconds. Each location update requires removing the driver from its current leaf node and inserting into a new one — potentially triggering node splits or merges. At 1.25M updates/sec, the tree is constantly being restructured, creating lock contention and cache invalidation.
Update operation:
1. Find current leaf containing driver_id → O(log N) tree traversal
2. Remove driver from leaf → O(1) but may trigger merge
3. Find new leaf for updated coordinates → O(log N) tree traversal
4. Insert driver into new leaf → O(1) but may trigger split
At 1.25M updates/sec:
→ 2.5M tree traversals/sec (find old + find new)
→ Frequent splits/merges in dense areas (airports, stations)
→ In-memory tree must be sharded — but spatial queries cross shard boundaries
Verdict: works for static data (POI search), too expensive for real-time moving points.
❌ Why QuadTree fails at this update rate
QuadTrees optimize for query performance at the cost of update performance. With 1.25M updates/sec, the tree restructuring overhead dominates. You'd need complex concurrent data structures (lock-free QuadTrees) that add engineering complexity without clear benefit over simpler approaches.
Option C: S2 Geometry Library (chosen)
Google's library divides the Earth into hierarchical cells at 30 levels. Level 12 cells are ~3.3km² (ideal for ride matching). Unlike GeoHash, S2 cells are roughly equal-area everywhere on Earth and have no edge discontinuities — a circle query returns a compact set of cells that perfectly covers the search area.
The key insight: S2 cells are just 64-bit integers. A radius query becomes "find all drivers whose S2 cell ID falls within this set of cell ranges." This maps perfectly to Redis sorted set range queries or simple hash lookups. Updates are O(1) — compute the new cell ID from lat/lng and update the sorted set member.
import { S2CellId, S2LatLng, S2RegionCoverer } from 's2-geometry';
// On location update: O(1) — just compute cell and GEOADD
function onLocationUpdate(driverId: string, lat: number, lng: number) {
const cellId = S2CellId.fromLatLng(S2LatLng.fromDegrees(lat, lng)).parent(12);
const cellToken = cellId.toToken(); // e.g., "3858f62"
// Update driver's position in the geo sorted set
await redis.geoadd('drivers:available:economy', lng, lat, driverId);
// Also track which S2 cell they're in (for surge counting)
await redis.hset(`driver:${driverId}`, 'cell', cellToken, 'lat', lat, 'lng', lng);
}
// On ride request: find drivers within radius
function findNearbyDrivers(pickupLat: number, pickupLng: number, radiusKm: number) {
// Redis GEORADIUS handles the spatial query natively
const candidates = await redis.georadius(
'drivers:available:economy',
pickupLng, pickupLat,
radiusKm, 'km',
'COUNT', 20, 'ASC', 'WITHCOORD', 'WITHDIST'
);
return candidates; // Already sorted by distance ascending
}
// S2 cells used for surge pricing (count drivers per cell)
function getDriversInCell(cellToken: string): number {
// Scan drivers hash for matching cell — or maintain a counter per cell
return await redis.get(`supply:${cellToken}`) ?? 0;
}
✅ Why S2 + Redis Geo wins
S2 gives us equal-area cells for surge pricing and region management. Redis Geo gives us O(log N) radius queries with built-in distance sorting. The combination handles both the 1.25M writes/sec (GEOADD is O(log N) in a sorted set) and the 350 radius queries/sec (GEORADIUS is O(N+log M) where N is results and M is total members). No custom data structure needed.
Option D: H3 (Uber's hexagonal grid)
Uber developed specifically for ride matching. Hexagonal cells have a key advantage: every neighbor is equidistant from the center (unlike square cells where diagonal neighbors are √2× farther). This makes "find all cells within N rings" produce a more uniform coverage than GeoHash or S2.
H3 is a valid choice — Uber uses it in production. For an interview, either S2 or H3 is acceptable. The trade-off: H3 has better neighbor uniformity but a smaller ecosystem and fewer database integrations. S2 has broader support (BigQuery, Postgres via PostGIS, native Redis Geo compatibility).
Head-to-head comparison
Each approach makes different trade-offs between update cost, query accuracy, and implementation complexity. The right choice depends on your write volume and query patterns.
| Approach | Update cost | Query accuracy | Best for |
|---|---|---|---|
| GeoHash | O(1) — recompute prefix | Edge effects at boundaries; needs neighbor expansion | Simple proximity search with small radius |
| QuadTree | O(log N) — remove + reinsert + possible restructure | Excellent — adaptive to density | Static POI data, not real-time moving points |
| S2 Geometry (chosen) | O(1) — compute cell ID from lat/lng | Equal-area cells, no edge discontinuities | Real-time moving points + surge pricing cells |
| H3 (hexagonal) | O(1) — compute hex index | Best neighbor uniformity (equidistant) | Uber-scale ride matching (production-proven) |
💡 What to tell the interviewer
"I'm choosing S2 cells for region management and surge pricing, combined with Redis GEORADIUS for the actual proximity query. S2 gives me equal-area cells for fair demand/supply counting. Redis Geo gives me sub-millisecond radius queries without building a custom spatial index. H3 is equally valid — Uber uses it — but S2 has broader ecosystem support."
Driver Location Ingestion
This is the hot write path — 1.25M GPS updates per second flowing from driver phones into the geospatial index. Every design choice here optimizes for throughput and resilience over consistency. A lost location update is invisible (the next one arrives in 4 seconds); a blocked connection server is catastrophic (500K drivers go dark).
Ingestion flow
The flow is deliberately simple — minimal processing between the driver's phone and the spatial index. Each step is designed to never block: validation is CPU-only (no I/O), the Redis write is fire-and-forget with pipelining, and failures are absorbed silently.
WebSocket frame received
Connection server receives a binary-encoded GPS frame (40 bytes). Deserialize: driver_id, lat, lng, heading, speed, timestamp. No auth check per frame — auth happened at connection establishment.
Validate coordinates
Reject impossible updates: lat/lng outside valid range, speed > 200 km/h, distance from last known position implies teleportation (> 500m in 4 seconds at reported speed 0). Log anomalies for fraud detection but don't block.
Update geospatial index
GEOADD to Redis Geo sorted set (drivers:available:{vehicle_type}). Also HSET driver metadata (heading, speed, last_update timestamp). Both commands pipelined in a single round-trip.
Update supply counter
Increment the per-cell supply counter (supply:{cell_token}) used by surge pricing. This is a simple INCR on a Redis key with a 60-second TTL — if it expires, the surge calculator treats supply as zero for that cell.
Emit to Kafka (async, sampled)
For analytics and trip reconstruction, emit 1-in-10 location updates to a Kafka topic. Full-rate emission (1.25M/sec) would overwhelm Kafka — sampling at 10% gives 125K/sec which is manageable and sufficient for post-ride trace reconstruction.
The ingestion handler
The code is intentionally minimal. No database calls, no complex business logic, no blocking I/O beyond the Redis pipeline. The entire handler should complete in under 1ms per update.
async function handleLocationUpdate(update: LocationUpdate): Promise<void> {
// 1. Validate (CPU-only, no I/O)
if (!isValidCoordinate(update.lat, update.lng)) return;
if (isImpossibleJump(update)) {
metrics.anomalousUpdates.inc();
return; // Silent drop — don't block the connection
}
// 2. Compute S2 cell for surge tracking
const cellToken = computeS2Cell(update.lat, update.lng, level=12);
// 3. Pipeline Redis commands (single round-trip)
const pipeline = redis.pipeline();
pipeline.geoadd(
`drivers:available:${update.vehicleType}`,
update.lng, update.lat, update.driverId
);
pipeline.hset(`driver:${update.driverId}`, {
lat: update.lat,
lng: update.lng,
heading: update.heading,
speed: update.speed,
cell: cellToken,
lastUpdate: update.timestamp,
});
pipeline.incr(`supply:${cellToken}`);
pipeline.expire(`supply:${cellToken}`, 60);
await pipeline.exec(); // Fire — don't check individual results
// 4. Sample to Kafka for analytics (1 in 10)
if (update.timestamp % 10 === 0) {
kafkaProducer.produce('driver-locations', update.driverId, update);
}
}
Connection management
Managing 5M persistent WebSocket connections is a distributed systems problem in itself. Each connection server handles ~500K connections. Drivers are assigned to servers via consistent hashing on driver_id — this ensures reconnections after a server restart land on the same server (preserving any in-memory session state).
| Concern | Strategy | Why |
|---|---|---|
| Server assignment | Consistent hash(driver_id) → server | Predictable routing; only ~1/N drivers move on server add/remove |
| Heartbeat / keepalive | Server pings every 30s; client responds within 5s | Detect dead connections (phone lost signal, app killed) |
| Stale driver cleanup | No update in 30s → remove from Redis Geo + mark offline | Prevents matching to drivers who are actually offline |
| Reconnection | Client exponential backoff (1s, 2s, 4s, max 30s) | Prevents thundering herd after server restart |
| Graceful shutdown | Server sends GOAWAY, waits 10s for clients to reconnect elsewhere | Zero-downtime deploys without mass disconnection |
Battery and network optimization
Driver phones have limited battery and often poor network connectivity. The update frequency must balance freshness (matching accuracy) against resource consumption. Uber's production system uses adaptive frequency based on driver state.
Driver state → Update frequency:
ON_TRIP (carrying rider): every 2 seconds (rider sees live tracking)
AVAILABLE (waiting for ride): every 4 seconds (matching needs fresh data)
EN_ROUTE (heading to pickup): every 2 seconds (rider sees approach)
IDLE (app open, not available): every 30 seconds (just presence check)
BACKGROUND (app minimized): every 60 seconds (battery preservation)
Bandwidth per driver:
Available: 40B × (1/4s) = 10 B/s = 864 KB/day
On trip: 40B × (1/2s) = 20 B/s = 1.7 MB/day
Battery impact:
GPS polling every 4s: ~5% battery/hour (acceptable)
GPS polling every 1s: ~12% battery/hour (too aggressive)
→ 4-second interval is the industry sweet spot
🔥 What happens when Redis is briefly unavailable
Connection servers buffer updates in a local ring buffer (last 1000 per driver). When Redis recovers, they replay the latest position for each driver — not the full history. Matching degrades for ~30 seconds (stale positions) but never fails completely. Active rides continue unaffected because ride state lives in Postgres, not Redis.
💡 Interview insight: why not Kafka for location ingestion?
Kafka adds 5–10ms latency per message. For location updates that feed real-time matching, that latency is wasted — the matching service needs the latest position, not a durable log of all positions. Redis gives sub-millisecond writes and reads. Kafka is used downstream for analytics (sampled) and trip reconstruction, not on the hot path.
Matching Algorithm
Matching is the core product logic — the decision that determines whether a rider waits 3 minutes or 10. The algorithm must balance multiple competing objectives: minimize pickup time, maximize driver utilization, respect driver preferences, and handle the race condition where multiple riders compete for the same driver simultaneously.
The matching pipeline
Matching is not a single query — it's a multi-stage pipeline that progressively narrows candidates from thousands of nearby drivers to the single best match. Each stage filters or ranks, and the pipeline must complete end-to-end in under 500ms.
Spatial query: find candidates
GEORADIUS on Redis — find top 20 available drivers within 5km of pickup, sorted by distance ascending. If fewer than 5 results, expand radius to 8km. If still empty, return NO_MATCH.
Filter: availability and eligibility
Remove drivers who: are already being offered another ride (offer_lock), have declined this rider before, are in a different vehicle class, or have been reported for safety issues. Typically removes 20–30% of candidates.
Enrich: fetch driver metadata
Batch HMGET from Redis for each candidate: rating, acceptance_rate, heading (are they driving toward or away from pickup?), current speed. Single pipelined Redis call — ~1ms for 15 drivers.
ETA computation
For top 5 candidates (by straight-line distance), compute actual road-distance ETA via the ETA service. This is the most expensive step (~50ms per batch). Parallelized across candidates.
Score and rank
Compute a composite score for each candidate combining ETA (60% weight), driver rating (20%), acceptance rate (15%), and heading alignment (5%). Select the highest-scoring driver.
Lock and offer
Set a Redis lock (offer_lock:{driver_id}, TTL 20s) to prevent concurrent offers to the same driver. Send RIDE_OFFER via WebSocket. Start a 15-second acceptance timer.
Scoring function
The scoring function is the heart of matching quality. Pure nearest-driver matching (Uber's original approach) is simple but suboptimal — a driver 500m away driving in the opposite direction has a higher actual ETA than a driver 1km away heading toward the pickup. The composite score captures this nuance.
interface MatchCandidate {
driverId: string;
distanceKm: number;
etaSeconds: number;
rating: number; // 1.0 – 5.0
acceptanceRate: number; // 0.0 – 1.0
headingAlignment: number; // 0.0 – 1.0 (1 = driving toward pickup)
}
function computeMatchScore(candidate: MatchCandidate): number {
// Normalize ETA to 0–1 (lower ETA = higher score)
// Max acceptable ETA: 600 seconds (10 min)
const etaScore = Math.max(0, 1 - candidate.etaSeconds / 600);
// Rating normalized to 0–1
const ratingScore = (candidate.rating - 1) / 4; // 1–5 → 0–1
// Acceptance rate already 0–1
const acceptScore = candidate.acceptanceRate;
// Heading alignment already 0–1
const headingScore = candidate.headingAlignment;
// Weighted composite
return (
etaScore * 0.60 +
ratingScore * 0.20 +
acceptScore * 0.15 +
headingScore * 0.05
);
}
// Heading alignment: cosine of angle between driver's heading and
// bearing from driver to pickup. 1.0 = driving straight toward pickup.
function computeHeadingAlignment(
driverHeading: number,
driverLat: number, driverLng: number,
pickupLat: number, pickupLng: number
): number {
const bearingToPickup = computeBearing(driverLat, driverLng, pickupLat, pickupLng);
const angleDiff = Math.abs(driverHeading - bearingToPickup);
const normalized = angleDiff > 180 ? 360 - angleDiff : angleDiff;
return Math.max(0, 1 - normalized / 180); // 0° diff = 1.0, 180° diff = 0.0
}
The race condition: concurrent matching
The hardest problem in matching isn't finding the nearest driver — it's handling the case where two riders request at the same moment and both want the same driver. Without coordination, both matching instances select the same winner, both send offers, and one rider gets a delayed re-match.
The solution is an via Redis: before sending an offer, the matching service attempts to (set-if-not-exists) and a 20-second TTL. If the SET fails, the driver is already being offered another ride — skip to the next candidate. This is cheaper than a distributed lock because conflicts are rare (most drivers aren't being simultaneously targeted).
async function tryOfferToDriver(driverId: string, rideId: string): Promise<boolean> {
// Attempt to acquire offer lock (NX = set-if-not-exists, EX = TTL)
const acquired = await redis.set(
`offer_lock:${driverId}`,
rideId,
'NX', 'EX', 20 // 20-second TTL (15s offer + 5s buffer)
);
if (!acquired) {
// Another ride is already being offered to this driver
metrics.offerConflicts.inc();
return false;
}
// Send offer via WebSocket
await connectionServer.pushToDriver(driverId, {
type: 'ride_offer',
rideId,
timeout: 15,
});
return true;
}
// On acceptance or timeout, release the lock
async function releaseOfferLock(driverId: string): Promise<void> {
await redis.del(`offer_lock:${driverId}`);
}
Re-matching on timeout or rejection
When a driver rejects or the 15-second timer expires, the matching service must quickly find the next best candidate. It doesn't restart from scratch — it maintains a ranked candidate list from the original query and moves to the next one. After 3 failed attempts, it re-queries the spatial index (positions may have changed) for a fresh candidate set.
| Scenario | Action | Latency impact |
|---|---|---|
| Driver accepts within 15s | Transition ride to ACCEPTED, notify rider | Total: 450ms (match) + 0–15s (acceptance) = best case ~1s |
| Driver rejects immediately | Release lock, offer to next candidate from ranked list | +200ms per retry (no re-query needed) |
| Driver times out (15s) | Release lock, offer to next candidate | +15s per timeout — rider sees 'finding another driver' |
| 3 consecutive failures | Re-query spatial index (fresh positions), new candidate set | +500ms for fresh query — positions may have improved |
| No candidates after re-query | Expand radius from 5km → 8km → 12km | +500ms per expansion — last resort before NO_MATCH |
| All attempts exhausted | Return NO_MATCH, refund payment hold, notify rider | Total elapsed: up to 60s — rider sees 'no drivers available' |
🎯 Why not batch-match (Hungarian algorithm)?
Batch matching (collect all requests in a window, solve the optimal assignment globally) gives theoretically better results but adds 5–10 seconds of latency while the batch accumulates. For ride-hailing, immediate matching with local optimization beats globally-optimal matching with delay. Riders expect a response in seconds, not minutes. Uber moved away from batch matching early on for exactly this reason.
💡 Interview tip: mention the cold-start problem
When a new driver signs up with zero rides, their acceptance_rate is undefined and rating is default 5.0. The scoring function should use Bayesian priors — assume average acceptance rate until they have 20+ offers, then use actual data. This prevents new drivers from being unfairly penalized or over-promoted.
Surge Pricing
Surge pricing is the market-making mechanism that balances supply and demand in real time. When demand spikes (concert ends, rain starts), prices rise to incentivize more drivers to come online and to reduce frivolous requests. When supply exceeds demand, prices drop to baseline. The system must compute multipliers per geographic cell every 30 seconds without affecting matching latency.
How surge is computed
The city is divided into S2 level-12 cells (~3.3km² each). For each cell, the Surge Service counts demand (ride requests in the last 2 minutes) and supply (available drivers currently in that cell). The ratio determines the multiplier. This runs as a background job every 30 seconds — it never blocks the matching path.
Ride Requests
Count per cell (2-min window)
Driver Locations
Count per cell (live)
Surge Calculator
demand/supply → multiplier
Redis Cache
surge:{cell_id} with 60s TTL
Rider App
Shows multiplier before confirm
The multiplier formula
The core formula is simple: demand/supply ratio mapped to a multiplier via a piecewise function. But raw ratios are noisy — a cell with 2 requests and 1 driver shouldn't surge to 2×. The formula includes minimum thresholds and smoothing to prevent oscillation.
interface CellMetrics {
cellId: string;
demand: number; // ride requests in last 2 minutes
supply: number; // available drivers in cell right now
}
function computeSurgeMultiplier(metrics: CellMetrics): number {
const { demand, supply } = metrics;
// Minimum thresholds — don't surge on tiny numbers
if (demand < 5 || supply < 2) return 1.0;
const ratio = demand / supply;
// Piecewise multiplier function
if (ratio <= 1.0) return 1.0; // supply >= demand → no surge
if (ratio <= 1.5) return 1.2; // mild imbalance
if (ratio <= 2.0) return 1.5; // moderate
if (ratio <= 3.0) return 2.0; // high demand
if (ratio <= 5.0) return 2.5; // very high
return 3.0; // cap at 3× (regulatory/PR limit)
}
// Smoothing: new multiplier is weighted average of current and previous
function smoothedMultiplier(current: number, previous: number): number {
const SMOOTHING_FACTOR = 0.7; // 70% new, 30% old
return current * SMOOTHING_FACTOR + previous * (1 - SMOOTHING_FACTOR);
}
// The full computation loop (runs every 30 seconds)
async function computeAllCells(): Promise<void> {
const activeCells = await getActiveCells(); // cells with any demand or supply
for (const cellId of activeCells) {
const demand = await redis.get(`demand:${cellId}`) ?? 0;
const supply = await redis.get(`supply:${cellId}`) ?? 0;
const previous = await redis.get(`surge:${cellId}`) ?? 1.0;
const raw = computeSurgeMultiplier({ cellId, demand: +demand, supply: +supply });
const smoothed = smoothedMultiplier(raw, +previous);
await redis.set(`surge:${cellId}`, smoothed.toFixed(2), 'EX', 60);
}
}
Why smoothing matters
Without smoothing, surge oscillates wildly. Imagine: surge goes to 2× → riders stop requesting → demand drops → surge drops to 1× → riders flood back → surge spikes again. This "sawtooth" pattern creates a terrible user experience. The exponential moving average (70/30 blend) dampens oscillations while still responding to genuine demand shifts within 2–3 computation cycles (~90 seconds).
Demand counting
Demand is tracked using a in Redis. When a ride is requested, the Ride Service increments the demand counter for the pickup cell. The counter has a 2-minute TTL — old requests naturally expire without cleanup jobs.
// Called by Ride Service when a new ride is requested
async function trackDemand(pickupLat: number, pickupLng: number): Promise<void> {
const cellToken = computeS2Cell(pickupLat, pickupLng, level=12);
const key = `demand:${cellToken}`;
// Increment and set TTL (2 minutes)
await redis.incr(key);
await redis.expire(key, 120); // auto-expires — no cleanup needed
}
Surge transparency and rider experience
Riders see the surge multiplier before confirming their ride request. The flow is: rider opens app → app calls GET /api/v1/surge with their location → shows "1.5× pricing in effect" → rider confirms → ride request includes the surge multiplier they agreed to. This prevents surprise charges and is required by regulations in many jurisdictions.
| Design choice | Approach | Trade-off |
|---|---|---|
| Multiplier cap | Hard cap at 3× (configurable per city) | Limits revenue but prevents PR disasters and regulatory issues |
| Surge lock-in | Rider locks in the displayed multiplier for 5 minutes | Prevents bait-and-switch; surge may change between view and confirm |
| Granularity | S2 level-12 cells (~3.3km²) | Fine enough for city blocks; coarser than street-level to avoid noise |
| Update frequency | Every 30 seconds | Fast enough to respond to events; slow enough to avoid oscillation |
| Smoothing | 70/30 exponential moving average | Dampens oscillation; takes ~90s to fully respond to demand shift |
🎯 Surge pricing is NOT on the matching hot path
The Surge Service computes multipliers asynchronously every 30 seconds and writes them to Redis. The matching service simply reads the cached value — a single Redis GET taking <1ms. Surge computation never blocks or slows down matching. If the surge cache is stale (Redis down), matching proceeds with multiplier = 1.0 as a safe default.
💡 Interview insight: mention supply positioning
Advanced systems don't just react to demand — they predict it. Uber's "supply positioning" uses historical patterns (concerts ending, flights landing) to proactively suggest drivers move to high-demand areas before requests arrive. Mention this as a follow-up optimization, not a core requirement.
ETA Estimation
ETA is what makes the product feel accurate. A rider sees "driver arriving in 4 minutes" — if the driver arrives in 3–5 minutes, trust is maintained. If it says 4 and takes 12, the product feels broken. ETA computation is the most computationally expensive part of the matching pipeline, but it runs in parallel with other steps and is heavily cached.
Two types of ETA
The system computes two distinct ETAs for every ride: pickup ETA (how long until the driver reaches the rider) and trip ETA (how long the ride itself will take). They use the same underlying road-graph engine but serve different purposes and have different accuracy requirements.
| ETA type | Used for | Accuracy requirement | Compute budget |
|---|---|---|---|
| Pickup ETA | Matching score, rider wait time display | ±1 minute (rider tolerance) | 50ms (part of matching pipeline) |
| Trip ETA | Fare estimation, rider planning | ±3 minutes (less critical pre-ride) | 100ms (computed in parallel with matching) |
Road-graph routing
ETA is NOT straight-line distance divided by average speed. That gives wildly inaccurate results in cities with one-way streets, highways, and traffic. The ETA service uses a weighted where edges represent road segments weighted by travel time. The weights incorporate historical speed data (time-of-day patterns) and live traffic from driver GPS traces. The three main approaches are (accurate but slow), pre-computed travel-time matrices (fast but approximate), and (best balance of speed and accuracy, used by Google Maps).
Approach 1: Full Dijkstra on road graph
- Most accurate
- O(E log V) per query — too slow for real-time at scale
- Used for trip ETA (longer routes, more budget)
Approach 2: Pre-computed travel time matrix
- Divide city into ~1000 zones
- Pre-compute zone-to-zone travel time every 5 minutes
- Lookup is O(1) — just matrix[from_zone][to_zone]
- Used for pickup ETA during matching (speed > accuracy)
Approach 3: Hierarchical routing (Contraction Hierarchies)
- Pre-process graph to add "shortcut" edges between important nodes
- Query time: O(log V) instead of O(E log V)
- Best balance of speed and accuracy
- Used by Google Maps, OSRM, Valhalla
Production choice: Approach 2 for matching (speed), Approach 3 for display (accuracy)
The travel-time matrix
For the matching pipeline where we need ETAs for 5–10 candidates in under 50ms total, a pre-computed matrix is the only viable approach. The city is divided into ~1000 zones (larger than S2 cells — think neighborhoods). Every 5 minutes, a background job recomputes the zone-to-zone travel times using recent driver GPS traces as ground truth.
// Pre-computed matrix: travelTime[fromZone][toZone] = seconds
// Updated every 5 minutes from aggregated driver GPS traces
const travelTimeMatrix: Map<string, Map<string, number>> = new Map();
// Fast ETA lookup for matching (O(1))
function getPickupEta(driverLat: number, driverLng: number,
pickupLat: number, pickupLng: number): number {
const fromZone = latLngToZone(driverLat, driverLng);
const toZone = latLngToZone(pickupLat, pickupLng);
// Same zone: use straight-line with speed factor
if (fromZone === toZone) {
const distKm = haversine(driverLat, driverLng, pickupLat, pickupLng);
return Math.round(distKm / 0.5 * 60); // ~30 km/h in-city average → seconds
}
// Cross-zone: matrix lookup
const baseTime = travelTimeMatrix.get(fromZone)?.get(toZone);
if (!baseTime) return fallbackEta(driverLat, driverLng, pickupLat, pickupLng);
return baseTime;
}
// Fallback: Haversine distance × speed factor (when matrix has no data)
function fallbackEta(lat1: number, lng1: number, lat2: number, lng2: number): number {
const distKm = haversine(lat1, lng1, lat2, lng2);
const avgSpeedKmPerMin = 0.5; // 30 km/h in city
return Math.round(distKm / avgSpeedKmPerMin * 60);
}
Updating the matrix with live data
The matrix is only as good as its data. Every completed trip provides a ground-truth data point: actual travel time between two zones at a specific time of day. A background pipeline aggregates these into rolling averages, weighted toward recent observations. This means the matrix automatically adapts to traffic patterns, road closures, and seasonal changes.
Input: completed trip traces (from Kafka topic: trip-completed)
Every 5 minutes:
1. Aggregate all trips completed in the last 30 minutes
2. For each (from_zone, to_zone) pair with >= 3 observations:
- Compute median travel time (robust to outliers)
- Blend with historical baseline: 60% recent + 40% historical
3. Write updated matrix to Redis (hash: eta_matrix:{from_zone})
4. ETA service reads from Redis on every query
Fallback hierarchy:
1. Recent observations (last 30 min) — best accuracy
2. Same time-of-day historical (last 4 weeks) — captures patterns
3. All-time average for this zone pair — last resort
4. Haversine × speed factor — when no data exists at all
🎯 Why not just use Google Maps API?
At 350 matching queries/sec × 5 candidates each = 1,750 ETA queries/sec. Google Maps Directions API costs $5 per 1000 requests and has rate limits. That's $750/hour just for pickup ETAs. The pre-computed matrix costs zero per query and responds in microseconds. Use Google Maps for the trip ETA shown to the rider (lower volume, higher accuracy needed) and the matrix for matching (high volume, speed critical).
💡 Interview tip: mention ETA accuracy feedback loop
After every ride, compare predicted pickup ETA vs actual pickup time. Track the error distribution. If median error exceeds 2 minutes for a zone pair, flag it for investigation (road closure? construction? map data stale?). This closed-loop feedback is what separates production systems from interview sketches.
Ride Lifecycle & State Transitions
The ride lifecycle is where consistency matters most. A driver must never be double-booked. A cancelled ride must never be completed. A payment must never be charged without a completed trip. Every state transition is a critical section that must be serialized per ride and validated against the state machine. This is the CP (consistency-prioritized) path of the system.
State transition handler
Every state change goes through a single handler that validates the transition, updates the database atomically, publishes an event, and notifies the affected parties. The handler uses — the UPDATE includes a WHERE clause on the current status, so concurrent transitions are safely rejected.
const VALID_TRANSITIONS: Record<string, string[]> = {
REQUESTED: ['MATCHING', 'CANCELLED'],
MATCHING: ['OFFERED', 'NO_MATCH', 'CANCELLED'],
OFFERED: ['ACCEPTED', 'MATCHING', 'CANCELLED'], // MATCHING = re-match on timeout
ACCEPTED: ['EN_ROUTE', 'CANCELLED'],
EN_ROUTE: ['IN_PROGRESS', 'CANCELLED'],
IN_PROGRESS: ['COMPLETED', 'CANCELLED'],
};
async function transitionRide(
rideId: string,
fromStatus: string,
toStatus: string,
metadata: Record<string, unknown>
): Promise<{ success: boolean; ride?: Ride }> {
// 1. Validate transition is legal
if (!VALID_TRANSITIONS[fromStatus]?.includes(toStatus)) {
throw new InvalidTransitionError(fromStatus, toStatus);
}
// 2. Atomic update with optimistic concurrency
const result = await db.query(`
UPDATE rides
SET status = $1,
updated_at = now(),
matched_at = CASE WHEN $1 = 'ACCEPTED' THEN now() ELSE matched_at END,
started_at = CASE WHEN $1 = 'IN_PROGRESS' THEN now() ELSE started_at END,
completed_at = CASE WHEN $1 = 'COMPLETED' THEN now() ELSE completed_at END,
cancelled_at = CASE WHEN $1 = 'CANCELLED' THEN now() ELSE cancelled_at END,
cancel_reason = CASE WHEN $1 = 'CANCELLED' THEN $4 ELSE cancel_reason END
WHERE id = $2 AND status = $3
RETURNING *
`, [toStatus, rideId, fromStatus, metadata.reason ?? null]);
// 3. If rowcount = 0, someone else transitioned first (race condition)
if (result.rowCount === 0) {
return { success: false }; // Caller should re-read and retry or return 409
}
const ride = result.rows[0];
// 4. Publish event for downstream consumers
await kafka.produce('ride-events', rideId, {
type: `ride.${toStatus.toLowerCase()}`,
rideId,
riderId: ride.rider_id,
driverId: ride.driver_id,
timestamp: new Date().toISOString(),
...metadata,
});
// 5. Side effects based on new state
await handleSideEffects(ride, toStatus);
return { success: true, ride };
}
Side effects per transition
Each state transition triggers specific side effects — notifications, driver availability changes, payment actions. These are executed after the database write succeeds, ensuring the source of truth is always consistent even if a side effect fails.
| Transition | Side effects | Failure handling |
|---|---|---|
| → MATCHING | Hold payment, start matching pipeline | If payment hold fails → CANCELLED (never match without payment) |
| → ACCEPTED | Remove driver from available pool (Redis), notify rider, release offer lock | If Redis remove fails → driver may get duplicate offers (self-healing on next update) |
| → EN_ROUTE | Start streaming driver location to rider, update driver status | Non-critical — rider can still see driver on map via polling fallback |
| → IN_PROGRESS | Stop showing pickup ETA, start trip timer, begin fare meter | Fare meter is local to driver app — server reconciles on completion |
| → COMPLETED | Capture payment, add driver back to available pool, trigger rating prompt | If payment capture fails → retry queue. Driver still freed immediately. |
| → CANCELLED | Release payment hold, free driver (if assigned), apply cancellation fee if applicable | Cancellation fee logic: only if driver was already en route (rider penalty) |
The acceptance race condition
The most critical race condition: a rider cancels at the exact moment a driver accepts. Without careful handling, the system could end up in an inconsistent state — driver thinks they have a ride, rider thinks they cancelled. The optimistic concurrency in the transition handler prevents this naturally.
Driver taps Accept (ride is in OFFERED state)
Accept request arrives at Ride Service. Handler attempts: UPDATE rides SET status = 'ACCEPTED' WHERE id = ? AND status = 'OFFERED'
Rider taps Cancel (same ride, same moment)
Cancel request arrives at Ride Service. Handler attempts: UPDATE rides SET status = 'CANCELLED' WHERE id = ? AND status = 'OFFERED'
Database serializes — one wins
PostgreSQL serializes the two UPDATEs. Whichever commits first changes status from OFFERED. The second UPDATE finds status ≠ 'OFFERED' → rowcount = 0 → returns 409 to the loser.
Loser is notified
If cancel won: driver gets RIDE_CANCELLED push. If accept won: rider gets 'cancellation failed — driver already accepted' and must cancel from ACCEPTED state (which may incur a fee).
🔑 Why this works without distributed locks
The ride state lives in a single PostgreSQL row. All transitions use the same conditional UPDATE pattern. PostgreSQL's row-level locking serializes concurrent modifications to the same ride automatically. No Redis locks, no ZooKeeper, no two-phase commit. The database IS the coordination mechanism. This only works because ride state is low-volume (~2,800 writes/sec) — well within a single Postgres primary.
Event-driven downstream processing
Every state transition publishes an event to Kafka. Downstream consumers process these events asynchronously — they never block the state transition itself. This decouples the ride lifecycle from billing, analytics, driver scoring, and fraud detection.
Topic: ride-events (partitioned by ride_id for ordering)
Consumer groups:
billing-service:
ride.completed → capture payment, generate invoice
ride.cancelled → release hold, apply cancellation fee if applicable
analytics-service:
All events → aggregate into ride metrics (completion rate, avg wait time)
driver-scoring:
ride.accepted → update acceptance rate
ride.completed → update completion rate, recalculate rating
fraud-detection:
ride.cancelled (by driver, repeatedly) → flag for review
ride.completed (suspiciously short) → check for fake rides
notification-service:
ride.accepted → push to rider ("driver on the way")
ride.completed → push receipt to rider
Delivery guarantee: at-least-once (consumers are idempotent on ride_id + event_type)
💡 Interview insight: idempotent consumers
Kafka delivers at-least-once. A consumer might see the same ride.completed event twice (after a rebalance). The billing service must be — check if payment was already captured for this ride_id before charging again. Use a processed_events table with a unique constraint on (ride_id, event_type) as a deduplication guard.
Scaling & Reliability
Different traffic levels need different architectures. A ride-matching system serving one city looks nothing like one serving 600 cities globally. Naming the scaling tiers explicitly shows you can scale up and down — starting simple and earning complexity is what senior engineers do.
Scaling at different tiers
Each tier introduces new constraints that force architectural changes. The key insight: location ingestion hits its ceiling first (at ~500K drivers), long before ride state or matching becomes a bottleneck.
| Scale | Architecture | Why |
|---|---|---|
| 1 city (100K drivers) | Single Redis, single Postgres, 2 connection servers, 1 matching service | Works. Redis handles 250K GEOADD/sec easily. Don't overbuild. |
| 10 cities (1M drivers) | + Redis Cluster (3 shards), + Postgres read replicas, 5 connection servers | Single Redis node maxes at ~500K writes/sec. Cluster distributes geo keys by city prefix. |
| 50 cities (5M drivers) | + Per-city Redis instances, 20 connection servers, matching service per region | Cross-city queries don't exist — shard everything by city/region for isolation. |
| Global (10M+ drivers) | + Multi-region deployment, regional Postgres, global Kafka, anycast DNS | Latency to Redis must be <5ms — requires regional data residency. |
City-based sharding (the natural partition)
Ride matching has a natural sharding dimension that most systems lack: geography. A rider in Mumbai will never be matched to a driver in Delhi. This means the entire system can be sharded by city/region with zero cross-shard queries on the hot path. Each city gets its own Redis instance, its own matching service fleet, and its own connection server pool.
Request routing:
1. Rider requests ride with pickup coordinates
2. API Gateway resolves coordinates → city_id (via geo-fence lookup)
3. Route to city-specific service cluster
Per-city infrastructure:
Redis: redis-mumbai.internal, redis-delhi.internal, ...
Matching: matching-mumbai.internal (3 replicas)
Connection: ws-mumbai.internal (load-balanced, sticky)
Postgres: Shared across cities (ride state is low-volume)
Benefits:
✅ Zero cross-shard queries — matching is always within one city
✅ Independent scaling — Mumbai gets more capacity than a small city
✅ Failure isolation — Redis crash in Delhi doesn't affect Mumbai
✅ Regulatory compliance — data stays in the correct region
Edge case: rides near city boundaries
→ Expand search radius into adjacent city's Redis if needed
→ Rare (<0.1% of rides) — acceptable to handle as a special case
Failure-mode playbook
Each component can fail independently. The system must degrade gracefully — a Redis failure should not prevent active rides from completing, and a Postgres failure should not disconnect drivers.
🔥 Redis (location index) down
New matching fails — no spatial queries possible. Active rides continue unaffected (state is in Postgres). Drivers stay connected (connection servers are independent). Recovery: Redis Cluster promotes a replica in ~10 seconds. During the gap, return 503 for new ride requests with "try again in a moment."
🔥 Connection server crashes
~500K drivers lose their WebSocket connection. They reconnect with (1s, 2s, 4s). Consistent hashing routes them to a different server. Location data goes stale for ~10 seconds during reconnection. Matching uses slightly stale positions — acceptable since drivers move slowly in cities.
🔥 Postgres primary down
New rides cannot be created (state writes fail). Active rides cannot transition (accept, complete). Location tracking and matching continue (they don't touch Postgres). Automated failover promotes a replica in ~30 seconds. During the gap, queue state transitions in memory and replay on recovery.
🔥 Matching service overloaded
Apply on the matching service. If p99 latency exceeds 2 seconds, shed load by returning "high demand, try again" to new requests. Active matching attempts continue. This prevents a cascade where slow matching blocks connection servers.
🔥 Kafka down
Ride events stop flowing to downstream consumers. Billing, analytics, and scoring are delayed — but rides continue normally. The Ride Service buffers events in-process (bounded queue). When Kafka recovers, events drain. No ride is lost — only downstream processing is delayed.
SLO targets per path
Different paths have different availability and latency targets. Setting explicit SLOs per path drives infrastructure investment and on-call priority decisions.
| Path | Availability SLO | Latency SLO | On failure |
|---|---|---|---|
| Location ingestion | 99.9% (8.7h/year downtime OK) | < 5ms per update | Stale positions for ~30s — matching slightly less accurate |
| Matching | 99.99% (52 min/year) | < 1s end-to-end | Rider sees 'no drivers' — revenue loss |
| Ride state transitions | 99.99% (52 min/year) | < 200ms per transition | Rides stuck in current state — manual intervention needed |
| Surge computation | 99% (3.6 days/year OK) | < 30s staleness | Default to 1× multiplier — riders get cheaper rides temporarily |
| ETA | 99.9% | < 100ms per query | Fall back to Haversine estimate — less accurate but functional |
💡 The key reliability insight
The system is designed so that the most critical user-visible path (active ride in progress) depends on the fewest components: just the connection server (for location streaming) and Postgres (for state). Redis, Kafka, matching service, and surge service can all be down without affecting a ride that's already in progress. This is failure isolation by design.
Observability
Good observability is what lets you detect a matching degradation before riders start churning, spot a connection server losing drivers, and prove the SLO is being met. Name the metrics, the dashboards, and the alerts — not just "we'll use Prometheus."
Golden signals (per service)
Each service has its own set of golden signals. The matching service cares about latency and success rate; the connection servers care about connection count and throughput; the ride service cares about state transition errors.
| Signal | Matching Service | Connection Servers | Ride Service |
|---|---|---|---|
| Traffic | match_requests/sec by city | active_connections, location_updates/sec | transitions/sec by type |
| Latency | p50/p95/p99 match time (target <1s) | p99 update processing time | p99 transition time |
| Errors | NO_MATCH rate, offer_timeout rate | connection_drops/sec, invalid_updates/sec | invalid_transition rate, constraint_violations |
| Saturation | Redis connection pool, goroutine count | file descriptors, memory per connection | Postgres connection pool utilization |
Business metrics (not just infra)
Infrastructure metrics tell you if the system is healthy. Business metrics tell you if the product is working. Both are essential for a ride-matching system where a 10% increase in match latency directly correlates with rider churn.
• Match success rate (target: > 95% of requests result in a ride)
• Average pickup ETA (target: < 5 min in urban areas)
• ETA accuracy (predicted vs actual — target: ±1 min median)
• Driver acceptance rate (target: > 80% — low rate = bad offer targeting)
• Time to first offer (from request to driver seeing the offer)
• Re-match rate (how often first driver rejects/times out)
• Surge coverage (% of cells with fresh surge data)
• Driver online hours vs rides completed (utilization efficiency)
• Cancellation rate by stage (OFFERED vs ACCEPTED vs EN_ROUTE)
• Rider wait time distribution (p50, p90, p99 by city)
Alerts that matter
Not every metric needs an alert. Page for user-visible failures; ticket for degradations that need attention within hours; ignore transient blips that self-heal.
| Alert | Page? | Threshold |
|---|---|---|
| Match p99 > 3s for 2 min | Yes | Riders are waiting — investigate immediately |
| NO_MATCH rate > 20% for 5 min | Yes | Riders can't get rides — supply crisis or system failure |
| Connection drops > 10K/min | Yes | Mass disconnection — server crash or network issue |
| Driver acceptance rate < 60% for 10 min | Ticket | Offer targeting degraded — not urgent but revenue impact |
| ETA error median > 3 min | Ticket | Matrix stale or road data issue — investigate within hours |
| Surge computation lag > 2 min | Ticket | Pricing stale — riders may see wrong prices |
| Postgres replication lag > 5s | Page | Read replicas serving stale data for ride history |
| Redis memory > 80% | Ticket | Capacity planning — not an incident yet |
| Kafka consumer lag > 100K events | Ticket if > 30 min | Billing/analytics delayed — not user-visible |
Distributed tracing
A single ride request touches 5+ services. Without distributed tracing, debugging "why did this match take 4 seconds?" is impossible. Instrument every service boundary with trace context propagation.
Trace: match.request (total 620ms)
├─ api-gateway.auth 12ms
├─ ride-service.create_ride 45ms
│ └─ postgres.insert 38ms
├─ matching-service.find_candidates 85ms
│ ├─ redis.georadius 8ms (15 candidates)
│ └─ redis.hmget_pipeline 12ms (metadata for 15)
├─ eta-service.batch_eta 180ms
│ └─ matrix_lookup × 5 2ms (pre-computed)
│ └─ road_graph_query × 2 160ms (cache miss)
├─ matching-service.score_and_rank 5ms
├─ matching-service.acquire_offer_lock 3ms
│ └─ redis.set_nx 2ms
├─ connection-server.push_offer 15ms
│ └─ websocket.send 8ms
└─ matching-service.start_timeout_timer 2ms
Sampling: 1% of all requests + 100% of slow (>2s) + 100% of failures.
🎯 The metric that predicts churn
Track "rider wait time" — the elapsed time from ride request to driver arrival at pickup. If the p90 exceeds 8 minutes in a city, rider retention drops measurably within 2 weeks. This single metric captures the combined health of matching, ETA accuracy, and driver supply. Alert on it trending upward even if individual service metrics look fine.
💡 SLI / SLO / error budget
SLI: fraction of ride requests that result in a matched driver within 60 seconds. SLO: 99.9%. Error budget: 0.1% of 350/sec = 0.35 failures/sec is the spending limit. Burn-rate alert: page when 1h burn > 14× (budget gone in 3 days). This turns "is matching healthy" into a number, not a vibe.
Trade-offs Consolidated
Every decision in this design was a trade. Bundling them in one place makes the reasoning easy to review — and gives a candidate a compact story to walk the interviewer through at the end.
| Decision | We picked | Why | What we gave up |
|---|---|---|---|
| Spatial index | Redis Geo (sorted set) + S2 cells for regions | 1.25M writes/sec throughput, sub-ms radius queries, no custom code | Limited to single-dimension radius queries; no polygon search |
| Location transport | WebSocket (persistent connections) | Bidirectional, low overhead per message, push ride offers instantly | Stateful servers (sticky sessions), complex connection management |
| Ride state store | PostgreSQL with partial unique index | ACID for correctness, partial index prevents double-booking at DB level | Single-primary write ceiling (~5K/s); must shard eventually |
| Matching strategy | Greedy nearest-first with composite scoring | Sub-second latency, simple to reason about, good enough for 95% of cases | Not globally optimal — batch matching would give better overall assignment |
| ETA for matching | Pre-computed zone-to-zone matrix | O(1) lookup, updated every 5 min, handles 1750 queries/sec trivially | Less accurate than real-time routing; ~1 min error in complex road networks |
| Surge granularity | S2 level-12 cells (~3.3km²), 30-second refresh | Fine enough for neighborhoods, coarse enough to avoid noise | Can't capture block-level demand spikes (stadium exit gate vs entrance) |
| Offer concurrency | Redis SETNX lock (optimistic, 20s TTL) | Cheap, fast, self-healing (TTL auto-releases on crash) | Rare false-positive: driver gets no offer for 20s if lock holder crashes |
| Event processing | Kafka with at-least-once delivery | Durable, replayable, decouples ride state from billing/analytics | Consumers must be idempotent; adds operational complexity |
| Sharding strategy | City-based geographic sharding | Zero cross-shard queries on hot path; natural isolation boundary | Cross-city rides (rare) need special handling; uneven city sizes |
| Driver assignment | Partial unique index (one active ride per driver) | Database-level guarantee, no distributed lock needed | Constraint violation on race = 409 to second caller (must handle gracefully) |
| Update frequency | Adaptive: 2s on-trip, 4s available, 30s idle | Balances accuracy with battery life; matches product needs per state | 4s staleness means a driver could move 30m between updates in city traffic |
Where reasonable engineers disagree
💬 Greedy matching vs batch optimization
Greedy (match immediately as requests arrive) gives lower latency but suboptimal global assignment. Batch (collect requests for 5s, solve assignment problem) gives better utilization but adds perceived wait time. Uber started greedy and moved to a hybrid: greedy for most, batch for high-density areas during peak.
💬 Redis Geo vs custom in-memory index
Redis Geo is simple and battle-tested but limited to radius queries. A custom in-memory index (R-tree, k-d tree) could support polygon queries and more complex spatial operations. For an interview, Redis Geo is the right answer — it solves the problem without custom infrastructure. Mention the custom option as a future optimization.
💬 Single Postgres vs per-city databases
Ride state is low-volume enough for a single Postgres primary globally. But regulatory requirements (data residency) may force per-country databases. Start with one, split when regulations demand it — not before.
💬 WebSocket vs gRPC streaming
WebSocket is simpler for mobile clients and has universal browser support. gRPC streaming is more efficient (binary protocol, multiplexing) but requires more client-side complexity. Uber uses a custom protocol over TCP; for an interview, WebSocket is the pragmatic choice.
🎯 The trade-off that defines seniority
The biggest divide between junior and senior answers is whether the candidate can articulate what they're sacrificing. "We use greedy matching for latency, which means two riders requesting simultaneously might both get suboptimal drivers — but the 500ms we save in perceived wait time is worth more than the 30-second longer pickup for 5% of rides" is a sentence that buys you two levels.
Follow-ups & Common Traps
The last 10 minutes of the interview are where candidates separate. The interviewer stops nodding along and starts probing: "what if...?", "how would you...?", "what breaks when...?". These are the questions worth pre-loading.
Curveball follow-ups
Q:A concert ends and 50,000 people request rides simultaneously in one cell. What happens?
A: Surge spikes to 3× within 30 seconds (one computation cycle). The matching service processes requests in FIFO order — first 500 get matched quickly (drivers already nearby). The rest queue with increasing wait times. Supply positioning (pre-event) should have moved extra drivers to the area. If demand still exceeds supply after 60s, expand the search radius to 8km → 12km to pull drivers from adjacent areas. Show riders accurate wait estimates ('15 min wait due to high demand') rather than false hope.
Q:A driver accepts a ride but their phone dies immediately. What happens?
A: The connection server detects the dead WebSocket within 30 seconds (missed heartbeat). It publishes a 'driver_disconnected' event. The Ride Service checks: if ride is in ACCEPTED or EN_ROUTE state and driver has been offline > 60 seconds, auto-transition to CANCELLED with reason 'driver_unreachable' and trigger re-matching. The rider sees 'your driver lost connection — finding another driver.' Payment hold is preserved for the re-match.
Q:How do you prevent a driver from being offered rides while already carrying a passenger?
A: Two layers: (1) When a ride transitions to ACCEPTED, the driver is removed from the Redis Geo 'available' set — they simply don't appear in GEORADIUS results. (2) The partial unique index on rides(driver_id) WHERE status IN ('ACCEPTED', 'EN_ROUTE', 'IN_PROGRESS') prevents the database from ever assigning two active rides. Even if layer 1 has a race condition (stale Redis), layer 2 catches it with a constraint violation.
Q:How would you add ride pooling (shared rides) to this system?
A: Pooling changes matching from 1:1 to N:1. The matching service must now consider: (1) drivers already carrying a passenger whose route overlaps with the new rider's route, (2) detour cost for existing passenger, (3) pickup ordering. The spatial query becomes 'find drivers within 5km whose current route passes within 500m of my pickup AND whose destination is within 2km of mine.' This requires storing active trip routes in Redis (as polylines) and doing route-overlap computation — a fundamentally different algorithm. I'd build it as a separate matching pipeline, not modify the existing one.
Q:GPS is inaccurate in dense urban areas (urban canyons). How do you handle it?
A: Three mitigations: (1) Snap-to-road: project raw GPS coordinates onto the nearest road segment using the road graph. This corrects 10–50m errors from building reflections. (2) Kalman filtering: smooth the GPS trace using speed and heading to reject impossible jumps. (3) For matching, use a slightly larger search radius (5km instead of 3km) to account for position uncertainty. Accept that matching in downtown Manhattan will always be slightly less precise than in suburbs.
Q:How do you handle the 'phantom driver' problem — driver appears available but isn't responding?
A: Track 'last_offer_response_time' per driver. If a driver has timed out on 3 consecutive offers without responding, temporarily remove them from the available pool for 5 minutes (soft-ban). Their app shows 'you've been paused due to missed ride requests.' This prevents the same unresponsive driver from blocking multiple riders. Also: if a driver's last location update is > 30 seconds old, exclude them from matching — they may have killed the app.
Q:A rider reports they were charged but the driver never showed up. How do you investigate?
A: The location trace tells the story. Pull the driver's GPS trail for the ride duration from the Kafka-archived location events. If the trace shows the driver never moved toward the pickup (or moved away), auto-refund and flag the driver. If the trace shows the driver arrived at pickup coordinates but rider wasn't there, check the rider's app activity (did they cancel late?). The location trace is the source of truth for disputes — this is why we archive 10% of updates to Kafka.
Q:How would you handle multi-region deployment for regulatory compliance?
A: Data residency laws (GDPR, India's data localization) require ride data to stay in-country. Deploy per-region: each region gets its own Postgres (ride state), Redis (locations), and Kafka (events). The API Gateway routes based on the rider's registered country, not their current location. Cross-border rides (rare) are handled by the origin country's infrastructure. Global services (user auth, payment) remain centralized with data replication agreements.
Common traps (and how to avoid them)
Storing driver locations in PostgreSQL
1.25M writes/sec of ephemeral GPS data in a relational database. Postgres maxes at ~50K writes/sec and you'd need indexes that are constantly rebuilt.
✅Use Redis Geo (in-memory sorted set). Locations are ephemeral — they're overwritten every 4 seconds. No durability needed.
Using distributed locks for matching
Acquiring a ZooKeeper or Redis Redlock for every match attempt adds 10–50ms latency and creates a single point of contention.
✅Use optimistic concurrency: Redis SETNX for offer locks (fast, self-healing TTL) and Postgres conditional UPDATE for state transitions (database handles serialization).
Single global spatial index
One Redis instance for all cities means a failure affects everyone, and the index contains drivers that can never match cross-city.
✅Shard by city/region. A rider in Mumbai never needs to query drivers in Delhi. Each city gets independent infrastructure with independent failure domains.
Synchronous ETA computation in the matching loop
Calling a road-graph API for every candidate (20 drivers × 100ms = 2 seconds) blows the 1-second matching budget.
✅Use a pre-computed zone-to-zone matrix for matching (O(1) lookup). Only compute precise road-graph ETA for the top 2–3 candidates, in parallel.
Computing surge pricing synchronously on ride request
Computing demand/supply ratio on every request adds latency to the hot path and creates a dependency on the surge service for matching.
✅Surge is pre-computed every 30 seconds and cached in Redis. The matching service reads a cached value — never computes it inline.
Polling for ride status updates
Rider app polling every 2 seconds for status changes creates 175K unnecessary requests/sec (350K active rides × 0.5 req/s).
✅Push status updates via WebSocket. The rider already has a persistent connection — use it. Polling is only a fallback for reconnection gaps.
Random driver selection instead of scoring
Picking a random nearby driver ignores ETA, rating, heading, and acceptance rate — leading to longer pickups and more rejections.
✅Score candidates on a composite of ETA (60%), rating (20%), acceptance rate (15%), and heading alignment (5%). The scoring function is cheap — the expensive part is the spatial query, which you're doing anyway.
🔥 The deepest trap — overbuilding from day one
Reaching for Kafka, Redis Cluster, multi-region, ML-based matching, and Kubernetes in the first five minutes telegraphs pattern-matching, not thinking. Senior answers start with one city: "single Redis, single Postgres, 2 connection servers — this handles 100K drivers comfortably. We add Redis Cluster when we hit 500K drivers, city sharding at 1M, and multi-region only when regulations require it." Earn complexity with explicit triggers.
Quick Revision
A 2-minute scan before the interview. If you can state each of these without looking, you're ready.
Quick Revision Cheat Sheet
Scale anchors: 10M rides/day (~350 peak/sec), 5M drivers, 1.25M location updates/sec.
The bottleneck: Location ingestion at 1.25M writes/sec — not rides, not matching.
Spatial index: Redis Geo (GEOADD + GEORADIUS). S2 level-12 cells for surge regions.
Matching pipeline: GEORADIUS → filter → enrich → ETA → score → lock → offer. Under 1 second.
Scoring formula: ETA (60%) + rating (20%) + acceptance rate (15%) + heading (5%).
Offer concurrency: Redis SETNX lock per driver (20s TTL). Prevents double-offers.
Ride state: PostgreSQL with conditional UPDATE (optimistic concurrency). Partial unique index prevents double-booking.
Surge pricing: demand/supply ratio per S2 cell, computed every 30s, cached in Redis. Cap at 3×.
ETA strategy: Pre-computed zone matrix for matching (O(1)). Road-graph for display (accurate).
Connection management: 5M WebSockets across 20 servers. Consistent hash(driver_id) for assignment.
Sharding: City-based geographic sharding. Zero cross-shard queries on hot path.
Update frequency: Adaptive: 2s on-trip, 4s available, 30s idle. Balances accuracy vs battery.
Failure isolation: Active rides work when Redis/Kafka/Matching/Surge are down. Only need Postgres + connection server.
SLO: Matching 99.99% / <1s. Ride state 99.99% / <200ms. Location 99.9%. Surge 99%.
Event processing: Kafka at-least-once. Consumers (billing, analytics, scoring) are idempotent.
Re-matching: On timeout/reject: next from ranked list. After 3 failures: fresh GEORADIUS. Max 60s total.
🎯 The 45-minute interview arc
- 0–5 min: Clarify requirements. State: 10M rides/day, 1.25M location updates/sec, match in <1s, never double-book.
- 5–10 min: Capacity estimation. Derive location QPS, connection server count, hot state size.
- 10–15 min: API + data model. REST for lifecycle, WebSocket for real-time. Ride state machine + Redis Geo.
- 15–25 min: HLD — four paths (location, matching, ride state, surge). One diagram.
- 25–35 min: Deep dive on whichever the interviewer probes — likely geospatial indexing or matching algorithm.
- 35–40 min: Trade-offs. Greedy vs batch, Redis Geo vs custom, single vs sharded.
- 40–45 min: Follow-ups. Concert scenario, driver disconnect, pooling, multi-region.
💡 The single sentence that defines a senior answer
"Location ingestion is AP best-effort at 1.25M writes/sec in Redis; matching is latency-optimized with degradation fallbacks; ride state is CP with ACID in Postgres; and surge is eventually consistent with 30-second staleness — so we pick different stores, delivery semantics, and SLOs per path." If you can say this and back each claim, you're answering at the right level.