Uber System Design
Designing a large-scale ride-hailing platform such as Uber or Ola requires building a highly distributed, real-time system that seamlessly connects millions of riders and drivers across diverse geographic regions. These platforms function as dynamic marketplaces, continuously processing live location updates, demand spikes, pricing decisions, and matching operations β all under strict latency, availability, and reliability constraints.
At its core, such a system must efficiently solve challenges in real-time location tracking, low-latency proximity search, elastic scaling, fault tolerance, and consistency management, while operating reliably under unpredictable traffic patterns and regional surges.
Functional Requirements
I have categorized the functional requirements from the users' point of view. There are two types of users in Uber/Ola-like systems: riders and drivers.
The core user flows are:
- The rider searches for a cab using a source & destination.
- The system shows ETA & fare estimates for each cab type (Go, Moto, Sedan, etc.).
- The rider books a ride.
- The system finds nearby drivers.
- Ride requests are sent to the drivers.
- A driver accepts / denies the request.
- The driver navigates to the pickup location.
- The rider is verified using an OTP.
- The trip is executed β drop-off.
- The rider makes the payment.
Out of Scope
- Profile creation
- Ratings & reviews
- Notification service internals
- Monitoring / analytics
Non-Functional Requirements
Availability vs. Consistency
Different subsystems require different guarantees:
- Highly Available β ETA, fare estimates
- Highly Consistent β Driver matching (to avoid double booking)
Latency Sensitivity
- Location updates must be near real-time.
- Matching decisions must be fast.
- UI interactions should feel instantaneous.
Durability
- Ride lifecycle data must persist.
- Payments & trip records cannot be lost.
Scalability
- The system must handle peak traffic (rush hours).
- It must support rapid geographic expansion.
Core Entities
- Rider
- Driver
- Ride
- Estimate
- Location
- Cab
APIs
Fare Estimate
GET /estimates?src_lat={}&src_long={}&dest_lat={}&dest_long={}
Output β estimateId, fare, ETA (per cab type)
Book a Ride
POST /rides
{
"estimateId": "...",
"cabType": "sedan"
}
Location Update
POST /location/update
{
"latitude": ...,
"longitude": ...
}
Driver Accepts/Denies Ride Request
PATCH /rides/driver/accept
{
"rideId": "...",
"accept": true
}
Driver Updates The Ride For Pickup and Drop-off
PATCH /rides/driver/update
{
"rideId": "...",
"update": "pickedUp"
}
Make Payment
POST /payments
{
"rideId": "...",
"paymentInfo": {}
}
Scale Estimation
Let's assume the following numbers for riders, drivers, and rides:
total riders
daily active riders
1 million drivers
Ride requests:
rides/day β rides per second = ~200 TPS (assuming there are seconds in a day)
Peak TPS (~3Γ) β ~600 TPS
Driver location updates:
Assume drivers update their locations every 5 seconds. β requests per second = TPS (write-heavy workload)
High-Level Design
Core Services Overview
Let's set aside the huge scale and high TPS requirements for now and first come up with a very high-level design.
-
API Gateway β Entry point handling authentication, authorization, routing, rate limiting, and request orchestration.
-
Estimate Service β Accepts source and destination locations from riders and computes fare estimates and ETAs using distance, traffic data, and pricing logic.
-
Ride Service β Accepts ride requests from riders, manages the ride lifecycle, persists ride state, and coordinates downstream workflows. The Ride Service calls the Driver Matching Service to find a suitable driver for the ride. When the ride is complete, it also invokes the Payment Service to accept payment.
-
Driver Matching Service β Finds suitable nearby drivers and controls ride assignment with consistency guarantees. Driver matching is a complex task, so introducing a separate service makes sense. It separates the ride creation/update task from driver matching.
-
Location Service β Ingests high-frequency driver location updates and maintains the latest coordinates.
-
Payment Service β Processes ride payments, charges users, and records transaction outcomes.

