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:
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.
// 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.
// 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.
// 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.






Comments
Loading comments…