Skip to content
SeeItFlow
System Design · 16 chapters · ~30 min read

Designing a Ride-Hailing Platform

A ride-hailing platform's core difficulty isn't the API gateway or the microservices layer — it's matching millions of moving riders and drivers against each other in near real time, which forces every downstream decision about location data, dispatch, and consistency. This walkthrough goes from a blank whiteboard to a production-grade distributed system, with every component motivated by a concrete requirement the previous design couldn't handle.

Mangesh MandlikLast updated:
Chapter 01

Problem Definition

We are designing a ride-hailing platform that connects riders who need transportation with drivers who provide it — in real time, at city scale, with sub-minute matching latency.

The core product experience is deceptively simple. A rider opens the app, specifies a pickup location and a destination, and requests a ride. The platform finds a nearby available driver, assigns them the trip, and both parties track each other in real time until the ride is completed. Payments are processed, receipts are sent, and both sides can rate the experience.

Behind that simplicity lies one of the most demanding real-time systems in modern software engineering. The platform must continuously track the location of millions of moving drivers, search a geographic space to find the best match for each new ride request, manage concurrent state transitions across thousands of active trips, and push real-time updates to every participant — all while maintaining consistency guarantees that prevent a single driver from being assigned to two riders simultaneously.

The primary user journey

Before diving into components and protocols, it helps to walk through the end-to-end journey that the system must support. The rider opens the app and sees nearby drivers on a map. They enter a destination and see an estimated fare and ETA. They confirm the ride request. The platform searches for nearby available drivers, evaluates them against matching criteria, and sends a ride offer to the best candidate. The driver accepts. Both rider and driver now see each other's location in real time. The driver arrives at the pickup point, starts the trip, navigates to the destination, and completes the trip. The fare is calculated, payment is processed, and both parties are prompted to rate each other.

Every architectural decision we make in this design should serve this journey. If a component doesn't help a rider get matched faster, a driver navigate more accurately, or a trip complete more reliably, it doesn't belong in the initial design.

Scope boundaries

In scope: rider and driver apps, ride matching and assignment, real-time location tracking, trip lifecycle management, basic fare calculation, and push notifications. These are the features without which the product simply does not function.

Out of scope: payment processing internals (we treat it as an external service), surge pricing algorithms, driver onboarding and background checks, route optimization and navigation engine internals, customer support tooling, and multi-modal transport (bikes, scooters). These are important systems, but each is a design exercise in its own right. Including them would dilute the focus without adding architectural insight.

Surge pricing is covered in depth in our Architecture Deep Dive →

Interactive

Visual Design Journey

Watch a single app server and database evolve, bottleneck by bottleneck, into a geo-partitioned platform that matches riders and drivers at massive scale.

Chapter 02

Functional Requirements

Functional requirements define what the system does. For a ride-hailing platform, these requirements split naturally into three perspectives: the rider, the driver, and the platform itself.

Rider capabilities

The rider must be able to request a ride by specifying a pickup location and a destination. They should be able to select a ride type (economy, premium, shared) and see an upfront fare estimate before confirming. Once a ride is matched, the rider must see the driver's name, photo, vehicle details, and real-time location on a map. They should be able to cancel a ride before pickup and view their ride history after completion.

Note what is conspicuously absent from this list: the rider does not choose their driver. The platform makes the assignment. This is a deliberate product decision that has deep architectural consequences — it means the matching logic lives server-side and must execute quickly enough that the rider perceives an instant response.

Driver capabilities

The driver must be able to toggle their availability status between online and offline. While online, the driver's app continuously sends location updates to the platform. When the platform identifies a match, the driver receives a ride offer containing the rider's pickup location, destination, and estimated fare. The driver can accept or reject the offer within a time window. After accepting, the driver navigates to the pickup point, starts the trip upon arrival, and completes the trip at the destination.

The continuous location sending is the single most important functional requirement from a systems perspective. It is not a feature — it is the heartbeat of the entire platform. Without fresh driver locations, the matching engine is blind, the rider's map is stale, and ETAs are meaningless. Everything downstream depends on this data flowing reliably and frequently.

Platform capabilities

The platform must discover nearby available drivers when a ride is requested, match the best driver to the ride, and manage the trip through its lifecycle states. It must push real-time location and status updates to both rider and driver throughout the trip. It must send notifications at key moments (driver assigned, driver arriving, trip completed). And it must durably store ride history for billing, analytics, and dispute resolution.

Chapter 03

Non-Functional Requirements

Non-functional requirements determine how the system behaves under stress, failure, and scale. For a ride-hailing platform, they are not secondary concerns — they are the reason the architecture exists.

High Availability

The system must be available 24/7. Riders request rides at 2 AM, during snowstorms, and on New Year's Eve. Downtime directly translates to lost revenue and, more importantly, stranded passengers. Target: 99.99% uptime (roughly 52 minutes of downtime per year). This means no single point of failure anywhere in the critical path.

Low Latency

Ride matching must complete in under 5 seconds from the moment a rider taps "Request Ride" to the moment a driver receives the offer. Location updates must propagate from driver to rider within 2 seconds. These are not aspirational targets — they are the threshold below which the product feels broken. A rider staring at a spinning screen for 10 seconds will close the app and hail a taxi.

Scalability

The system must handle millions of concurrent users, hundreds of thousands of active trips, and hundreds of thousands of location updates per second — and it must do so with costs that scale sub-linearly with traffic. Adding 10x users should not require 10x infrastructure.

Reliability

Once a rider is matched with a driver, the system must not lose that assignment. A trip in progress must survive server restarts, network blips, and partial infrastructure failures. The state machine governing trip lifecycle must be consistent — a trip cannot simultaneously be in the "matching" and "completed" states.

Consistency (where it matters)

Not all data requires the same consistency guarantees. Driver-to-ride assignment must be strongly consistent — a driver cannot be assigned to two trips simultaneously. Location data can be eventually consistent — showing a driver's position from 2 seconds ago is acceptable. Ride history can be eventually consistent — a completed trip appearing in history after a 30-second delay is fine. Recognizing these different consistency requirements is what prevents the architecture from being either too slow (everything strongly consistent) or too broken (everything eventually consistent).

Fault Tolerance

Individual components will fail. The question is not whether a Redis node will go down, but what happens to the system when it does. Every component must have a failure mode that degrades gracefully rather than cascading. If the location cache fails, the matching service should operate on stale data rather than refusing to match entirely. In practice this means serving from the last-known index (a replica or surviving shard) for as long as it holds usable positions; only when the index is genuinely empty, such as right after a restart, does matching fall back to "no drivers available" (see the Redis failure scenario on the Failure page).

The hierarchy matters. For a ride-hailing platform, the priority ordering is: Availability > Latency > Consistency > Scalability. This ordering drives every trade-off in the architecture. We will choose AP over CP in the CAP theorem for location data. We will accept slightly stale reads to avoid blocking on replication. We will over-provision hot paths rather than optimizing for cost at the expense of response time.