There is something wrong with the above image. We need a WebSocket layer, which we will add in the next section. Before that, let's go through the overall flow.
Basic Uber / Ola Ride Flow
1. Rider Requests an Estimate
- The rider enters the source & destination.
- The Estimate Service calculates the ETA & fare using distance, traffic data, and pricing logic.
2. Rider Books a Ride
- The rider selects a cab type.
- The Ride Service creates a ride with
status = REQUESTED. - The Ride Service asks the Driver Matching Service to find a driver.
3. Driver Discovery
- The Driver Matching Service searches for nearby drivers.
4. Driver Allocation
- The Driver Matching Service sends requests to nearby drivers.
- The first driver to accept gets the ride.
- The Driver Matching Service sends the driver's details to the Ride Service.
5. Ride Confirmation
- The Ride Service updates the ride β
ACCEPTED, with the driver ID. - The rider & driver receive the ride details.
6. Driver Navigation
- The driver moves to the pickup location.
- Real-time location updates are sent via WebSocket.
7. Trip Start (OTP / Verification)
- The driver verifies the rider.
- Ride status β
STARTED
8. Trip Completion
- The driver reaches the destination and completes the trip.
- Ride status β
FINISHED
9. Payment Processing
- After the ride is completed, the Ride Service invokes the Payment Service to accept payment.
- The rider completes the payment via the Payment Service.
- The payment status is recorded.
10. Post-Ride Updates
- The driver is marked as available.
- The trip is stored for history & analytics.
Driver Matching Logic
Let's start with a naive driver matching logic.
The rider sends the source and destination locations. Every location has a latitude and longitude (shown here as x and y on the world map).
How do we find drivers near the rider? Let's say the service finds drivers within a 2 km range of the rider's source location. A database query scans for drivers within the coordinate range.
Example query pattern:
SELECT driver_id, latitude, longitude FROM location WHERE latitude BETWEEN (user_lat - radius) AND (user_lat + radius) AND longitude BETWEEN (user_long - radius) AND (user_long + radius);
At Uber's scale, this will definitely fail because:
- Traditional databases are not optimized for multi-dimensional geo queries. These databases typically use only one index efficiently per query. Combined latitude + longitude range filtering becomes costly. Even if you filter by latitude and longitude one after another, the queries return large intermediate result sets. Result β inefficient execution plans and rising latency.
- Many drivers fall inside the bounding box. The database must scan and post-filter large portions of the table, so query latency increases non-linearly with scale.
- Range queries produce a square region, not an actual radius. Drivers near the corners may be far away. Solving this requires extra distance calculations, which is significant wasted computation.
We will come up with better approaches in the next sections.
Why do we need a WebSocket connection?
Ride-hailing systems involve continuous real-time interactions such as:
- Frequent driver location updates
- Dispatching ride requests to drivers
- Instant ride lifecycle updates (accepted, arrived, started, finished)
These workflows demand low-latency communication and immediate message delivery.
Traditional polling mechanisms introduce unnecessary overhead, while external notification systems may add delays.
Why Not Use SSE (Server-Sent Events)?
Although SSE supports server-to-client streaming, it has critical limitations:
- Communication is unidirectional (server β client only).
- It requires separate APIs for client-to-server events.
- Its behavior is inconsistent across mobile environments.
- It is less suitable for highly interactive systems.
Ride-hailing platforms require both parties to exchange events dynamically, which SSE does not handle efficiently. So we can use a bidirectional connection such as WebSocket.
WebSocket Infrastructure β Enables low-latency bidirectional communication for live updates and dispatch events.
-
WebSocket Manager β Maintains a registry of active client connections and routes real-time events to the correct sockets.
-
WebSocket Handler β Owns individual WebSocket connections and handles incoming/outgoing messages for a connected client.

