TechLead
Lesson 22 of 30
6 min read
System Design

System Design: Ride-Sharing Service

Design a ride-sharing service like Uber or Lyft covering driver matching, geospatial indexing, ETA calculation, and surge pricing algorithms.

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.

Continue Learning