Chapter 04

Scale Estimation

Scale estimation is not about arriving at precise numbers. It is about discovering which workloads dominate the system and letting those workloads shape the architecture.

User and trip volumes

Let's establish reasonable order-of-magnitude estimates for a platform operating in hundreds of cities worldwide. We assume 100 million registered riders and 5 million registered drivers. On any given day, perhaps 20 million riders are active and 2 million drivers go online at some point. At peak hours, roughly 1 million drivers are online simultaneously and 500,000 trips are in progress concurrently.

How many new ride requests does the system handle? If an average trip lasts 15 minutes and there are 500K concurrent trips, the system completes about 33,000 trips per minute, which means roughly 500-1,000 new ride requests per second at peak. This is significant but not extraordinary — a well-tuned application server can handle this with a handful of instances.

The location update tsunami

Now consider location updates. Every online driver sends a GPS update every 3-6 seconds (let's say every 6 seconds, or 10 updates per minute). With 1 million online drivers, that is 10 million location updates per minute, or approximately 166,000 writes per second. This number is two orders of magnitude larger than the ride request rate.

Each location update is small — perhaps 100 bytes containing a driver ID, latitude, longitude, heading, speed, and timestamp. At 166K updates/sec, that is roughly 16 MB/sec of raw location data, or about 1.4 TB per day. The data is transient (we only care about the latest location for matching purposes), but the write throughput is relentless.

Read patterns

Read volumes are driven by the rider experience. Every rider with the app open polls for nearby drivers to display on the map — let's say every 5 seconds. If 5 million riders have the app open (not all requesting rides, just browsing), that's 1 million map queries per second. Each query requires a geospatial search over the location data: "find all available drivers within 3 km of this point." This read workload is not only high-throughput but also computationally expensive — it requires spatial indexing, not just key-value lookups.

Key takeaways
  1. 01Location updates (~166K writes/sec) dominate write throughput by 100x over ride requests (~1K/sec)
  2. 02Location data is transient and tolerates eventual consistency; trip data is durable and requires strong consistency
  3. 03Geospatial reads ("find nearby drivers") are the most computationally expensive operation, not the most frequent
  4. 04The architecture must treat location updates and business transactions as separate workloads with different storage strategies
  5. 05Storage volume (~1.4 TB/day of location data) is manageable, but write throughput is the bottleneck
Chapter 05

Core Entities & Data Model

The data model must reflect the two natures of the system: durable business objects that live for years, and transient operational state that changes every few seconds.

Before choosing databases or defining schemas, we need to understand what entities the system manages and how they relate to each other. The entities fall into two categories. Durable entities — riders, drivers, trips, and ride requests — represent business data that must be persisted reliably and queried historically. Transient state — driver locations and availability — represents operational data that is written at extreme rates and only matters in its most recent form.

Rider

Represents a registered user who requests rides. Key fields: rider_id (UUID), name, email, phone, payment_method_id, rating, created_at. This is a slowly changing entity — updated when the user edits their profile or when their rating changes after a trip. Stored in the primary relational database.

Driver

Represents a registered driver who provides rides. Key fields: driver_id (UUID), name, phone, license_number, vehicle_id, rating, status (offline/online/assigned/on_trip), created_at. The status field is critical — it determines whether a driver is eligible for matching. The profile data is durable; the status and location are transient and updated constantly.

Vehicle

Associated with a driver. Key fields: vehicle_id, driver_id, make, model, year, color, license_plate, vehicle_type (economy/premium/SUV). This entity rarely changes and is read frequently (every time a rider views their assigned driver). It is an excellent caching candidate.

Ride Request

Created when a rider requests a ride. Key fields: request_id (UUID), rider_id, pickup_location (lat/lng), destination (lat/lng), ride_type, estimated_fare, status (pending/matched/cancelled/expired), created_at. A ride request is short-lived — it either transitions to a trip within seconds or expires. But it must be persisted for auditing and analytics.

Trip

The central business entity. Key fields: trip_id (UUID), request_id, rider_id, driver_id, pickup_location, destination, status (assigned/arriving/started/completed/cancelled), started_at, completed_at, fare, distance, duration. A trip is created when a driver accepts a ride offer and persists forever. It is the system of record for billing, disputes, and analytics.

Driver Location

The most frequently updated entity in the entire system. Key fields: driver_id, latitude, longitude, heading, speed, timestamp. This is NOT stored in the relational database. It is stored in an in-memory data store (Redis) with geospatial indexing. Only the latest location matters — there is no history to maintain for operational purposes (though raw updates may be streamed to a data lake for analytics).

The storage split

The data model makes the storage strategy obvious. Durable business entities (Rider, Driver, Vehicle, Ride Request, Trip) belong in a relational database — PostgreSQL is the natural choice. They have well-defined schemas, relationships (a trip references a rider and a driver), and they benefit from ACID transactions (creating a trip and updating a driver's status must be atomic).

Transient operational state (Driver Location, Driver Availability) belongs in an in-memory store — Redis is the natural choice. The write rate (166K/sec) would overwhelm any relational database, and the data has no long-term value. Redis provides the throughput, and its geospatial commands (GEOADD, GEOSEARCH) provide the spatial querying we need for driver discovery.

This is not a premature optimization. This is a direct consequence of the scale estimation. A single PostgreSQL instance can handle roughly 10,000-30,000 writes per second under favorable conditions (an illustrative range; it depends on hardware, schema and durability settings). We need 166,000 writes per second for location updates alone. The storage split is not optional — it is mandatory.

Chapter 06

API Design

APIs define the contract between clients and the platform. They are the system's public surface area — and the hardest thing to change once clients depend on them.

The API design follows directly from the functional requirements. We need endpoints for ride lifecycle management, driver location reporting, and driver status management. We also need a persistent connection mechanism for real-time updates.

Ride lifecycle

httpThe ride request endpoint. Note the synchronous response includes a fare estimate but does NOT include a matched driver — matching happens asynchronously.
POST /v1/rides
Content-Type: application/json
Authorization: Bearer {rider_token}

{
  "pickup": { "lat": 37.7749, "lng": -122.4194 },
  "destination": { "lat": 37.3382, "lng": -121.8863 },
  "ride_type": "economy"
}

Response 201:
{
  "ride_id": "ride-abc-123",
  "status": "matching",
  "estimated_fare": { "amount": 24.50, "currency": "USD" },
  "estimated_pickup_eta": 240
}
httpThe ride status endpoint, polled by the rider app or used as a fallback when the WebSocket connection drops.
GET /v1/rides/{ride_id}
Authorization: Bearer {token}

Response 200:
{
  "ride_id": "ride-abc-123",
  "status": "assigned",
  "driver": {
    "name": "Alex",
    "rating": 4.92,
    "vehicle": { "make": "Toyota", "model": "Camry", "color": "Silver", "plate": "ABC 1234" },
    "location": { "lat": 37.7752, "lng": -122.4180 },
    "eta_seconds": 180
  }
}
httpCancellation endpoint. The cancellation fee depends on the trip state — cancelling after the driver has arrived incurs a fee; cancelling during matching does not.
POST /v1/rides/{ride_id}/cancel
Authorization: Bearer {rider_token}

Response 200:
{
  "ride_id": "ride-abc-123",
  "status": "cancelled",
  "cancellation_fee": { "amount": 5.00, "currency": "USD" }
}

Driver location and status

httpLocation update endpoint. Returns 204 with no body to minimize response size — this endpoint is called 10 times per minute per driver.
POST /v1/drivers/location
Authorization: Bearer {driver_token}

{
  "lat": 37.7751,
  "lng": -122.4185,
  "heading": 90.0,
  "speed": 12.5,
  "timestamp": 1690000000
}

Response 204 (No Content)
httpStatus toggle endpoint. Going online makes the driver visible to the matching engine; going offline removes them.
POST /v1/drivers/status
Authorization: Bearer {driver_token}

{
  "status": "online"
}

Response 200:
{
  "driver_id": "driver-xyz-789",
  "status": "online",
  "since": "2024-01-15T08:00:00Z"
}

Real-time updates

The endpoints above handle request-response interactions. But the rider also needs real-time updates: when a driver is assigned, where the driver is right now, when the driver arrives. These cannot be served efficiently via polling — the latency would be too high and the server load would be enormous.

textThe WebSocket channel for a specific trip. The rider subscribes after requesting a ride and receives all state transitions and location updates as server-pushed messages.
WebSocket: wss://realtime.platform.com/v1/trips/{ride_id}
Authorization: via initial handshake token

Server-sent messages:
{ "type": "driver_assigned", "driver": { ... } }
{ "type": "driver_location", "lat": 37.7752, "lng": -122.4180, "eta": 150 }
{ "type": "driver_arrived" }
{ "type": "trip_started" }
{ "type": "trip_completed", "fare": { "amount": 24.50, "currency": "USD" } }
Chapter 07

Initial Architecture

Every system starts simple. The goal of the initial architecture is not to handle 166,000 writes per second — it is to validate the product with the fewest possible moving parts.

The simplest architecture that could possibly work is a single application server connected to a single PostgreSQL database. The mobile clients (rider and driver apps) communicate with the server over HTTPS. The server handles all business logic: accepting ride requests, storing driver locations, finding nearby drivers, matching, and managing trip state. PostgreSQL stores everything — user profiles, trips, and yes, even driver locations.

Swipe to pan
Initial ArchitectureClient, app server, database — the simplest possible start.Rider Appmobile clientApp ServermonolithPostgreSQLtrips · usersRESTSQL
The simplest reasonable architecture — one server, one database

At small scale — say, a single city with 1,000 drivers and 10,000 riders — this architecture works fine. PostgreSQL can handle the location update rate (roughly 170 writes/sec with 1,000 drivers at 10 updates/min) without breaking a sweat. A PostGIS extension provides geospatial queries for finding nearby drivers. The application server handles a few ride requests per second. Everything fits on one machine.

This is not a toy architecture to be embarrassed about. This is how every successful ride-hailing startup actually started. The product risk at this stage is not "will the database handle 166K writes/sec" — it is "will anyone actually request a ride." Premature architectural complexity is a far greater threat than premature scaling problems.

“The best architecture satisfies the requirements with an appropriate level of complexity. An architecture that handles 10x more load than you'll see in the next two years is not better — it is more expensive, harder to debug, and slower to evolve.”

— A principle, not a person

When and why we evolve

The initial architecture will break. The question is where and in what order. As the platform grows from one city to ten, from 1,000 drivers to 100,000, the cracks will appear in a predictable sequence. The application server will run out of CPU and memory. The database will struggle with concurrent writes. Geospatial queries will become too slow. The matching logic will become too complex for a simple nearest-driver algorithm. And the lack of real-time push will make the user experience unacceptable.

Each crack motivates a specific architectural change. We don't redesign the whole system at once — we evolve it one bottleneck at a time. The next chapter walks through each evolution in the order it would naturally occur.

Chapter 08

Architecture Evolution

Architecture evolution is not a redesign — it is a series of targeted interventions, each motivated by a specific bottleneck that the current design cannot handle.

Phase 1: Scaling the application tier

The first bottleneck is the application server itself. As traffic grows, a single server runs out of CPU cores to handle concurrent requests. The first response is vertical scaling — a bigger machine with more cores and memory. This buys time, perhaps 5-10x more capacity, with zero architectural changes.

Eventually, vertical scaling hits a ceiling (or becomes prohibitively expensive). The next step is horizontal scaling: multiple application server instances behind a load balancer. The load balancer distributes incoming requests across instances using round-robin, least-connections, or IP-hash algorithms. This requires the application servers to be stateless — they cannot store session data or in-memory caches that are specific to a user. Any request must be handleable by any server.

Swipe to pan
Horizontal ScalingLoad balancer distributes traffic across multiple app servers.Rider AppDriver AppLoad BalancerServer AServer BServer CPostgreSQL
Horizontal scaling distributes traffic across stateless application servers

Making the application stateless is not difficult for most endpoints. Ride requests, status queries, and location updates are naturally stateless — each request carries all the information needed to process it (user identity via auth token, ride ID in the URL, coordinates in the body). The database is the shared state. As long as all instances talk to the same database, the system behaves correctly.

Phase 2: Separating location storage

The second bottleneck, and the most critical one, is the database. As the driver fleet grows past 10,000-50,000, the location update write rate starts overwhelming PostgreSQL. At 50,000 online drivers sending 10 updates per minute, the database receives ~8,300 writes per second — just for location updates. These are UPDATE statements on existing rows, each requiring an index update on the geospatial column. PostgreSQL can handle this with aggressive tuning, but it leaves little headroom for the transactional workload (ride creation, trip updates, user queries).

The solution is to separate location data from the relational database entirely. Driver locations move to Redis, which can sustain hundreds of thousands of writes per second on a single instance. Redis's GEOADD command stores a driver's position in a sorted set with geospatial indexing, and GEOSEARCH (formerly GEORADIUS) retrieves all drivers within a given radius of a point.

redisRedis geospatial commands. GEOADD inserts or updates a member's position in O(log N). GEOSEARCH returns members within a radius, sorted by distance.
# Store a driver's location
GEOADD drivers:available -122.4194 37.7749 driver-xyz-789

# Find all available drivers within 3 km of a point
GEOSEARCH drivers:available FROMLONLAT -122.4194 37.7749 BYRADIUS 3 km ASC COUNT 20

This is the first real architectural split in the system. We now have two storage layers: PostgreSQL for durable business data and Redis for transient location data. The application code must know which store to query for what — a ride request reads from PostgreSQL for the rider's profile and from Redis for nearby drivers. This adds complexity, but it eliminates the bottleneck that would otherwise cap the system at 50,000 drivers.

Swipe to pan
Storage Separation · hot vs. transactionalApp Serversride serviceRedisdriver location · geo indexPostgreSQLtrips · users · payments166K writes/stransactional writesClientsrider + driver
Rapidly changing location data separated from durable business data

For how geohashing itself works, and how it compares to quadtree and H3 indexing, see our Architecture Deep Dive →

Phase 3: Driver discovery as a service

With locations in Redis, finding nearby drivers is fast — but it is not yet smart. A naive implementation queries GEOSEARCH to find the 20 nearest drivers and returns them all. This works when any nearby driver is equally good, but in reality, drivers have different vehicle types, different ratings, and different ETAs to the pickup point. A driver 2 km away on a highway might arrive faster than a driver 500 meters away on a congested side street.

As the matching requirements become more sophisticated, it makes sense to extract driver discovery into its own Geospatial Index. This component owns the Redis instance containing driver locations, handles all spatial queries, and can incorporate additional data (road network, traffic conditions, driver heading relative to pickup) into the results. The app servers call the geospatial index with a pickup location and ride type, and receives back a ranked list of candidate drivers.

Phase 4: The matching problem

Finding nearby drivers is not the same as choosing the right driver. Discovery answers "who is nearby?" while matching answers "who should get this ride?" The matching decision involves multiple factors: distance to pickup, ETA considering traffic, driver rating, driver acceptance rate, how long the driver has been waiting for a ride, vehicle type compatibility, and potentially fairness constraints (ensuring drivers get equitable ride distribution).

This complexity warrants its own Matching Service. When a ride request arrives, the app servers ask the geospatial index for candidate drivers, then passes those candidates to the matching service. The matching service scores each candidate, selects the best one, and sends a ride offer to that driver. If the driver declines or times out, the matching service moves to the next candidate.

The matching service must handle the offer timeout pattern: it sends an offer, starts a timer (typically 15-30 seconds), and either receives an acceptance or times out and tries the next driver. This is a stateful operation — the matching service must remember which drivers have already been offered this ride. This state can live in memory (if the matching service is single-instance or partitioned by region) or in Redis (if it must survive instance restarts).

For the full matching pipeline — scoring, concurrency handling, and fleet-wide optimization — see our Architecture Deep Dive →

Phase 5: Solving the assignment race condition

Here is a subtle but critical problem. Two ride requests arrive within milliseconds of each other. Both query the geospatial index and both receive Driver A as the top candidate. Both send offers to Driver A. Driver A accepts the first one. What happens to the second ride?

Without protection, the system could assign Driver A to both rides — a catastrophic consistency violation. The solution is atomic state transitions on the driver's status. When the matching service selects a driver, it must atomically transition the driver's status from available to offered. This transition must be a compare-and-swap operation: "set status to offered only if it is currently available." If the CAS fails, the driver was already claimed by another ride, and the matching service moves to the next candidate.

Swipe to pan
Race Condition · Driver Double AssignmentWITHOUT FIXRider ARider BMatchingDriver X: availableDriver X: availabledouble-booked!WITH ATOMIC CASRider ARider BMatchingUPDATE...WHEREstatus=available1 winsother re-matchesAtomic compare-and-swap prevents concurrent assignment of the same driver.UPDATE drivers SET status = 'assigned'WHERE id = $1 AND status = 'available'-- rows_affected = 1 ? success : retry
Without atomic assignment, two rides can claim the same driver
pythonThe compare-and-swap pattern for driver assignment. The WHERE clause ensures the update only succeeds if the driver is still available. This is the single most important consistency guarantee in the entire system.
# Pseudo-code for atomic driver assignment
def assign_driver(driver_id, ride_id):
    # Atomic conditional UPDATE in PostgreSQL
    result = db.execute(
        "UPDATE driver SET status = 'assigned', current_ride_id = %s "
        "WHERE id = %s AND status = 'available'",
        [ride_id, driver_id]
    )
    if result.rows_affected == 0:
        return False  # Driver was already claimed
    return True  # Assignment succeeded

This pattern — optimistic concurrency control via conditional updates — is the backbone of the trip assignment system. It prevents double-booking without requiring distributed locks or two-phase commits. The cost is that some ride requests will need to retry with a different driver, but retries are fast (milliseconds) and far cheaper than the alternative of blocking all matching on a global lock.

Phase 6: The trip state machine

A trip is not a static record — it is a state machine with well-defined transitions. Understanding these transitions is essential for implementing trip management correctly.

The states are: Requested (rider has asked for a ride), Matching (platform is searching for a driver), Assigned (driver accepted, heading to pickup), Arriving (driver is near pickup), Started (rider is in the car), Completed (rider dropped off), and Cancelled (ride was cancelled by rider, driver, or system). Each transition has preconditions: a trip can move from Matching to Assigned only when a driver accepts, and from Assigned to Started only when the driver explicitly starts the trip at the pickup location.

Swipe to pan
Trip State MachineRequestedMatchingAssignedArrivingStartedCompletedCancelled
Explicit trip states with enforced valid transitions

The app servers enforce these transitions. Every state change is persisted to PostgreSQL before being acknowledged. Invalid transitions (e.g., moving directly from Matching to Completed) are rejected. This state machine is the source of truth for what is happening with every trip in the system, and its integrity is non-negotiable.

Chapter 09

Asynchronous Processing & Events

Not everything that happens when a trip completes needs to happen before the rider sees the receipt. Recognizing the boundary between synchronous and asynchronous work is one of the most impactful architectural decisions in the system.

The critical path vs. everything else

When a trip is completed, the system must do many things: calculate the final fare, charge the rider's payment method, send a receipt notification, update both parties' ratings, log the trip for analytics, update the driver's earnings dashboard, and feed the trip data into ML models for ETA prediction. How many of these must happen synchronously — before the driver's app shows "Trip Complete"?

The answer is: very few. The fare calculation must be synchronous because the receipt needs to show the amount. But payment processing, notification delivery, analytics ingestion, and ML pipeline updates can all happen asynchronously, seconds or even minutes after the trip completes. The driver should see "Trip Complete" within a second, not after waiting for a payment gateway timeout.

This distinction — what is on the critical path and what is not — determines where we introduce asynchronous processing. The mechanism is an event bus: the app servers publish a TripCompleted event, and multiple independent consumers react to it on their own timelines.

Swipe to pan
Critical Path (sync) vs. Fire-and-Forget (async)SYNC · must completeRiderRide ServiceMatchingAssignmentResponseASYNC · can be delayedKafkaevent busNotificationsSMS · pushAnalyticsmetrics
The critical path stays synchronous; everything else becomes an event

Introducing Kafka

Apache Kafka is the natural choice for the event bus. The app servers publish structured events to Kafka topics, and independent consumer services subscribe to these topics. Each consumer processes events at its own pace, maintains its own offset (position in the log), and can be scaled independently.

Swipe to pan
Async Event Processing · KafkaTrip ServiceproducerKafka · trip-eventspartition-0partition-1partition-2TripCreatedNotificationsSMS · pushAnalyticsmetrics · billingdecoupled · independent scalingTripCreated · DriverAssigned · TripCompleted
Trip events flow through Kafka to independent consumers

The key events in the system are:

RideRequested

Published when a rider submits a new ride request. Consumed by the analytics consumer (demand tracking, surge pricing input) and the notification service (confirmation to the rider).

DriverAssigned

Published when a driver accepts a ride offer. Consumed by the notification service (push notification to the rider with driver details) and the analytics consumer (supply-demand matching metrics).

TripStarted

Published when the driver starts the trip at the pickup location. Consumed by the ETA service (actual pickup time vs. predicted, for model training) and the safety service (start monitoring the trip).

TripCompleted

Published when the driver completes the trip. This is the richest event — consumed by the notification service (sends receipt and triggers payment via an external gateway), analytics consumer (trip metrics), rating service (prompt for ratings), and the ML pipeline (training data for ETA models, surge pricing, and demand forecasting).

Event design principles

Events should be self-contained — they carry enough data that consumers don't need to make additional API calls to process them. A TripCompleted event should include the rider ID, driver ID, fare breakdown, distance, duration, and route summary. This reduces coupling between services and eliminates a class of cascading failures where a downstream service fails because it can't reach an upstream API.

Events should be immutable facts, not commands. A TripCompleted event says "this trip was completed" — it does not say "please charge the rider." The notification service decides to trigger a charge because it observed a completion event. This distinction matters because it means adding a new consumer (say, a loyalty points service) requires zero changes to the app servers. The app servers don't know or care who is listening.

Partitioning and ordering

Kafka guarantees ordering within a partition but not across partitions. For trip events, the natural partition key is trip_id — this ensures all events for a single trip are processed in order. A TripStarted event will always be processed before the corresponding TripCompleted event, which is essential for correct billing and analytics.

If we partition by rider_id instead, we guarantee ordering across all trips for a single rider — useful for fraud detection ("this rider completed 50 trips in the last hour from different cities") but not necessary for most consumers. The partition key is a trade-off between ordering scope and parallelism — more unique keys mean more even distribution and higher throughput.

Idempotency

In a distributed system, events will occasionally be delivered more than once. A consumer might crash after processing an event but before committing its offset, causing the event to be redelivered on restart. Every consumer must be idempotent — processing the same event twice must produce the same result as processing it once.

For payment processing, this means checking whether a charge has already been processed for a given trip_id before initiating a new one. For the analytics consumer, this means using upsert semantics rather than blind inserts. Idempotency is not a nice-to-have — it is a correctness requirement in any system that uses asynchronous event processing.

Chapter 10

Real-Time Communication

Real-time communication is what makes a ride-hailing app feel alive. Without it, the rider is staring at a static map, refreshing manually, wondering if their driver is actually coming.

Why polling falls short

The simplest approach to keeping the rider's map updated is polling: the client sends a GET request every N seconds to fetch the driver's latest location. At a 3-second polling interval with 500K active trips, the server handles ~166K polling requests per second. Each request travels through the load balancer, application server, and potentially a cache or database — consuming resources at every hop. Most responses will contain identical data because the driver's location hasn't changed in the last 3 seconds.

Polling wastes bandwidth on redundant responses, introduces latency equal to half the polling interval on average, and doesn't scale efficiently. It is acceptable as a fallback mechanism but not as the primary communication channel.

WebSocket connections

WebSockets provide a persistent, full-duplex connection between the client and server. Once established, the server can push updates to the client instantly, without waiting for a request. The client maintains a single TCP connection for the duration of the trip, and the server sends location updates, state transitions, and notifications through that connection as they occur.

For a ride-hailing platform, WebSockets are ideal for the trip experience. When a rider requests a ride, the client opens a WebSocket connection scoped to that ride. The server pushes a driver_assigned message when a driver accepts, followed by continuous driver_location updates as the driver approaches, and state transition messages (driver_arrived, trip_started, trip_completed) as they occur.

Swipe to pan
Real-Time Driver Tracking · push vs. pollDriver AppApp ServerRedisGEOADDRT GatewayRiderlat/lng 6spushPUSHRider AppServerwhere's my driver?repeat every 3s...POLL
Server pushes location updates the instant they arrive — no polling

The Real-Time Gateway

Managing WebSocket connections at scale requires a dedicated Real-Time Gateway. This component accepts WebSocket connections from clients, maintains a mapping of connections to trip IDs, and routes messages from backend services to the correct client connections.

The architecture works as follows: the driver's app sends location updates to the app servers via HTTP. The app servers update Redis and publish the new location to an internal pub/sub channel (Redis Pub/Sub or a dedicated message bus). The Real-Time Gateway subscribes to these channels, looks up which WebSocket connections are interested in updates for a given driver/trip, and pushes the update to those connections.

At 500K concurrent trips, the Real-Time Gateway manages at least 1 million concurrent WebSocket connections (one per rider and one per driver, plus spectators). Each connection consumes a file descriptor and a small amount of memory. A single server can handle 50,000-100,000 concurrent connections with proper tuning (epoll on Linux, kqueue on macOS), so the Real-Time Gateway needs 10-20 instances.

Connection management challenges

WebSocket connections are inherently stateful — a connection lives on a specific server instance. If that instance restarts or fails, all connections on it are dropped. Clients must handle reconnection gracefully, and the system must rebalance connections across healthy instances.

Connection routing is another challenge. When a backend service wants to push an update for trip T, it needs to know which Real-Time Gateway instance holds the WebSocket for that trip. This routing is typically handled by a shared registry (a Redis hash mapping trip IDs to server instances) or by broadcasting the update to all gateway instances and letting each one check whether it has the relevant connection. Broadcasting is simpler but wasteful at scale; a registry is more efficient but adds a dependency.

Chapter 11

Caching & Read Scaling

Caching is not about making the system faster — it is about protecting the database from read workloads that would otherwise overwhelm it.

What to cache

Not all data benefits equally from caching. The best caching candidates are data that is read frequently, changes infrequently, and is expensive to compute or fetch. In our system, the prime candidates are:

Driver profiles and vehicle information. Every time a rider is matched with a driver, the app displays the driver's name, photo, rating, and vehicle details. This data changes rarely (a driver doesn't change their car mid-trip) but is read millions of times per day. A cache-aside pattern with Redis eliminates almost all database reads for this data.

Trip status. Active trip data is read by both the rider and driver apps, potentially every few seconds (as a polling fallback). Caching the latest trip status in Redis reduces load on PostgreSQL and improves response latency from ~5ms (database) to ~0.5ms (cache).

Fare estimates. Computing a fare estimate involves route calculation, distance estimation, and potentially surge pricing. The result can be cached for a short period (30-60 seconds) keyed by a geohash of the pickup and destination, reducing redundant computation for popular routes.

Cache-aside pattern

The cache-aside (or lazy-loading) pattern is the standard approach. When a request arrives, the application first checks Redis. If the data is present (cache hit), it returns immediately. If not (cache miss), it queries PostgreSQL, stores the result in Redis with a TTL, and returns. On writes, the application updates PostgreSQL and invalidates (or updates) the corresponding cache entry.

pythonCache-aside pattern. The 5-minute TTL ensures stale data eventually expires even if the invalidation path fails.
def get_driver_profile(driver_id):
    # 1. Check cache
    cached = redis.get(f"driver:{driver_id}")
    if cached:
        return json.loads(cached)
    
    # 2. Cache miss — query database
    profile = db.query("SELECT * FROM drivers WHERE id = %s", driver_id)
    
    # 3. Populate cache with TTL
    redis.setex(f"driver:{driver_id}", 300, json.dumps(profile))  # 5-minute TTL
    
    return profile

Cache invalidation

Cache invalidation is famously one of the two hard problems in computer science. For our system, the key challenge is ensuring that when a driver's status changes (from available to on-trip), the cached data reflects this promptly. A stale cache could show a rider that a driver is available when they're already on another trip.

The solution is TTL-based expiration combined with explicit invalidation on writes. When the app servers assign a driver, it both updates PostgreSQL and deletes the driver's cache entry. Subsequent reads will miss the cache and fetch fresh data. The TTL acts as a safety net — even if the explicit invalidation fails (network blip, code bug), the stale data expires within minutes.

Cache stampede prevention

A cache stampede occurs when a popular cache entry expires and hundreds of concurrent requests simultaneously miss the cache and hit the database. For a ride-hailing platform, this could happen when a popular driver's cache entry expires — suddenly, every rider viewing that driver's profile queries the database.

The standard prevention technique is locking on miss: when a cache miss occurs, the first request acquires a short-lived lock (a Redis SET NX EX command) and fetches from the database. Subsequent requests see the lock and either wait briefly or return a slightly stale cached value. Only one request hits the database, and all others benefit from the result.

Read replicas

Caching handles hot-key reads, but the database also serves read queries that aren't cache-friendly: ride history, search, analytics dashboards. For these, PostgreSQL read replicas offload read traffic from the primary. The primary handles all writes; replicas asynchronously replicate the write-ahead log and serve read queries.

The trade-off is replica lag — a replica may be milliseconds to seconds behind the primary. For ride history ("show me my last 10 trips"), a 1-second lag is invisible. For active trip status, it could mean showing stale state. The application must route queries deliberately: active trip status reads go to the primary (or the cache); historical queries go to replicas.

Chapter 12

Write Scaling & Partitioning

Read scaling buys time. Eventually, the write workload outgrows a single database instance, and you must partition the data across multiple machines.

The write scaling progression

Write scaling follows a predictable progression. Vertical scaling (bigger machine) is always the first step — more RAM, faster SSDs, more CPU cores. A well-tuned PostgreSQL instance on modern hardware can handle 30,000-50,000 writes per second. For the transactional workload (ride requests, trip updates — ~1,000 writes/sec), a single primary with vertical scaling is sufficient for a long time.

The pressure to scale writes comes from two sources. First, the transactional write rate grows as the platform expands to more cities. Second, the operational overhead of a single primary becomes a risk — if it fails, the entire platform goes down. Both pressures push toward partitioning (also called sharding): splitting the data across multiple independent database instances, each handling a subset of the writes.

Choosing a shard key

The shard key determines which database instance holds a given record. The choice is consequential — it affects query routing, data locality, and the feasibility of joins and transactions.

Sharding by `trip_id` distributes writes evenly (trip IDs are random UUIDs) and makes single-trip queries efficient. But querying all trips for a specific rider requires fanning out to every shard — expensive and slow.

Sharding by `rider_id` puts all of a rider's trips on the same shard, making rider-specific queries (ride history, active trip lookup) fast. But if some riders are much more active than others, shards become unbalanced.

Sharding by geographic region (city or zone) puts all trips in a region on the same shard. This provides excellent data locality — a city's rides are self-contained — and matches the operational reality that most queries are scoped to a region. The downside is that some cities (New York, Mumbai) generate far more traffic than others, creating hot shards.

In practice, geographic sharding is the most common choice for ride-hailing platforms, with sub-region splits for the hottest cities. The operational advantages (independent scaling per region, regional failure isolation, compliance with data residency requirements) outweigh the hot-shard problem, which can be addressed by splitting hot regions into smaller zones.

Trade-offs of sharding

Sharding is not free. Cross-shard queries become expensive or impossible — you cannot JOIN a rider from shard A with a trip from shard B in a single SQL statement. Cross-shard transactions require two-phase commit or saga patterns, both of which add latency and complexity. Rebalancing (splitting a hot shard or merging underutilized ones) is operationally painful and requires careful data migration.

The general rule is: delay sharding as long as possible. Vertical scaling, read replicas, caching, and query optimization can push the sharding boundary surprisingly far. When you do shard, shard by the dimension that aligns with your most common query pattern and accept the cost for cross-dimension queries.

Chapter 13

Multi-Region Architecture

A ride-hailing platform is not one global system — it is hundreds of city-scale systems unified by a shared identity and a global routing layer.

Traffic patterns and geographic hotspots

Ride-hailing traffic is inherently geographic. Demand spikes are local: a concert ending in downtown Austin, a rainstorm in London, New Year's Eve in Times Square. These spikes can be 5-10x normal traffic for a specific area while the rest of the world is unaffected. A monolithic global architecture would require provisioning for the worst-case sum of all regional peaks — wasteful and fragile.

The solution is a geographic architecture that routes requests to regional deployments, each independently scaled and operated. A rider in San Francisco hits San Francisco's infrastructure. A rider in Tokyo hits Tokyo's. Neither affects the other.

Swipe to pan
Geographic Partitioning · multi-regionGlobal DNSgeo routingUS-EastLBApp SvrsRedisPostgresEU-WestLBApp SvrsRedisPostgresriders and drivers in Paris matched locally in EU-West
Each region runs its own stack — matching is fundamentally a local problem

The three-tier geographic model

The architecture has three tiers. The Global Routing Layer sits at the edge and directs incoming requests to the correct region based on the client's GPS coordinates or IP address. It is implemented via DNS-based routing (GeoDNS) or an edge proxy (like Cloudflare Workers or AWS CloudFront with Lambda@Edge). This layer is stateless and globally distributed.

The Regional Layer contains a full deployment of all components — app servers, matching service, geospatial index, real-time gateway, notification service, analytics consumer — along with their databases and caches. Each region operates independently and can survive the failure of other regions. Regions are sized based on the traffic of the cities they serve: a US-West region might serve San Francisco, Los Angeles, and Seattle, while a South Asia region serves Mumbai, Delhi, and Bangalore.

The City/Zone Layer is a logical partitioning within a region. Hot cities like New York or London may have dedicated infrastructure (dedicated database shards, additional matching service instances) to handle their disproportionate traffic. Smaller cities in the same region share infrastructure.

Data locality and cross-region concerns

Geographic architecture provides excellent data locality. A ride in San Francisco reads and writes data in the US-West region. Latency is low because the data is close to the user. But some data is inherently global: user accounts, payment methods, and support tickets must be accessible from any region (a rider who usually rides in New York might request a ride while visiting London).

The standard approach is to replicate user profile data to all regions (eventually consistent, with the home region as the source of truth) and keep trip data local to the region where the trip occurred. Cross-region trip queries ("show me all my rides across all cities") are routed to a global aggregation service that fans out to regional databases.

Key takeaways
  1. 01Geographic architecture matches the natural locality of ride-hailing traffic — rides are local, so infrastructure should be local
  2. 02Global routing directs each request to the nearest regional deployment based on GPS or IP
  3. 03Each region is independently scalable and can survive other regions failing
  4. 04User profiles are replicated globally; trip data stays regional
  5. 05Hot cities within a region get dedicated infrastructure (database shards, additional service instances)
  6. 06Cross-region queries are rare and handled by a global aggregation layer, not by every region
Chapter 14

Failure Handling

In a distributed system, failure is not an exceptional event — it is a constant background condition. The question is never whether something will fail, but how the system behaves when it does.

Application server failure

An application server crashes mid-request. The load balancer detects the failure via health checks (HTTP pings every 5-10 seconds) and stops routing traffic to the dead instance. In-flight requests on that instance are lost — the clients receive a timeout and retry. Because application servers are stateless, the retry goes to a healthy instance and succeeds transparently.

The key design principle is that no user-visible state lives exclusively on an application server. Trip state is in PostgreSQL. Driver locations are in Redis. WebSocket connections are reestablished by the client. The application server is a pure compute node — losing one is like losing a worker in a factory. The work is reassigned, and the factory keeps running.

Redis failure

Redis holds driver locations and caches. If a Redis instance fails, the immediate impact is twofold: the geospatial index cannot find nearby drivers, and cached data is unavailable. For cached data, the fallback is straightforward — cache misses are served from PostgreSQL, at higher latency but without data loss.

For location data, the situation is more serious. Driver locations are only in Redis — there is no durable backup of the latest positions. When a new Redis instance starts, it is empty. Drivers' location updates will repopulate it within seconds (since drivers send updates every 3-6 seconds), but during those seconds, the matching engine operates on incomplete data. The system is degraded but functional — it matches riders with the subset of drivers whose locations have been received since the restart.

To reduce the blast radius, Redis is deployed as a cluster with replication. Each shard has a primary and one or more replicas. If a primary fails, a replica is promoted automatically (Redis Sentinel or Redis Cluster handles this). The failover takes 1-5 seconds, during which writes to that shard are rejected and reads return stale data.

Kafka failure

If Kafka brokers fail, asynchronous processing stops — notifications aren't sent, analytics aren't updated, and billing is delayed. But the critical path (ride requests, matching, trip state management) continues unimpeded because it doesn't depend on Kafka. The system operates in a degraded mode where trips complete successfully but receipts and notifications are delayed.

Kafka's built-in replication (typically RF=3) means a single broker failure is invisible — the partition leaders are redistributed to surviving brokers. A full Kafka outage is extremely rare but catastrophic for async workflows. The mitigation is a dedicated Kafka cluster with cross-datacenter replication and aggressive alerting on under-replicated partitions.

PostgreSQL failure

A PostgreSQL primary failure is the most serious single-component failure in the system. All transactional writes stop — no new trips can be created, no trip states can be updated, no driver assignments can be made. Reads can be served from replicas, but writes require a new primary.

The mitigation is automated failover: a standby replica is promoted to primary, typically within 10-30 seconds. Tools like Patroni, pg_auto_failover, or cloud-managed solutions (AWS RDS Multi-AZ) handle this automatically. The key metrics are RPO (Recovery Point Objective — how much data can you afford to lose, typically < 1 second with synchronous replication) and RTO (Recovery Time Objective — how quickly does the system recover, typically < 30 seconds with automated failover).

Network failures

Network failures are the most common and most insidious. A service might be running perfectly but unable to reach another service due to a network partition, DNS failure, or router misconfiguration. The symptoms are timeouts, retries, and eventually thread exhaustion.

The defenses are layered. Timeouts prevent a request from blocking forever on a dead connection — every outgoing call must have a timeout. Retries with exponential backoff recover from transient failures without amplifying load. Circuit breakers detect sustained failures and stop sending requests to a failing service, allowing it to recover rather than being overwhelmed by retry traffic.

Cascading failures

The most dangerous failure mode is cascading failure — where one component's failure triggers failures in others. The classic scenario: the database becomes slow, application server threads block waiting for database responses, the thread pool is exhausted, the server cannot handle any requests (including health checks), the load balancer marks it as dead, traffic shifts to remaining servers, which are now overloaded and also exhaust their threads. Within minutes, the entire fleet is down.

Swipe to pan
Cascading Failure · latency propagationLBRide Servicethreads fullMatching2200msLocation Svc2100msRedis2000msSLOWCircuit Breakertimeout: 200msfail fast · return fallback · protect upstreamthreads exhausted2200ms2100ms2000ms
A slow dependency causes thread exhaustion that propagates backward

The defenses against cascading failure are bulkheads (isolating critical resources so one slow dependency can't exhaust the shared thread pool), circuit breakers (stopping calls to a failing dependency before threads are consumed), load shedding (rejecting low-priority requests when the system is under stress to preserve capacity for high-priority ones), and backpressure (slowing down upstream producers when downstream consumers can't keep up).

Chapter 15

Security & Observability

Security protects the system from adversaries. Observability protects the system from itself — from the subtle bugs, performance regressions, and capacity limits that are invisible without instrumentation.

Authentication and authorization

Every API request must be authenticated — the system must know who is making the request. For mobile clients, the standard approach is JWT (JSON Web Token) issued after login and included in every request's Authorization header. The token contains the user's ID, role (rider or driver), and an expiration time. The API gateway validates the token's signature and expiration before forwarding the request to backend services.

Authorization ensures that authenticated users can only access their own data. A rider cannot view another rider's trip history. A driver cannot accept a ride offer meant for a different driver. These checks are implemented in the application layer, not the database layer, because they require business logic ("is this driver the one who was offered this ride?").

API protection

The API must be protected against abuse. Rate limiting prevents a single client from overwhelming the system — a rider who sends 1,000 ride requests per second is either a bug or an attacker. Rate limits are applied per user and per endpoint, with different limits for different operations (location updates are high-frequency by design; ride requests should be limited to a few per minute).

Input validation rejects malformed requests before they reach business logic. Latitude must be between -90 and 90. Longitude must be between -180 and 180. Ride type must be one of the allowed values. These checks prevent injection attacks and protect downstream services from unexpected data.

Data encryption

All data in transit must be encrypted with TLS — between client and server, between services, and between services and databases. Data at rest must be encrypted in the database and in Redis. Sensitive fields (phone numbers, payment method IDs) may be additionally encrypted at the application layer with customer-specific keys, enabling key rotation without re-encrypting the entire database.

Observability: the three pillars

Observability in a distributed system rests on three pillars: metrics, logs, and traces. Together, they answer the questions that arise during every incident: What is happening? Where is it happening? Why is it happening?

Metrics are numerical measurements collected at regular intervals. The key metrics for a ride-hailing platform are:

Request latency (p50, p95, p99) per endpoint. Error rate per endpoint and per service. Throughput (requests per second) per service. Matching latency — the time from ride request to driver assignment — is the single most important business metric. Driver utilization — the fraction of online time spent on trips — is the most important supply metric. Kafka consumer lag — how far behind each consumer is — is the most important infrastructure metric for async workflows.

Logs provide detailed records of individual events. Structured logging (JSON format with consistent fields) enables searching and filtering across millions of log entries. Every log entry should include a request ID or trace ID that connects it to a specific user action.

Distributed traces follow a single request across multiple services. When a rider requests a ride, the request touches the API gateway, app servers, geospatial index, matching service, and notification service. A distributed trace (implemented with OpenTelemetry or a similar framework) shows the latency contribution of each service, making it easy to identify which service is slow.

Chapter 16

Final Architecture

The final architecture is not a diagram drawn from scratch — it is the cumulative result of fifteen chapters of reasoning from requirements through bottlenecks to solutions. Every component exists because a specific problem demanded it.

Swipe to pan
Complete ArchitectureGlobal DNSLBApp 1App 2App 3Redisgeo indexPostgreSQL+ read replicaMatchinggeospatialKafkaevent busNotificationsAnalyticsRT GatewayWebSocketRider Appdecoupled services · async event flow · independent scaling
The complete architecture — every component earned by a specific requirement

Component inventory

Let's enumerate every component in the final system and why it exists:

API Gateway

The entry point for all client requests. Handles authentication (JWT validation), rate limiting, request routing, and TLS termination. Exists because these cross-cutting concerns should not be duplicated in every service.

App Servers

Generic stateless application tier that handles all business logic: ride requests, trip state management, driver location ingestion, and orchestration of the matching flow. Internally these responsibilities are logically separated (ride lifecycle, trip state machine, location updates), but they run as a single horizontally-scaled deployment behind the load balancer. Exists because keeping the stateless tier unified avoids premature microservice overhead while still allowing independent scaling by adding instances.

Matching Service

Scores candidate drivers and selects the best match for a ride request. Manages the offer-timeout-retry loop. Exists because matching is a complex, stateful operation that evolves independently of other concerns.

Geospatial Index

Performs spatial queries against the Redis location store — finding available drivers within a radius of a pickup point. Exists because geospatial querying is computationally distinct from CRUD operations and benefits from independent scaling.

Real-Time Gateway

Manages WebSocket connections with clients. Receives location and state updates from backend services and pushes them to connected clients. Exists because real-time push requires persistent connections and dedicated connection management.

Notification Service

Consumes trip events from Kafka and sends push notifications to riders and drivers. Exists because notifications are asynchronous, can tolerate delay, and should not block the critical path.

Analytics Consumer

Consumes all trip events from Kafka for dashboards, reporting, demand forecasting, and ML model training. Exists because analytical workloads have fundamentally different access patterns from operational workloads.

End-to-end request flow

The ride request flow: The rider taps "Request Ride." The request travels through the Global Routing Layer to the nearest regional deployment, hits the Load Balancer, and reaches the API Gateway, which validates the JWT and rate-limits. The gateway routes the request to the App Servers, which persist the ride request in PostgreSQL and call the Geospatial Index. The geospatial index queries Redis for available drivers within 3 km and returns a ranked list. The app servers pass the candidates to the Matching Service, which scores them and sends a ride offer to the top candidate via the Real-Time Gateway. If the driver accepts, the matching service atomically updates the driver's status in PostgreSQL (compare-and-swap), the app servers create a trip record and publish a DriverAssigned event to Kafka, and the Real-Time Gateway pushes a driver_assigned message to the rider's WebSocket.

The driver location flow: The driver's app sends a location update every 3-6 seconds to the App Servers via HTTP. The app servers write the new position to Redis via GEOADD and publish the update to an internal channel. The Real-Time Gateway picks up the update and pushes it to the rider's WebSocket connection, so the rider sees the driver's car moving on the map in near real-time.

The event flow: When the app servers record a state transition, they publish an event to Kafka. The TripCompleted event is consumed independently by the Notification Service (sends receipt and triggers payment via an external gateway) and the Analytics Consumer (records metrics). Each consumer operates at its own pace, maintains its own offset, and can be scaled independently. If one consumer is slow, it doesn't affect the others.

The decision journal

Every architectural decision in this system traces back to a specific requirement or bottleneck. It is worth reviewing these decisions as a coherent narrative:

We split storage into PostgreSQL and Redis because the location update workload (166K writes/sec) exceeds what any relational database can handle, while business data requires ACID guarantees that Redis cannot provide. We introduced a matching service because finding the nearest driver is not the same as finding the best driver, and the matching logic evolves independently. We chose Kafka for asynchronous processing because trip events need durable, ordered delivery to multiple independent consumers. We adopted WebSockets for real-time updates because polling at the required frequency would generate more load than the actual location updates. We deployed a geographic architecture because ride-hailing traffic is local and regional isolation provides both performance and fault tolerance benefits.

“Architecture follows requirements. If you cannot trace every component, every database, and every message queue back to a specific requirement or bottleneck, you have accidental complexity — and accidental complexity is the silent tax that slows down every future change.”

— A design principle
Key takeaways
  1. 01Start with the simplest architecture (monolith + PostgreSQL) and evolve as bottlenecks appear
  2. 02Separate transient high-throughput data (locations) from durable business data (trips) into different storage systems
  3. 03Use atomic compare-and-swap operations for driver assignment to prevent double-booking without distributed locks
  4. 04Keep the critical path short — the ride request returns immediately, assignment is an atomic conditional update on the critical path, and notifications and analytics are asynchronous via Kafka
  5. 05Design for geographic locality — ride-hailing traffic is inherently regional, so the architecture should be too
  6. 06Every component earns its place by solving a specific bottleneck — never add infrastructure speculatively
  7. 07Observability and failure handling are not afterthoughts — they are what keep the architecture running in production
  8. 08The trip state machine is the system's source of truth — its integrity is the most important invariant to protect