WebSocket Interaction Flow
Driver Matching
-
Driver Matching Service identifies candidate drivers for a ride.
-
It sends a dispatch event to the WebSocket Manager targeting specific driver IDs.
-
WebSocket Manager looks up active connections (driverId β socket).
-
The Manager forwards the event to the appropriate WebSocket Handlers.
-
WebSocket Handlers push the ride request to the driverβs live WebSocket connection.
-
The driver's response (accept / deny) flows back through Handlers β Manager β Driver Matching Service.
Deep Dive: Handling Frequent Driver Location Updates
Assume there are 1 million daily active drivers, each sending a location update every 5 seconds:
location updates in every 5 seconds
= location updates every second = TPS (which is huge)
Accepting Updates β HTTP vs. WebSocket
HTTP (REST / Polling)
- Each update requires full requestβresponse overhead.
- Headers & protocol costs are repeated.
- Latency is higher under heavy load.
- It is inefficient for small, frequent messages.
HTTP is suitable for infrequent, stateless communication.
WebSocket
- A persistent connection (no repeated handshake)
- Minimal framing overhead per message
- Very low-latency delivery
- Efficient for continuous updates
WebSocket is ideal for high-frequency, real-time streaming workloads like driver locations.
High TPS Storage Strategy
Driver updates generate extremely high write throughput: 200k TPS.
- A SQL database supports a maximum of 10k TPS (single node).
- DynamoDB supports up to 100k+.
- Redis supports up to 1 million TPS.
Can we batch the updates to the database to reduce write overhead?
Batching introduces a delay in driver location updates -> so driver matching might not be accurate.
NaΓ―ve approach:
Our naive approach was to write every update to the primary database, which leads to bottlenecks & latency spikes. How can we do better?
Consider the key observation below.
Key observation:
- The matching logic needs only the drivers' latest location, not their full history.
So why don't we store the latest location in a cache and the historical location details in a database?
But Can We Store the Latest Location of 1 Million Drivers in a Cache?
How much storage does it need?
What data are we storing per driver?
For the latest location only, you typically store:
-
driver_id β 8 bytes
-
latitude β 8 bytes
-
longitude β 8 bytes
No history, no metadata.
That is approximately β 24 bytes per driver.
Even if we consider 50-100 bytes per driver:
For the latest locations of 1 million drivers, we need β drivers Γ 100 bytes = bytes = 100 MB (which can easily fit into cache)
Recommended Pattern
Fast Path (Hot Data)
- Store the latest driver coordinates in Redis / an in-memory cache
- Supports massive TPS
- Optimized for geo queries
Durable Path (Cold Data)
- Persist location events asynchronously (Cassandra / DynamoDB)
- Used for analytics & recovery
- Does not impact real-time operations

