Skip to content
ExponaBLOG
ENGINEERING CRAFT · DAY 020 / 100

Design a ride-hailing backend: best practices explained

What I learned building live tracking and dispatch.

Expona Admin8 min read

Building a ride-hailing backend means solving simultaneous puzzles: real-time location tracking at scale, sub-second matching, offer lifecycle management, and payment reconciliation. We've learned hard lessons deploying these systems in high-traffic markets. Let's walk through how to design a ride-hailing backend: best practices explained, focusing on what actually works under load.

Core flow: from request to completed ride

The happy path sounds simple but hides complexity. A rider opens the app and requests a trip. The system finds nearby available drivers, presents the offer to the closest one, waits for acceptance (with a timeout), begins real-time tracking once accepted, and finally handles pricing, payment, and ratings. Each step is a potential failure point.

Here's the basic state machine:

typescript
enum RideState {
  REQUESTED = 'REQUESTED',
  MATCHING = 'MATCHING',
  OFFERED = 'OFFERED',
  ACCEPTED = 'ACCEPTED',
  ARRIVED = 'ARRIVED',
  IN_PROGRESS = 'IN_PROGRESS',
  COMPLETED = 'COMPLETED',
  CANCELLED = 'CANCELLED',
}

interface Ride {
  id: string;
  riderId: string;
  driverId?: string;
  pickupLocation: Location;
  dropoffLocation: Location;
  state: RideState;
  requestedAt: Date;
  acceptedAt?: Date;
  completedAt?: Date;
  fare: Fare;
}

interface Location {
  lat: number;
  lng: number;
  timestamp: Date;
}

The system must handle concurrent offers (multiple drivers receiving the same ride offer), declined offers (driver ignores or explicitly rejects), and timeouts (driver never responds). Each transition triggers downstream events: accepted rides trigger real-time tracking setup; completed rides trigger invoice generation and payment processing.

Location is a write-heavy stream

Drivers send location updates every 2–5 seconds when active. A moderately sized service handling 10,000 concurrent rides generates 20,000–50,000 location writes per second. Persisting every update to a relational database causes immediate scaling pain.

The practical approach: store only the latest position in fast storage. Redis with a hash or stream structure works well. Periodically snapshot older positions to cold storage (PostgreSQL or data warehouse) for analytics, billing verification, and legal compliance.

typescript
// Redis: latest driver position (write-heavy, low latency)
async function updateDriverLocation(
  driverId: string,
  lat: number,
  lng: number,
): Promise {
  const key = `driver:${driverId}:location`;
  await redis.hset(key, {
    lat: lat.toString(),
    lng: lng.toString(),
    updatedAt: Date.now().toString(),
  });
  await redis.expire(key, 3600); // expire if driver goes offline
}

// Periodic snapshot to PostgreSQL (batch write, for audit trail)
async function snapshotLocationHistory(): Promise {
  const drivers = await redis.keys('driver:*:location');
  
  for (const key of drivers) {
    const data = await redis.hgetall(key);
    const driverId = key.split(':')[1];
    
    await db.query(
      `INSERT INTO location_history (driver_id, lat, lng, recorded_at)
       VALUES ($1, $2, $3, $4)`,
      [driverId, data.lat, data.lng, new Date(parseInt(data.updatedAt))],
    );
  }
}

Use TTL on the Redis keys so stale driver sessions automatically expire. For realtime tracking, push location updates via WebSocket to the rider and the dispatch system. Use a pub/sub pattern: drivers publish to a channel, subscribers (riders and backend services) receive updates immediately.

Expect 95th percentile latency under 500ms for location delivery to a watching rider. Most delays come from network round-trips and message queue processing, not the data store.

Matching needs geospatial queries

When a rider requests a trip, find available drivers within a reasonable radius (typically 5–10 km). A simple distance calculation against every driver in the database is slow. Geospatial indexing is non-negotiable.

PostgreSQL with PostGIS, Redis with geospatial commands, or specialized services like Elasticsearch all work. For moderate scale, Redis geo-commands are fast and simple: query nearby drivers in O(log N) time.

typescript
// Add driver to geospatial index when they come online
async function markDriverAvailable(
  driverId: string,
  lat: number,
  lng: number,
): Promise {
  await redis.geoadd('drivers:available', lng, lat, driverId);
}

// Find nearby available drivers
async function findNearbyDrivers(
  pickupLat: number,
  pickupLng: number,
  radiusKm: number = 10,
): Promise {
  const driverIds = await redis.georadius(
    'drivers:available',
    pickupLng,
    pickupLat,
    radiusKm,
    'km',
    {
      count: 50, // limit results for performance
      sort: 'ASC',
    },
  );
  
  return driverIds;
}

// Remove driver from index when they go offline
async function markDriverUnavailable(driverId: string): Promise {
  await redis.zrem('drivers:available', driverId);
}

