Design Uber: A Ride-Hailing Platform
Uber connects millions of riders with drivers in real-time across 10,000+ cities worldwide. Designing Uber is one of the most challenging system design problems because it requires real-time location tracking, geospatial matching, dynamic pricing, and low-latency communication. This guide covers the complete architecture for a ride-hailing platform.
1. Requirements
Functional Requirements
- Riders can request a ride by specifying pickup and drop-off locations.
- The system matches riders with the nearest available drivers.
- Real-time tracking of driver location on the rider's map.
- ETA (Estimated Time of Arrival) calculation for both pickup and drop-off.
- Dynamic (surge) pricing based on demand and supply.
- Trip history, receipts, and ratings for both riders and drivers.
- Driver app shows ride requests and navigation.
- Multiple ride types: UberX, UberPool, UberXL, UberBlack.
Non-Functional Requirements
- Low latency: Driver matching within 10 seconds. Location updates processed in real-time.
- High availability: 99.99% uptime. Ride requests must always work.
- Scalability: Handle millions of concurrent rides and drivers.
- Accuracy: Precise ETA estimation and fair pricing.
- Consistency: A driver must not be matched to two riders simultaneously.
2. Capacity Estimation
| Metric | Estimate |
|---|---|
| Daily active riders | 20 million |
| Daily active drivers | 5 million |
| Daily rides completed | 25 million |
| Peak concurrent rides | ~1 million |
| Location updates per driver | Every 4 seconds |
| Location update QPS | 5M drivers / 4 sec = 1.25 million updates/sec |
| Ride request QPS | 25M / 86,400 ≈ 290/sec (avg), peak ~1,500/sec |
| Location data per update | ~200 bytes (lat, lng, timestamp, driver_id, heading, speed) |
| Location data bandwidth | 1.25M × 200B = 250 MB/sec inbound |
The key challenge is processing 1.25 million location updates per second and using that data for real-time matching and ETA calculations.
3. High-Level Design
| Component | Responsibility |
|---|---|
| Location Service | Ingests and stores real-time driver locations |
| Matching Service (Dispatch) | Matches riders with optimal drivers |
| Trip Service | Manages trip lifecycle (request → match → pickup → dropoff) |
| Pricing Service | Calculates fare and surge pricing |
| ETA Service | Estimates arrival and trip times using routing data |
| Notification Service | Push notifications, SMS updates |
| Payment Service | Handles fare charging and driver payouts |
| Map Service | Routing, geocoding, map data |
| Load Balancer | Distributes requests across service instances |
| Message Queue (Kafka) | Decouples location ingestion, trip events, analytics |
4. Detailed Component Design
4.1 Geospatial Indexing
The core challenge is answering the query: "Find the nearest available drivers to this location" at 1,500 requests/sec. We need a spatial index.
Approach: Geohash + In-Memory Grid
Divide the Earth's surface into grid cells using geohashes. Each cell is a fixed-size region (e.g., geohash precision 6 = ~1.2 km × 0.6 km). Maintain an in-memory index mapping each geohash cell to a list of drivers currently in that cell.
import geohash2
class DriverLocationIndex:
def __init__(self):
# geohash -> set of driver_ids
self.grid = defaultdict(set)
# driver_id -> (lat, lng, geohash, timestamp)
self.drivers = {}
def update_location(self, driver_id, lat, lng, timestamp):
new_hash = geohash2.encode(lat, lng, precision=6)
# Remove from old cell if moved
if driver_id in self.drivers:
old_hash = self.drivers[driver_id][2]
if old_hash != new_hash:
self.grid[old_hash].discard(driver_id)
self.grid[new_hash].add(driver_id)
self.drivers[driver_id] = (lat, lng, new_hash, timestamp)
def find_nearby_drivers(self, lat, lng, radius_km=3):
center_hash = geohash2.encode(lat, lng, precision=6)
neighbors = geohash2.neighbors(center_hash)
search_cells = [center_hash] + neighbors
candidates = []
for cell in search_cells:
for driver_id in self.grid.get(cell, []):
d_lat, d_lng, _, _ = self.drivers[driver_id]
dist = haversine(lat, lng, d_lat, d_lng)
if dist <= radius_km:
candidates.append((driver_id, dist))
return sorted(candidates, key=lambda x: x[1])
Alternative approaches include QuadTrees, R-Trees, or using Redis GEO commands (GEOADD/GEOSEARCH) for simplicity at smaller scale. Uber uses a custom spatial index called H3 (hexagonal hierarchical geospatial indexing).
4.2 Matching Algorithm (Dispatch)
When a rider requests a ride, the Matching Service must find the best driver. The algorithm considers:
- Proximity: Distance from driver to pickup point.
- ETA: Actual road-network ETA (not straight-line distance).
- Driver rating and acceptance rate.
- Vehicle type match: UberX, XL, Black, etc.
- Driver heading: A driver heading toward the rider is preferred over one heading away.
function matchRider(rideRequest) {
// Step 1: Find candidates within radius
const candidates = locationIndex.findNearby(
rideRequest.pickupLat, rideRequest.pickupLng,
radiusKm: 5, vehicleType: rideRequest.vehicleType,
status: "available"
);
if (candidates.length === 0) {
return { status: "no_drivers_available" };
}
// Step 2: Calculate ETA for top 10 nearest
const topCandidates = candidates.slice(0, 10);
const etas = await Promise.all(
topCandidates.map(d => etaService.getETA(d.lat, d.lng,
rideRequest.pickupLat, rideRequest.pickupLng))
);
// Step 3: Score and rank
const scored = topCandidates.map((driver, i) => ({
driverId: driver.id,
score: computeScore(etas[i], driver.rating, driver.acceptanceRate),
eta: etas[i]
}));
scored.sort((a, b) => b.score - a.score);
// Step 4: Send request to best driver (with timeout)
return dispatchToDriver(scored[0], rideRequest, timeoutSeconds: 15);
}
If the first driver does not accept within 15 seconds, the request cascades to the next best driver. Uber's actual dispatch system uses a batch matching approach, solving a global optimization problem every few seconds to maximize overall efficiency.
4.3 Surge Pricing
When demand exceeds supply in an area, prices increase to incentivize more drivers to come online and to reduce rider demand.
function calculateSurgeMultiplier(geohashCell) {
const demand = rideRequests.countLast5Min(geohashCell);
const supply = availableDrivers.count(geohashCell);
const ratio = demand / Math.max(supply, 1);
if (ratio <= 1.0) return 1.0; // No surge
if (ratio <= 1.5) return 1.25; // Mild surge
if (ratio <= 2.0) return 1.5;
if (ratio <= 3.0) return 2.0;
return Math.min(ratio, 5.0); // Cap at 5x
}
Surge is calculated per geohash cell and updated every 1-2 minutes. The surge multiplier is shown to the rider before they confirm the request.
4.4 ETA Calculation
ETA is calculated using routing algorithms on road-network graphs. The ETA Service considers:
- Road network distance (not straight-line distance).
- Real-time traffic data from active driver GPS traces.
- Historical traffic patterns (time of day, day of week).
- Turn penalties, one-way streets, traffic signals.
Uber uses a modified Dijkstra's algorithm with contraction hierarchies for fast shortest-path queries. Historical and real-time traffic data is combined to estimate travel time on each road segment.
4.5 Real-Time Location Tracking
During an active trip, the rider sees the driver's location update in real-time:
- Driver app sends GPS coordinates every 4 seconds to the Location Service.
- Location Service publishes updates to Kafka, partitioned by trip_id.
- A WebSocket connection between the rider's app and the server streams location updates.
- The rider's app interpolates between location points for smooth animation.
5. Database Schema
CREATE TABLE riders (
id BIGINT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
phone VARCHAR(20) UNIQUE NOT NULL,
email VARCHAR(255),
rating DECIMAL(3,2) DEFAULT 5.00,
payment_method_id BIGINT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE drivers (
id BIGINT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
phone VARCHAR(20) UNIQUE NOT NULL,
license_number VARCHAR(50) NOT NULL,
vehicle_type ENUM('uberx','uberxl','uberblack','uberpool'),
vehicle_make VARCHAR(50),
vehicle_model VARCHAR(50),
vehicle_plate VARCHAR(20),
rating DECIMAL(3,2) DEFAULT 5.00,
status ENUM('offline','available','on_trip','matching') DEFAULT 'offline',
current_lat DECIMAL(9,6),
current_lng DECIMAL(9,6),
current_geohash VARCHAR(12),
last_location_update TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_drivers_geohash ON drivers(current_geohash, status);
CREATE TABLE trips (
id BIGINT PRIMARY KEY,
rider_id BIGINT NOT NULL,
driver_id BIGINT,
status ENUM('requested','matching','driver_assigned','en_route','arrived',
'in_progress','completed','cancelled') DEFAULT 'requested',
vehicle_type ENUM('uberx','uberxl','uberblack','uberpool'),
pickup_lat DECIMAL(9,6) NOT NULL,
pickup_lng DECIMAL(9,6) NOT NULL,
pickup_address TEXT,
dropoff_lat DECIMAL(9,6) NOT NULL,
dropoff_lng DECIMAL(9,6) NOT NULL,
dropoff_address TEXT,
surge_multiplier DECIMAL(3,2) DEFAULT 1.00,
estimated_fare_cents BIGINT,
actual_fare_cents BIGINT,
distance_meters INT,
duration_seconds INT,
requested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
matched_at TIMESTAMP,
pickup_at TIMESTAMP,
dropoff_at TIMESTAMP,
cancelled_at TIMESTAMP
);
CREATE INDEX idx_trips_rider ON trips(rider_id, requested_at DESC);
CREATE INDEX idx_trips_driver ON trips(driver_id, requested_at DESC);
CREATE TABLE location_history (
driver_id BIGINT NOT NULL,
timestamp TIMESTAMP NOT NULL,
lat DECIMAL(9,6) NOT NULL,
lng DECIMAL(9,6) NOT NULL,
heading SMALLINT,
speed_kmh SMALLINT,
trip_id BIGINT,
PRIMARY KEY (driver_id, timestamp)
);
Location history is stored in a time-series database (like Cassandra or TimescaleDB) optimized for high write throughput and time-range queries. See SQL vs NoSQL for choosing the right store.
6. Key Trade-offs
| Decision | Trade-off |
|---|---|
| Geohash vs QuadTree vs H3 | Geohash is simple but has edge-case issues at cell boundaries. QuadTree adapts to density but is complex. H3 (hexagonal grid) provides uniform distances from center to edges. Uber chose H3 for its superior spatial properties. |
| Nearest driver vs global optimization | Greedily matching the nearest driver is simple but globally suboptimal. Batch matching (collecting requests and drivers over a 2-sec window and solving an assignment problem) yields better overall wait times. Uber uses batch matching. |
| WebSocket vs polling for location updates | WebSocket provides real-time updates but requires persistent connections. Polling is simpler but adds latency and server load. For active trips, WebSocket is worth the connection cost. |
| Strong vs eventual consistency for driver status | Driver availability must be strongly consistent to prevent double-matching. Use a distributed lock or optimistic concurrency control when assigning a driver to a ride. |
7. Scaling Considerations
7.1 Location Service Scaling
1.25M location updates/sec is a massive write load. Use Kafka as the ingestion layer (partitioned by driver_id). Location Service consumers update the in-memory spatial index and write to the time-series database. Shard the spatial index by geographic region across multiple servers. Use consistent hashing to distribute geohash cells across index servers.
7.2 City-Level Isolation
Uber runs isolated services per city/region. A ride in New York never queries driver data in San Francisco. This provides natural sharding and failure isolation. Each city's services can be scaled independently based on local demand.
7.3 Handling Peak Demand
Events (concerts, sports games, holidays) cause demand spikes. Strategies: (1) Auto-scale matching and location services based on ride request rate. (2) Pre-compute surge zones. (3) Notify offline drivers of high-demand areas. (4) Cache ETA computations for popular routes during peak times.
Use swehelper.com tools to practice geospatial system design problems.
8. Frequently Asked Questions
Q1: How does Uber prevent a driver from being matched to two riders simultaneously?
When a driver is being considered for a match, the system uses optimistic concurrency control. The Matching Service attempts to atomically update the driver's status from "available" to "matching" using a compare-and-swap operation. If two ride requests try to match the same driver simultaneously, only one CAS succeeds. The failed request retries with the next best candidate. This ensures a driver is never double-booked.
Q2: How is surge pricing calculated and how quickly does it update?
Surge pricing is calculated per geographic cell (geohash or H3 hexagon) by comparing the number of ride requests (demand) to available drivers (supply) in that cell over a rolling 5-minute window. The surge multiplier is recalculated every 1-2 minutes. The multiplier is displayed to riders before they confirm, and once confirmed, the surge rate is locked for that trip even if it changes before the driver arrives.
Q3: How does Uber handle driver location updates at 1 million+ per second?
Location updates flow through Kafka (high-throughput message queue) partitioned by driver_id. Consumer services update an in-memory spatial index for matching queries and write to a time-series database for historical storage. The spatial index is sharded by geographic region across multiple servers. Each location update is small (~200 bytes), making the bandwidth manageable.
Q4: Why does Uber use H3 hexagonal indexing instead of geohash?
Geohash rectangles have a problem: the distance from the center to the edge varies (corners are farther than sides), causing inconsistent search radii. H3 hexagons have a nearly uniform distance from center to all edges, making proximity searches more consistent. H3 also supports hierarchical resolution levels (0-15) and efficient neighbor traversal. These properties make it superior for ride-matching where consistent distance calculations are critical.