Resulting Benefits
β Handles high TPS efficiently
β Keeps matching latency low
β Avoids database overload
β Improves system resilience
Deep Dive: Driver Matching Logic
Earlier, we saw why naΓ―ve database range queries fail at scale.
Once we move latest driver locations to Redis (geo-indexed cache), we can use spatial partitioning techniques to make proximity search efficient.
Two common ideas are:
- Geohashing
- Quad Trees
Geohashing
Geohashing is a method for encoding geographic coordinates (latitude and longitude) into a compact, hierarchical string. It enables efficient spatial indexing, proximity searches, and location-based queries.
Instead of treating the world as continuous coordinates, geohashing:
- Divides the world into grid cells
- Encodes each cell in binary
- Converts binary into Base32 characters
- Produces a short hash string
Grid Subdivision
Geohashing works by recursively splitting space:
-
Level 1: The world is divided into four large cells
Binary identifiers:00,01,10,11 -
Level 2+: Each cell is subdivided again into four smaller cells.
Every subdivision adds more precision.
Binary Encoding
Each split alternates between:
- Longitude division
- Latitude division
This generates a sequence of bits (a binary sequence such as β 011010011...) describing the position of a point within the grid.
The binary bits are grouped into chunks of 5 and then converted into Base32 characters:
1001 10110 01001 10001 10000 10111 β 9q9jhr (Base 32), a human-friendly GeoHash
Properties:
- A similar prefix means spatial proximity.
- A shorter hash means a larger region.
- A longer hash means a smaller region.
How it helps driver matching:
- Each driver's location is stored with its geohash.
- A rider request arrives with a source location.
- Compute the rider's geohash.
- Fetch drivers from:
- The same hash cell
- Adjacent hash cells
- Sort by exact distance.
- Pick the closest drivers.
Why this works:
β Limits the search space
β Avoids full table scans
β Constant-time lookup in the cache
Redis directly supports this via:
GEOADDGEOSEARCH / GEORADIUS
Quad Tree Indexing
A Quad Tree recursively partitions space based on data density.
Key intuition:
- The entire map = one big cell.
- If there are too many points inside a cell β split it into 4 subcells.
- Repeat until the density is manageable.
Start with the root region (the entire space).
-
Points present: A, B, C, D, E, F
-
Too many points β split into 4 quadrants
For simplicity, in the diagram above, consider more than one point to be too many points. After the split:
Top-left quadrant β contains A β Only one point β no split
Bottom-left quadrant β contains B β Only one point β no split
Top-right quadrant β contains C and D β More than one point β must split
Bottom-right quadrant β contains E and F β More than one point β must split
- Splitting the C/D region
That quadrant is subdivided again. Now C and D fall into different child cells.
β Each child now has β€ 1 point β stop splitting
Why the split happened: local density > capacity.
- Splitting the E/F region
The same logic applies: too many points (more than one here, for simplicity) in the E/F region β subdivision required.
After the split, both E and F fall in the north-left division, so that north-left division has more than one point.
Hence, subdivision is required again. Once the points are separated into smaller cells β stop.
A Quadtree Avoids Unnecessary Subdivision
This is the core efficiency advantage.
Big Insight: Adaptive Resolution
Notice:
- Sparse areas β large cells
- Dense areas β small cells
This mirrors real systems:
Downtown β deeply subdivided
Suburbs β shallow tree
Why This Matters in Systems (Uber / Maps / Games)
Splits occur only where needed, improving:
β Query speed β Memory usage β Spatial locality β Scalability
This avoids wasting computation on empty space.
Deep Dive: Updated Driver Matching Flow
Drivers continuously stream their latest GPS coordinates to the backend.
Driver App β Location Service β Cache (Redis)
GEOADD active_drivers <lon> <lat> <driver_id>
The list of drivers is fetched from the chunks adjacent to the source location's chunk.
Key Properties
- Always stores the latest position
- Overwrites the previous location
- Optimized for high-frequency updates
- Redis acts as a real-time location cache
When a rider requests a trip, the system performs a proximity search within Redis.
Step 1: Ride Request
The rider sends the source and destination locations. Ride Service β Driver Matching Service
src_lat, src_lon, dst_lat, dst_lon
Step 2: Proximity Search in Redis
GEOSEARCH active_drivers
FROMLONLAT <src_lon> <src_lat>
BYRADIUS <r> km
WITHDIST
COUNT N
ASC
Redis returns:
- Nearby drivers
- Sorted by distance
- Low-latency lookup
Step 3: Candidate Filtering
The matching service may apply:
β Driver availability checks β ETA constraints β Business rules (rating, surge, etc.)
Step 4: Driver Dispatch
The Driver Matching Service asks drivers to accept the ride (using the WebSocket connection).
Step 5: Acceptance Handling
If a driver accepts the ride, then on receiving the driver's response, the Driver Matching Service updates the Ride Service with the matched driver ID.
The Ride Service assigns the driver_id to the ride entry, and the ride status is changed to ACCEPTED.
The Ride Service notifies the rider that a driver has been found.