After finding drivers by distance, filter by availability status and vehicle type. Don't query the database for every candidate; instead, keep driver state in Redis with a short TTL, or embed vehicle type in the geospatial index (using separate indices for different vehicle classes).

Rank the results: closest drivers first, but consider current ride load and acceptance rate. A driver who always accepts trips quickly should be weighted higher than one with a flaky connection.

Offers expire

Once the system finds a driver and sends them an offer, the driver has a limited window (usually 20–45 seconds) to accept. If they don't respond, the offer expires automatically and the system moves to the next candidate.

Implement this with a job queue and per-offer timers. When an offer is created, schedule a job to expire it after N seconds. If the driver accepts before that job runs, cancel it. Otherwise, the expiration job marks the offer as timed-out and triggers another matching round.

typescript
// Create an offer and schedule expiration
async function createOffer(
  rideId: string,
  driverId: string,
  expiresInSeconds: number = 30,
): Promise {
  const offerId = `offer:${rideId}:${driverId}`;
  
  // Store offer state
  await redis.hset(offerId, {
    rideId,
    driverId,
    state: 'PENDING',
    createdAt: Date.now().toString(),
  });
  
  // Schedule expiration
  await jobQueue.schedule(
    'expireOffer',
    { offerId, rideId, driverId },
    { delay: expiresInSeconds * 1000 },
  );
  
  // Send offer to driver via push notification
  await notificationService.sendOfferToDriver(driverId, rideId);
}

// Driver accepts the offer
async function acceptOffer(
  offerId: string,
  driverId: string,
): Promise {
  const offer = await redis.hgetall(offerId);
  
  if (offer.state !== 'PENDING') {
    return false; // offer already expired or accepted
  }
  
  // Mark as accepted and cancel expiration job
  await redis.hset(offerId, 'state', 'ACCEPTED');
  await jobQueue.cancel(`expire:${offerId}`);
  
  // Update ride state
  const rideId = offer.rideId;
  await db.query(
    `UPDATE rides SET driver_id = $1, state = $2, accepted_at = NOW()
     WHERE id = $3`,
    [driverId, RideState.ACCEPTED, rideId],
  );
  
  return true;
}

// Offer expires
async function expireOffer(offerId: string, rideId: string, driverId: string): Promise {
  const offer = await redis.hgetall(offerId);
  
  if (offer.state !== 'PENDING') {
    return; // already accepted or expired
  }
  
  await redis.hset(offerId, 'state', 'EXPIRED');
  
  // Log decline and trigger next matching round
  await db.query(
    `INSERT INTO offer_log (ride_id, driver_id, outcome) VALUES ($1, $2, $3)`,
    [rideId, driverId, 'EXPIRED'],
  );
  
  // Re-match with the next available driver
  await matchNextDriver(rideId);
}

For high throughput, use a dedicated job scheduler (like Bull with Redis, or AWS SQS) rather than database polling. Avoid spawning timers in-process; they're lost on service restarts and don't scale across replicas.

Track offer outcomes: accepted, expired, explicitly declined. Over time, this data helps identify which drivers are reliable and which ones waste offers. Feed this into your driver ranking algorithm.

Common mistakes

  • Storing every location update in the database: You'll hit write throughput limits within weeks. Use Redis for live data, snapshot cold data periodically.
  • Running matching synchronously in the request handler: If geospatial queries or database calls stall, rider requests timeout. Move matching to a background job and return a ride ID immediately.
  • Forgetting to clean up expired offers: Stale Redis keys pile up. Always set TTLs and actively delete expired data.
  • No circuit breaker on notification delivery: If your push notification service is slow, offer acceptance delays. Decouple notifications from the critical path.
  • Matching the same ride to multiple drivers without coordination: Multiple drivers accept the same ride. Use pessimistic locking or compare-and-swap atomics to ensure only one acceptance succeeds.
  • Not handling driver disappearance mid-ride: A driver goes offline after accepting. Have a timeout mechanism to reassign rides or notify the rider.

Key takeaways

  • Design a ride-hailing backend in layers: fast ephemeral state (Redis) for real-time operations, durable storage (PostgreSQL) for audit trails and billing.
  • Location is a write-heavy stream; keep only the latest position in fast storage and batch historical snapshots to cold storage.
  • Geospatial queries scale with proper indexing; use Redis geo-commands or PostgreSQL PostGIS to find nearby drivers in sub-millisecond time.
  • Offer expiration must be asynchronous and job-queue-driven, not timer-based in-process.
  • Coordinate accepting offers carefully; use atomic operations to prevent race conditions where two drivers accept the same ride.
  • Push critical path operations (matching, offer creation, acceptance) to background jobs; return results to the client immediately and update state asynchronously.
  • Monitor the full lifecycle: offer-to-acceptance ratio, average time-to-match, driver acceptance rates. These metrics expose bottlenecks early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles