Designing a Ride-Sharing Service
Ride-sharing platforms like Uber and Lyft connect riders with drivers in real time. These systems must handle millions of concurrent location updates, perform fast geospatial queries, calculate accurate ETAs, and dynamically adjust pricing. The architecture requires careful balancing of real-time performance, reliability, and scalability.
Core Requirements
- Functional: Request a ride, match with a nearby driver, track trip in real time, calculate fare, process payment
- Non-Functional: Sub-second matching, real-time location updates every 3-4 seconds, 99.99% availability, global scale
- Scale: ~1 million active drivers, ~10 million trips/day, ~10 million location updates/minute
High-Level Architecture
The system is divided into several core services: a ride matching service, a location service, a trip service, a pricing service, and a notification service. Each operates independently and communicates through a combination of synchronous APIs and asynchronous events.
| Service | Responsibility | Key Technology |
|---|---|---|
| Location Service | Ingest and store driver locations in real time | Redis with geospatial indexes |
| Matching Service | Find and assign the best driver for a ride request | Custom algorithm + location data |
| Trip Service | Manage trip lifecycle from request to completion | PostgreSQL + state machine |
| Pricing Service | Calculate fares and apply surge pricing | Real-time demand analysis |
| Notification Service | Push updates to riders and drivers | WebSocket + push notifications |
Matching Riders with Drivers
The matching algorithm is the heart of the system. When a rider requests a ride, the system must find the best available driver considering distance, estimated arrival time, driver rating, and vehicle type.
interface RideRequest {
riderId: string;
pickupLocation: GeoPoint;
dropoffLocation: GeoPoint;
rideType: "ECONOMY" | "PREMIUM" | "XL";
timestamp: Date;
}
interface DriverCandidate {
driverId: string;
location: GeoPoint;
distanceKm: number;
etaMinutes: number;
rating: number;
vehicleType: string;
acceptanceRate: number;
}
async function matchDriver(request: RideRequest): Promise<DriverCandidate | null> {
// Step 1: Find nearby available drivers within radius
let searchRadiusKm = 3;
let candidates: DriverCandidate[] = [];
while (candidates.length < 5 && searchRadiusKm <= 10) {
candidates = await locationService.findNearbyDrivers(
request.pickupLocation,
searchRadiusKm,
request.rideType
);
searchRadiusKm += 2;
}
if (candidates.length === 0) return null;
// Step 2: Calculate ETA for each candidate
const withETA = await Promise.all(
candidates.map(async (driver) => ({
...driver,
etaMinutes: await etaService.calculate(
driver.location,
request.pickupLocation
),
}))
);
// Step 3: Score and rank candidates
const scored = withETA.map((driver) => ({
...driver,
score: calculateMatchScore(driver),
}));
scored.sort((a, b) => b.score - a.score);
// Step 4: Send request to top candidate
return scored[0];
}
function calculateMatchScore(driver: DriverCandidate): number {
// Weighted scoring: lower ETA and higher rating = better score
const etaScore = Math.max(0, 100 - driver.etaMinutes * 10);
const ratingScore = driver.rating * 20;
const acceptanceScore = driver.acceptanceRate * 50;
return etaScore * 0.5 + ratingScore * 0.3 + acceptanceScore * 0.2;
}Location Tracking and Geospatial Indexing
Drivers send location updates every 3-4 seconds. With 1 million active drivers, this is approximately 300,000 updates per second. The system must ingest these updates and support fast geospatial queries.
// Redis Geospatial Commands for driver location
// GEOADD drivers longitude latitude driverId
// GEORADIUS drivers longitude latitude radius km
interface GeoPoint {
latitude: number;
longitude: number;
}
class LocationService {
private redis: RedisClient;
async updateDriverLocation(
driverId: string,
location: GeoPoint
): Promise<void> {
// Store in Redis geospatial index
await this.redis.geoadd(
"active_drivers",
location.longitude,
location.latitude,
driverId
);
// Also publish to location stream for trip tracking
await this.redis.xadd("driver_locations", "*", {
driverId,
lat: location.latitude.toString(),
lng: location.longitude.toString(),
timestamp: Date.now().toString(),
});
}
async findNearbyDrivers(
point: GeoPoint,
radiusKm: number,
rideType: string
): Promise<DriverCandidate[]> {
const results = await this.redis.georadius(
"active_drivers",
point.longitude,
point.latitude,
radiusKm,
"km",
"WITHCOORD",
"WITHDIST",
"ASC",
"COUNT",
20
);
// Filter by availability and vehicle type
const available = await this.filterAvailable(results, rideType);
return available;
}
}For very large scale, you can shard drivers by geographic region using geohashes or S2 cells. Each cell maps to a specific Redis instance, so a query only hits the relevant shard.
ETA Calculation
ETA calculation is more complex than simple distance division. Accurate ETAs require road network data, real-time traffic, historical patterns, and machine learning models.
- Road network graph: Use OpenStreetMap data to build a weighted graph of roads with distances and speed limits
- Shortest path: Use Dijkstra's or A* algorithm for route calculation
- Traffic adjustment: Multiply segment travel times by real-time traffic factors
- Historical patterns: Use ML models trained on past trip data to predict travel times for specific routes at specific times
- Segment-level prediction: Break routes into segments and predict each segment's travel time independently for better accuracy
Surge Pricing Algorithm
Surge pricing balances supply and demand. When rider demand exceeds available drivers in an area, prices increase to incentivize more drivers and reduce demand.
interface SurgeZone {
zoneId: string;
boundary: GeoPoint[];
currentMultiplier: number;
activeDrivers: number;
pendingRequests: number;
recentCompletedTrips: number;
}
function calculateSurgeMultiplier(zone: SurgeZone): number {
const demandSupplyRatio = zone.pendingRequests / Math.max(zone.activeDrivers, 1);
// Tiered surge pricing
if (demandSupplyRatio < 1.0) return 1.0; // No surge
if (demandSupplyRatio < 1.5) return 1.2;
if (demandSupplyRatio < 2.0) return 1.5;
if (demandSupplyRatio < 3.0) return 2.0;
if (demandSupplyRatio < 4.0) return 2.5;
return 3.0; // Cap at 3x
// In production, use a smooth function rather than tiers:
// return Math.min(3.0, 1.0 + Math.log2(demandSupplyRatio) * 0.8);
}
// Recalculate surge every 2 minutes per zone
async function updateSurgePricing(): Promise<void> {
const zones = await getActiveZones();
for (const zone of zones) {
const drivers = await locationService.countDriversInZone(zone);
const requests = await tripService.countPendingInZone(zone);
zone.activeDrivers = drivers;
zone.pendingRequests = requests;
zone.currentMultiplier = calculateSurgeMultiplier(zone);
await saveSurgeZone(zone);
}
}Trip Management
Each trip follows a well-defined state machine. The trip service manages transitions and ensures consistency across all components.
enum TripState {
REQUESTED = "REQUESTED", // Rider requested a trip
DRIVER_ASSIGNED = "DRIVER_ASSIGNED", // Driver matched
DRIVER_EN_ROUTE = "DRIVER_EN_ROUTE", // Driver heading to pickup
ARRIVED = "ARRIVED", // Driver at pickup location
IN_PROGRESS = "IN_PROGRESS", // Trip started
COMPLETED = "COMPLETED", // Trip finished
CANCELLED = "CANCELLED", // Cancelled by rider or driver
}
// Valid state transitions
const VALID_TRANSITIONS: Record<TripState, TripState[]> = {
[TripState.REQUESTED]: [TripState.DRIVER_ASSIGNED, TripState.CANCELLED],
[TripState.DRIVER_ASSIGNED]: [TripState.DRIVER_EN_ROUTE, TripState.CANCELLED],
[TripState.DRIVER_EN_ROUTE]: [TripState.ARRIVED, TripState.CANCELLED],
[TripState.ARRIVED]: [TripState.IN_PROGRESS, TripState.CANCELLED],
[TripState.IN_PROGRESS]: [TripState.COMPLETED],
[TripState.COMPLETED]: [],
[TripState.CANCELLED]: [],
};Real-Time Updates via WebSocket
Both riders and drivers need real-time updates. WebSocket connections provide low-latency bidirectional communication for location tracking, trip status changes, and notifications.
WebSocket Architecture
- Connection management: Use a WebSocket gateway (e.g., Socket.IO cluster) that handles millions of concurrent connections
- Pub/Sub backbone: Use Redis Pub/Sub or Kafka to route messages to the correct WebSocket server instance
- Channel-based routing: Each active trip has a channel. Both rider and driver subscribe to their trip channel
- Fallback: If WebSocket fails, fall back to long polling or push notifications
- Heartbeat: Send periodic pings to detect stale connections and clean up resources
Key Design Trade-offs
Location update frequency: More frequent updates mean better accuracy but higher bandwidth and server costs. 3-4 second intervals provide a good balance. During active trips, you can increase frequency to 1-2 seconds for better tracking.
Matching strategy: Sending the request to the single best driver is simpler but risks rejection. Broadcasting to multiple drivers and taking the first acceptance reduces wait time but can cause coordination issues.