What happens in case of Redis failure?
Redis is distributed, and failure is very rare. Even so, we should have robust monitoring and failover strategies for that case.
The system also recovers quickly after a failure, because drivers send location updates every 5 seconds.
Driver Matching Consistency (Preventing Double Booking)
A ride-sharing system must guarantee matching correctness under extreme concurrency.
Two critical failure scenarios exist:
- One ride β multiple drivers
- One driver β multiple rides
Both must be prevented.
Problem 1: One Ride, Multiple Drivers
We must ensure:
β Only one driver can accept a ride
β No duplicate assignments
β No race conditions across servers
Solution: Partition by Ride ID
The Driver Matching Service is sharded using consistent hashing on ride_id.
The Driver Matching Service sends requests to drivers sequentially:
- If one driver denies, it sends the request to the next driver.
- So no two drivers will be booked at the same time for one ride.
Implication:
β A single service instance owns a ride
β No cross-instance coordination needed
β No duplicate dispatch decisions
Flow:
- A ride request arrives.
- Hash(
ride_id) β matching instance - The instance sends requests to drivers.
Problem 2: One Driver, Multiple Rides (Harder Problem)
Even with ride-based sharding:
β Consider two different rides that went to two different instances.
β Both instances query Redis.
β The same driver may appear in both results.
Without protection:
β A driver could receive multiple requests.
β The driver accepts one β the others become invalid.
β Risk of double booking
Why Instance-Level Locks Fail
Locking inside a service instance:
β Does not work across instances
β Cannot prevent distributed races
Multiple instances may still target the same driver.
Why DB-Based Locks Are Dangerous
Updating the driver state in the database:
β Slow (high write latency)
β Requires cleanup logic
β Risk of stale locks on crashes
Example failure:
The driver state is OUTSTANDING_REQUEST, and the service crashes. The driver is stuck in that state.
This requires cron / repair jobs to clean up, which is undesirable overhead.
Correct Solution: Distributed Lock in Redis (With TTL)
Redis provides a fast, atomic locking primitive.
When dispatching a request:
SET driver_lock:<driver_id> ride_id NX EX 10
β NX β Only acquire if not locked β EX 10 β Auto-expires after 10 seconds
For each candidate driver, the Driver Matching Service attempts to acquire a Redis lock.
-
If the lock fails β skip the driver.
-
If the lock succeeds β send the request.
-
Only one ride globally can target that driver.
Why TTL Is Essential?
Drivers may ignore requests or services may crash.
TTL guarantees:
- No permanent lock
- Automatic recovery
- No cleanup jobs required
If the driver accepts:
- Update the driver state to IN_RIDE.
- Remove the lock / let it expire (the driver state now ensures that we don't send requests to that driver).
If the driver rejects / times out:
- Delete the lock immediately (an optional optimization).
How Frequently Do Drivers Need to Send Location Updates?
- On the client, we can use an adaptive strategy (on-device sensors and a client algorithm).
- If the driver is not moving, don't send updates.
- If the driver is moving slowly, send less frequent updates.
Scaling the Ride Service
The Ride Service operates on the critical path of every trip request and must support extreme concurrency, burst traffic, and geographically skewed demand.
The Ride Service should be designed as stateless.
Implications:
- Any instance can process any request.
- Horizontal scaling becomes straightforward.
- No session affinity or sticky routing is needed.
- Failures are easier to recover from.
All persistent state is stored externally:
- Ride metadata β Database
- Driver availability β Cache / Redis
- Event history β Durable log (Kafka / DynamoDB / Cassandra)
Queue-Based Load Regulation
Instead of invoking downstream services synchronously, we can introduce a queue between them.
Ride Service β Queue β Driver Matching Service
Introducing a queue provides:
- Burst absorption during traffic spikes
- Backpressure protection
- Controlled processing rate
- Failure isolation
Without queuing:
- β Sudden demand spikes can overload matching systems
- β Cascading failures become likely
With queuing, the system remains stable under peak load.

Large-scale ride-sharing systems must handle geographically skewed traffic while maintaining low latency and high availability. Geo sharding and multiβdata center deployment are core strategies to achieve this.
Geo Sharding
Geo sharding partitions the system based on geographic regions such as:
β Country
β City
β Region / grid cell
Each shard is responsible for rides, drivers, and users within its region.
Why Geo Sharding?
β Requests are processed close to users
β Lower network latency
β Independent scaling per region
β Failure isolation (smaller blast radius)
Most ride operations are local, making geographic partitioning a natural fit. GeoSharding handles scale and locality.
MultiβData Center Deployment
Each geographic shard is deployed across multiple data centers within the region.
Typical setup:
β Activeβactive data centers
β A load balancer routes traffic to the nearest DC
β Shared regional data stores
Benefits
β High availability
β Disaster recovery
β Zero or minimal downtime
β Resilience to DC-level failures
Multiβdata center support handles availability and resilience.
Data Consistency Model
β Strong consistency within a region
β Eventual consistency across regions
Cross-region replication is used for:
β Analytics
β Reporting
β Machine learning models