Skip to content
SeeItFlow
Uber System Design

Architecture Deep Dive

You understand the high-level architecture: API gateway, microservices, databases, message queues. Now we go deeper. Each chapter dissects a critical component — how it actually works under production load, the data structures it uses, the failure modes it must handle, and the engineering trade-offs behind every decision.

Mangesh MandlikLast updated:
Deep Dive 01

Matching Service

The matching service is the heart of a ride-hailing platform. It takes a rider's pickup location and finds the best available driver — in under 500 milliseconds, while thousands of other requests compete for the same pool of drivers.

The Four-Stage Pipeline

Matching is not a single operation — it is a pipeline with four distinct stages. First, candidate discovery: the service queries the location index for all available drivers within a configurable radius (typically 3 km, expanding to 5 km in low-supply areas). This returns a set of 10–200 driver IDs with their last known coordinates. Second, filtering: candidates are removed if they cannot serve this ride — wrong vehicle type, driver has reached their daily hour limit, or the rider has previously given the driver a 1-star rating. Third, scoring: each remaining candidate receives a composite score based on straight-line distance, estimated time of arrival (ETA), driver rating, acceptance rate, and current heading direction. ETA dominates the score because a driver 2 km away on a highway may arrive faster than one 800 meters away on congested streets. Fourth, selection: the highest-scoring driver receives the offer.

Swipe to pan
Matching PipelineCandidates narrow at each stage until one driver is assigned.Ride Requestincoming1Geo LookupN candidates2FilteringM filtered3Scoringranked4Assignment1 selected5GEORADIUSavail + vehicle typeETA + distanceatomic CAS
The matching pipeline narrows from all nearby drivers to the single best candidate

Distance vs ETA

Straight-line (Haversine) distance is cheap to compute but misleading in cities. A driver across a river may be 500 meters away but 15 minutes by road. The matching service calls a routing engine (OSRM or Valhalla) to compute actual driving ETA for the top-N candidates after filtering. Computing ETA for all 200 candidates would be too expensive, so the service first sorts by straight-line distance and only computes ETA for the top 10–20.

How ETA Is Actually Calculated

Estimating time-of-arrival sits on a spectrum, and most systems that need it — not just ride-hailing — end up choosing somewhere along the same three points: Naive distance. Straight-line (Haversine) distance divided by an assumed average speed. It costs almost nothing to compute and requires no map data, but it is badly wrong in any city with rivers, highways, or one-way streets — the reason the matching pipeline above never uses it beyond an initial cheap filter.

Road-graph routing. A routing engine (OSRM, Valhalla, or a commercial equivalent) runs a shortest-path search — Dijkstra or A* — over the actual road network, weighted by historical and live traffic conditions. This is what the matching pipeline calls for its top candidates: it is accurate enough to rank drivers correctly, but too expensive to run against every candidate in a large pool, which is exactly why the pipeline pre-filters by straight-line distance first.

ML-based prediction. A model trained on real-time and historical trip data — current traffic, time of day, day of week, weather, road segment history — predicts ETA directly rather than computing a shortest path. This can outperform pure routing-engine estimates because it captures patterns a static graph search cannot (a segment that is reliably slower every weekday at 5 PM, for instance). The central modeling challenge is distribution shift: traffic patterns change day to day and season to season, so a model trained on last month's data quietly degrades unless it is retrained continuously against fresh signal.

ApproachAccuracyLatencyInfra costComplexity
Naive (Haversine)Low in citiesMicrosecondsNoneTrivial
Road-graph routingHighSingle-digit ms (co-located)Routing engine + map dataModerate
ML-based predictionHighest, if kept freshLow ms (served model)Training + serving pipelineHigh — needs continuous retraining
Three approaches to ETA estimation, compared

Concurrency: The Same Driver, Two Ride Requests

When two ride requests arrive simultaneously and both select Driver A as their best match, only one can win. The matching service discovers candidates via the Redis geospatial index, but the actual assignment commitment uses an atomic conditional UPDATE in PostgreSQL: UPDATE driver SET status = 'assigned' WHERE id = X AND status = 'available'. Both requests attempt this concurrently. The one that succeeds (affecting one row) proceeds; the one that fails (zero rows affected) immediately moves to the next-best candidate without re-querying the location index. This retry uses the already-scored candidate list, so it adds only microseconds of latency.

When a driver rejects the offer (or the offer times out after 15 seconds), the service does not start from scratch. It walks down the pre-scored candidate list and offers the ride to the next driver. If the entire candidate list is exhausted, the service re-runs candidate discovery with an expanded radius. After three failed rounds, the ride request is marked as 'no drivers available' and the rider is notified.

Batched Matching Under High Load

During surge periods, individual matching becomes suboptimal. If 50 ride requests arrive within the same second in a small area, greedy matching (each request independently picks the closest driver) leads to poor global outcomes — riders further from drivers may get matched first, leaving closer riders with worse options. Large ride-hailing systems commonly switch to batched matching during high load. It collects requests over a short window (200–500 ms), constructs a bipartite graph of riders and drivers, and solves an assignment optimization that minimizes total ETA across all matches. This is a variant of the Hungarian algorithm, modified to run in O(n^2) time with practical heuristics for the candidate set size.

Beyond Batch-Local Matching: Fleet-Wide Optimization

Batched matching solves the problem within one short time window, but it is still local — it optimizes across the riders and drivers that happened to arrive in the same 200-500 ms slice, not across the whole fleet. Published industry work on this problem frames it more generally as a linear-programming or network-flow assignment: model every open ride request and every available driver in a region as nodes in a bipartite graph, and solve for the assignment that minimizes total ETA (or maximizes some other global objective) across the entire graph, not just one batch.

The trade-off is real. A fleet-wide solve needs a broader, more current view of driver state than a batch-local one, and the optimization itself is more expensive to run — which pushes latency in the wrong direction for a system with a 200 ms p50 target. In practice, this means fleet-wide optimization tends to run on a longer cadence (recomputing a broader assignment plan every few seconds) layered on top of, not instead of, the fast batch-local matching described above — batch-local matching handles the immediate assignment, and periodic fleet-wide re-optimization corrects the accumulated drift.

Key takeaways
  1. 01Matching is a four-stage pipeline: discover, filter, score, select
  2. 02ETA dominates scoring because distance is misleading in urban environments
  3. 03Atomic conditional UPDATE in PostgreSQL on driver status prevents double-dispatch
  4. 04Batched matching during surge periods produces globally better outcomes than greedy individual matching
  5. 05The pre-scored candidate list enables fast fallback when a driver rejects
Deep Dive 02

Location Service & Geospatial Index

Every active driver sends a GPS update every 6 seconds — 10 updates per minute. At peak, with 1 million active drivers, the location service ingests 166,000 updates per second (1M drivers × 10 updates/min = 10M/min ÷ 60 ≈ 166K/sec). Each update must be written to the geospatial index and be immediately queryable by the matching service.

Redis Geospatial Commands

The location index uses Redis's built-in geospatial data structure, exposed through GEOADD and GEOSEARCH commands. Under the hood, Redis represents geospatial data as a sorted set where the score is a 52-bit geohash of the coordinates. GEOADD driver-locations <longitude> <latitude> driver:4829 inserts or updates a driver's position. GEOSEARCH driver-locations FROMLONLAT <lng> <lat> BYRADIUS 3 km ASC COUNT 200 finds the 200 nearest drivers within 3 km. Both operations are O(log(N) + M) where N is the total number of entries and M is the number of results — fast enough for our throughput requirements.

Geohash Encoding

A geohash converts a two-dimensional coordinate (latitude, longitude) into a one-dimensional sortable string by interleaving the bits of the latitude and longitude binary representations. The key insight is that points sharing a common geohash prefix are geographically close. A 6-character geohash represents a cell roughly 1.2 km x 600 m. A 7-character geohash narrows to ~150 m x 150 m. Redis uses a 52-bit integer geohash (equivalent to about 10 characters of base-32 geohash), giving sub-meter precision. The sorted set structure means range queries on the geohash score efficiently find all points within a geographic region.

Geohash vs Quadtree vs H3: Choosing a Spatial Index

Geohash is not the only way to index two-dimensional space, and it is worth knowing what the alternatives trade away. A quadtree recursively subdivides space into four quadrants, but only where points actually exist — a quadtree over a city automatically produces small cells downtown and large cells in sparse suburbs, adapting to real point density instead of imposing one fixed grid resolution everywhere. That adaptivity is exactly what a uniform geohash grid lacks: a geohash cell is the same size whether it covers a dense downtown block or an empty stretch of highway.

H3, Uber's own open-source hexagonal hierarchical spatial index, addresses a different geohash weakness: square cells have neighbors at two different distances (adjacent-edge vs. diagonal-corner), which distorts distance and neighbor calculations near cell boundaries. A hexagonal grid gives every neighboring cell the same center-to-center distance, which makes "find the k-ring of neighboring cells" — the same operation Redis approximates by checking eight neighboring geohash cells — cleaner and more uniform. The cost is that hexagons cannot be subdivided as simply as a quadtree, and the tooling ecosystem, while mature, is less universally supported than a plain geohash string that any datastore can sort on.

In practice, the choice tracks what the system already relies on: a Redis-backed system that just needs "nearby points, sortable by a single string" gets most of the benefit from geohash with minimal machinery, as this deep dive covers above. A system doing heavier geometric analysis — density-aware clustering, hex-grid aggregation for analytics or pricing zones — has more reason to reach for H3 or a quadtree, even at the cost of more specialized tooling.

Update Strategy: Overwrite, Never Append

Location data is purely last-write-wins. When a driver sends a new GPS coordinate, the previous coordinate is overwritten — there is no history in the hot path. GEOADD is an upsert: if the member (driver ID) already exists in the sorted set, its score (geohash) is updated in place. This means the data size is bounded by the number of active drivers, not the number of updates. With 1 million active drivers and roughly 200 bytes per entry (member key + score + sorted set overhead), the entire location index fits in approximately 200 MB of Redis memory.

Co-Locating Availability with Location

The matching service needs drivers who are both nearby AND available. Maintaining a separate availability index and intersecting results with the location index adds latency and complexity. Instead, the service uses separate Redis sorted sets per availability state: driver-locations:available, driver-locations:on-trip, driver-locations:offline. When a driver's status changes, their entry is moved between sorted sets atomically using a Lua script that executes ZREM + GEOADD in a single Redis transaction. The matching service only queries driver-locations:available.

Redis vs PostgreSQL: What Goes Where

Redis holds the current, ephemeral location data: where is this driver right now. PostgreSQL holds durable location history: the GPS trace of every trip (used for fare calculation, dispute resolution, and analytics). The location service writes to both: a fast synchronous write to Redis for real-time queries, and an asynchronous write (via Kafka) to PostgreSQL for permanent storage. If Redis loses data, drivers re-announce their positions within 6 seconds via their next heartbeat. If PostgreSQL loses data, trip history is gone — hence it requires proper backup and replication.

Key takeaways
  1. 01Redis geospatial sorted sets provide O(log N + M) spatial queries
  2. 02Geohash encodes 2D coordinates into a 1D sortable value, enabling range scans on a single index
  3. 03Location data is overwrite-only — bounded by driver count, not update count
  4. 04Separate sorted sets per availability state avoid expensive intersections
  5. 05Redis holds ephemeral current positions; PostgreSQL holds durable trip traces
Deep Dive 03

How a Ride-Hailing Surge Pricing Engine Works

When ride requests in an area outnumber available drivers, prices rise — surge pricing is the mechanism that connects a real-time supply/demand imbalance to a price multiplier. It is one of the most frequently asked follow-on questions in system design interviews for exactly this reason: this chapter describes a generic design, not any company's internal implementation. The algorithm itself is simple to state, but the second-order effects (drivers gaming it, riders reacting to it, fairness across neighborhoods) are where a strong answer separates from a shallow one.

The Supply/Demand Signal Pipeline

Surge pricing needs a live, per-area ratio of demand to supply: how many riders are actively requesting a ride in this area right now, versus how many drivers are available to take one. Computing this per exact GPS coordinate would be meaningless — you need a stable notion of "this area," which is exactly what the geospatial cells from the Location Service deep dive already give you. Reusing the same geohash (or H3) cells that back driver-location lookups means surge computation piggybacks on infrastructure that already exists, rather than standing up a second geospatial system: open ride requests and available drivers are both counted per cell, and the ratio is recomputed continuously as both numbers change.

Multiplier Calculation

The raw demand/supply ratio maps to a price multiplier through a function tuned so that a ratio near 1.0 (roughly balanced) produces no surge, and the multiplier climbs as the ratio grows. A naive implementation would recompute this multiplier every time a new request or driver update changes the ratio — but the ratio for a small area fluctuates second to second as individual requests come and go, and a multiplier that jumps between 1.2x and 2.1x within a few seconds is both a bad rider experience and hard for anyone to reason about. Production systems smooth the signal: averaging the ratio over a short rolling window, and capping how much the multiplier is allowed to move between updates, so the number a rider sees is stable enough to trust.

The Feedback Loop Problem

Anti-Gaming Measures

Because drivers can see the effect of supply on price, the mechanism creates an incentive to manipulate it. Two commonly discussed patterns: drivers going offline as an area approaches surge, waiting for the multiplier to rise, then coming back online to accept the now-higher fare — reducing effective supply exactly when it is most needed; and riders opening the app, seeing a surge multiplier, and simply walking a few blocks into an adjacent zone with a lower multiplier before requesting. Mitigations address both: a minimum surge duration (once an area surges, the multiplier holds for some minimum window so it can't be gamed by very short online/offline cycles) and geographic smoothing (multipliers are blended across adjacent cells rather than changing sharply at a cell boundary, reducing the incentive to walk one block for a materially lower price).

Fairness Considerations

Communicating Surge in Real Time

Once a multiplier is computed for a cell, riders and drivers in that area need to see it update live — a rider opening the app should see the current surge before requesting, and a driver should see it change as they move between zones. This is a delivery problem, not a pricing problem: it reuses the same real-time push mechanism (WebSocket connections, per-user channel delivery) already covered in the Real-Time Service deep dive, rather than needing a separate communication path.

Key takeaways
  1. 01Surge pricing reuses the same geospatial cells as driver-location indexing — no separate geospatial system needed
  2. 02Multiplier calculation must smooth the underlying ratio, or riders see an unstable, untrustworthy number
  3. 03The feedback loop (price affects both demand and supply simultaneously) is the core modeling challenge, not the multiplier formula itself
  4. 04Minimum surge duration and geographic smoothing mitigate the most common gaming patterns
  5. 05Fairness across neighborhoods has no fully clean algorithmic fix — treat it as an ongoing measurement and policy concern
  6. 06Delivering the live multiplier to riders and drivers reuses the existing real-time push infrastructure
Deep Dive 04

Trip State Machine

A trip passes through a strict sequence of states from request to completion. The state machine is the single source of truth for what is happening with a trip, and every transition must be validated, atomic, and durable.

States and Transitions

The trip lifecycle has six primary states. REQUESTED: the rider has submitted a ride request but no driver is assigned. MATCHING: the system is actively searching for a driver. ASSIGNED: a driver has accepted and is en route to the pickup location. ARRIVING: the driver is within 200 meters of the pickup point. STARTED: the rider has been picked up and the trip is underway. COMPLETED: the trip has ended normally. There is also a CANCELLED state, which can be reached from REQUESTED, MATCHING, ASSIGNED, or ARRIVING — but never from STARTED (once a trip is underway, it must be completed or handled as a special case through support).

The valid transitions form a directed graph: REQUESTED can move to MATCHING or CANCELLED. MATCHING can move to ASSIGNED or CANCELLED. ASSIGNED can move to ARRIVING or CANCELLED. ARRIVING can move to STARTED or CANCELLED. STARTED can only move to COMPLETED. Each transition has an actor: the system triggers REQUESTED→MATCHING and MATCHING→ASSIGNED, the driver app triggers ASSIGNED→ARRIVING and ARRIVING→STARTED and STARTED→COMPLETED, and either the rider or driver can trigger transitions to CANCELLED (with different cancellation policies depending on the current state).

Swipe to pan
Trip State MachineRequestedMatchingAssignedArrivingStartedCompletedCancelled
Only valid transitions are allowed — invalid state changes are rejected

Atomic State Transitions in PostgreSQL

The trip state lives in PostgreSQL, and transitions use conditional updates to ensure atomicity. The pattern is a WHERE clause that asserts the expected current state. If the row's current state does not match, zero rows are updated and the service knows the transition was invalid or already applied.

sqlConditional update: the trip moves to STARTED only if it is currently in ARRIVING state and the requesting driver is the assigned driver. The version column enables optimistic concurrency control for external consumers.
UPDATE trips
SET    status      = 'STARTED',
       started_at  = NOW(),
       updated_at  = NOW(),
       version     = version + 1
WHERE  id          = $1
  AND  status      = 'ARRIVING'
  AND  driver_id   = $2
RETURNING id, status, version;

If RETURNING yields no rows, the service checks the current state. If the trip is already STARTED (idempotent retry), it returns success. If the trip is in an unexpected state (CANCELLED), it returns an appropriate error. This pattern eliminates the need for SELECT-then-UPDATE, which would require explicit row locking or risk race conditions.

Edge Cases in State Transitions

Driver cancels mid-trip: while the system disallows cancellation during STARTED through the normal state machine, the driver might stop responding entirely. A background process monitors active trips and flags those with no driver location update for 5 minutes. These trips are escalated to support, not automatically cancelled, because the rider may still be in the vehicle. Rider cancels during matching: the system marks the trip as CANCELLED and immediately releases any tentative driver assignment. If the matching service has already dispatched an offer to a driver, the cancellation races with the driver's acceptance — this is resolved by the atomic conditional UPDATE in PostgreSQL: when the matching service tries to assign the driver, the conditional update fails because the trip is no longer in MATCHING state. Timeout on no-response: if a driver does not respond to an offer within 15 seconds, the system triggers MATCHING→MATCHING (re-matching) by selecting the next candidate.

Key takeaways
  1. 01The state machine has six primary states with strictly defined valid transitions
  2. 02Conditional SQL updates enforce valid transitions atomically without row locking
  3. 03Idempotent retries are handled by checking whether the transition already occurred
  4. 04Edge cases (driver disappears, cancellation races) are resolved by the same conditional update pattern
  5. 05The version column supports external consumers who need optimistic concurrency control
Deep Dive 05

Real-Time Service

When a rider is watching their driver approach on the map, they expect smooth, real-time updates. The real-time service maintains persistent WebSocket connections to every active rider and driver, and routes location updates and trip events to the correct connections — across a fleet of horizontally scaled servers.

WebSocket Connection Lifecycle

When the rider app opens, it establishes a WebSocket connection to the real-time service through the load balancer. The connection is authenticated using a JWT token passed in the initial handshake. Upon successful authentication, the server registers the connection in a connection registry: a Redis hash mapping user_id → server_id:connection_id. This registry is the key to routing messages across a multi-server deployment. When the connection drops, the server removes the registry entry and cleans up associated subscriptions.

Swipe to pan
Real-Time Driver Tracking · push vs. pollDriver AppApp ServerRedisGEOADDRT GatewayRiderlat/lng 6spushPUSHRider AppServerwhere's my driver?repeat every 3s...POLL
Location updates push from driver to rider through the real-time gateway

Routing Messages Across Servers

Consider the flow: Driver A's location update needs to reach Rider B's phone. Driver A is connected to Server 3. Rider B is connected to Server 7. The location service publishes the update to a Redis Pub/Sub channel keyed by trip_id. All real-time servers subscribe to channels for their connected users' active trips. Server 7, which holds Rider B's connection, receives the message from Redis Pub/Sub and pushes it down Rider B's WebSocket. This fan-out pattern decouples the producer (location service) from the consumer (specific WebSocket server), at the cost of each server subscribing to all relevant channels.

Connection-Aware Load Balancing

WebSocket connections are long-lived (minutes to hours), unlike HTTP requests (milliseconds). Standard round-robin load balancing distributes connections evenly at creation time, but connection churn is uneven — some servers may accumulate more connections over time. Two approaches exist. Sticky sessions (using consistent hashing on user_id) ensure reconnections hit the same server, reducing registry churn but creating hotspots if a popular area maps to one server. The connection registry approach (used here) lets any server handle any connection and relies on the registry for routing. This is more flexible but adds a Redis lookup to every message route.

Reconnection and Missed Updates

Mobile connections are unreliable. When a WebSocket drops and the client reconnects, it may have missed several location updates and a trip state change. The real-time service handles this with a reconnection handshake: the client sends a last_received_sequence number. The server checks a short-lived buffer (Redis list, TTL 60 seconds) of recent events for that user and replays any events after the given sequence number. If the sequence gap is too large or the buffer has expired, the server sends a full state snapshot of the current trip: driver location, trip status, ETA. This is simpler and more reliable than guaranteeing zero message loss over WebSockets.

Heartbeat and Connection Health

The server sends a WebSocket ping frame every 30 seconds. If no pong is received within 10 seconds, the connection is considered dead and cleaned up. On the client side, if no message (including pings) is received for 45 seconds, the client assumes the connection is dead and initiates a reconnection. This dual-sided health check catches both server-side failures (server crash, network partition) and client-side failures (app backgrounded, network switch).

Scaling to 1M+ Concurrent Connections

A single server process can handle 50,000–100,000 concurrent WebSocket connections (depending on message rate and payload size). Reaching 1 million connections requires 10–20 server instances. The bottleneck is not CPU or memory on the WebSocket servers — it is the Redis Pub/Sub fan-out. With 500K active trips, each generating 1 location update every 6 seconds, Redis Pub/Sub must deliver ~83K messages/second across all subscribers. Redis Pub/Sub is single-threaded, so this approaches its throughput limit. At larger scale, the architecture shifts to partitioned Pub/Sub (multiple Redis instances, each handling a shard of trip channels) or replaces Redis Pub/Sub with a dedicated message broker like NATS.

Key takeaways
  1. 01A connection registry in Redis maps user IDs to server instances for cross-server message routing
  2. 02Redis Pub/Sub provides the fan-out mechanism for trip-scoped events
  3. 03Reconnection uses sequence-based replay from a short-lived buffer, falling back to a full state snapshot
  4. 04Connection draining with client-side jitter prevents thundering herds during deploys
  5. 05At extreme scale, partitioned Pub/Sub or NATS replaces single-instance Redis Pub/Sub
Deep Dive 06

Kafka Event Pipeline

Kafka is the nervous system connecting services that do not need synchronous communication. Trip events, location traces, and notification signals all flow through Kafka topics, consumed by independent services at their own pace.

Topic Design

The platform uses several core topics. trip-events carries all trip state transitions (created, matched, started, completed, cancelled) as structured events. location-events carries the raw GPS stream from drivers — high volume (166K events/sec at peak) but tolerant of occasional loss. notification-events carries push notification requests (ride matched, driver arriving, trip completed) consumed by the notification service. Each topic is configured independently: trip-events uses replication factor 3 with acks=all for durability, while location-events uses replication factor 2 with acks=1 for throughput, accepting the small risk of data loss on broker failure.

Swipe to pan
Async Event Processing · KafkaTrip ServiceproducerKafka · trip-eventspartition-0partition-1partition-2TripCreatedNotificationsSMS · pushAnalyticsmetrics · billingdecoupled · independent scalingTripCreated · DriverAssigned · TripCompleted
Each consumer group processes events independently at its own pace

Partition Key Selection

The partition key determines which partition receives a message, and messages within a partition are strictly ordered. For trip-events, the partition key is trip_id. This guarantees that all events for a single trip (created, matched, started, completed) arrive at the same partition in order — critical for consumers that need to reconstruct trip state. For location-events, the partition key is driver_id, ensuring all updates from one driver are ordered (so consumers do not process an older location after a newer one). Notification-events use user_id as the partition key to prevent duplicate notifications from concurrent consumer processing.

Consumer Groups

A Kafka consumer group is a set of consumer instances that divide the partitions of a topic among themselves. Each partition is consumed by exactly one instance in the group, enabling parallel processing. The trip-events topic has multiple consumer groups: notification-consumer (sends push notifications on trip state changes) and analytics-consumer (writes events to the data warehouse for reporting and downstream processing). Each group processes events independently and at its own pace — if the analytics pipeline is slow, it does not affect notification delivery.

Exactly-Once Is Hard — Use Idempotency

Kafka supports exactly-once semantics through transactions and idempotent producers, but end-to-end exactly-once processing requires the consumer's side effects to be part of the same transaction — which is impossible when the side effect is sending a push notification or charging a credit card. In practice, the system uses at-least-once delivery with idempotent consumers. Each event carries a unique event_id. The consumer records processed event_ids in a deduplication table (or checks the idempotency of the downstream operation). For notifications, this means using the notification provider's built-in deduplication key. For analytics, it means checking whether an event with this event_id has already been written to the warehouse.

Consumer Lag and Dead Letter Queues

Consumer lag is the difference between the latest offset in a partition and the consumer group's committed offset. A lag of zero means the consumer is fully caught up. Lag is monitored per consumer group per partition, with alerts at different thresholds: a lag exceeding 10,000 messages triggers a warning, and a lag exceeding 100,000 triggers a page. High lag indicates either a slow consumer (scale up instances), a poison message (consumer crashes and restarts on the same message), or a downstream dependency failure.

Poison messages — events that consistently crash the consumer — are handled with a dead letter queue (DLQ) pattern. After N failed processing attempts (typically 3), the event is published to a separate DLQ topic (e.g., trip-events-dlq) and the consumer advances past it. DLQ events are reviewed manually or by an automated recovery process. The DLQ topic has a long retention period (30 days) and is monitored for growth.

Schema Evolution

Event schemas inevitably change: new fields are added, old fields are deprecated. The platform uses a schema registry (Confluent Schema Registry or equivalent) with Avro or Protobuf serialization. Schema evolution rules enforce backward compatibility: new fields must have defaults, existing fields cannot be removed or have their types changed. This allows new producers to emit enriched events while old consumers continue to function, reading only the fields they recognize. Breaking schema changes require a new topic version (e.g., trip-events-v2) with a migration period where both old and new topics are active.

Key takeaways
  1. 01Topic design balances durability (replication, acks) against throughput per use case
  2. 02Partition keys (trip_id, driver_id) guarantee per-entity ordering
  3. 03Multiple consumer groups process the same events independently for different purposes
  4. 04At-least-once delivery with idempotent consumers is more practical than exactly-once
  5. 05Dead letter queues isolate poison messages so they do not block the consumer pipeline
  6. 06Schema evolution rules enforce backward compatibility to prevent consumer breakage
Deep Dive 07

PostgreSQL at Scale

PostgreSQL is the system of record for all durable business data: users, drivers, trips, payments, and trip history. It must handle thousands of writes per second for active trips while serving analytical queries on millions of historical records — and it must never lose a committed transaction.

Data Distribution Across Tables

The schema divides into hot and cold data. Hot tables see constant writes: trips (status updates every few seconds for active trips), trip_locations (GPS points appended throughout each trip), and payments (created on trip completion). Cold tables are read-heavy: users and drivers (rarely updated), trip_history (an archived view of completed trips), and fare_adjustments. Understanding this distinction drives every scaling decision — hot tables need write optimization and careful index management, while cold tables benefit from read replicas and materialized views.

Connection Pooling with PgBouncer

Each microservice maintains its own connection pool. With 20+ microservices, each configured for 20 connections, PostgreSQL would face 400+ backend connections — each consuming ~10 MB of memory for work_mem, sort buffers, and process overhead. PgBouncer sits between the services and PostgreSQL, multiplexing hundreds of client connections onto a smaller pool of actual database connections (typically 50–100). In transaction pooling mode, a backend connection is assigned to a client only for the duration of a transaction, then returned to the pool. This reduces PostgreSQL's connection overhead by 4–8x.

Read Replicas

Streaming replication creates one or more read replicas that receive WAL (write-ahead log) records from the primary and apply them asynchronously. Replication lag is often well under a second, but it can grow under heavy write load. Trip history queries, analytics dashboards, customer support lookups, and reporting all read from replicas, keeping the primary free for writes. The application routes queries based on staleness tolerance: any query that must reflect the latest state (checking current trip status) goes to the primary; queries where 1-second-old data is acceptable go to a replica.

When and How to Shard

A single PostgreSQL instance handles on the order of ~10K writes/second with properly tuned hardware (the Complete Design quotes an illustrative 10,000–30,000 range; we plan shards against the conservative end). Beyond that, vertical scaling hits diminishing returns and sharding becomes necessary. The shard key must satisfy two requirements: even distribution across shards and query locality (most queries should hit a single shard). For a ride-hailing platform, region_id (coarse geographic area, e.g., city or metro area) is the primary shard key. All trips, drivers, and location data for a region live on the same shard. Cross-region queries (a rider who travels between cities) require scatter-gather across shards, but these are rare. The shard key is embedded in the trip_id using a composite format: {region_id}_{uuid}, allowing the routing layer to extract the shard from the ID without a lookup.

Indexes and Query Optimization

The most critical indexes are: a composite index on trips(status, region_id) for finding active trips in a region; a composite index on trips(driver_id, status) for finding a driver's current trip; an index on trips(rider_id, created_at DESC) for trip history; and a partial index on trips(status) WHERE status IN ('REQUESTED', 'MATCHING', 'ASSIGNED', 'ARRIVING', 'STARTED') — this covers only the ~0.1% of trips that are currently active, making it extremely small and fast. The trip_locations table uses a BRIN (Block Range INdex) on created_at, which is ideal for append-only time-series data — it stores min/max values per block of pages rather than indexing every row, using 1000x less space than a B-tree.

VACUUM, Table Bloat, and High-Write Tables

PostgreSQL's MVCC model means every UPDATE creates a new row version and marks the old one as dead. The trips table, with status updated multiple times per trip, accumulates dead tuples rapidly. Autovacuum reclaims this space, but under heavy write load, it may not keep up — leading to table bloat (the table's physical size grows far beyond its logical size) and index bloat (indexes contain pointers to dead tuples). Tuning autovacuum for hot tables is essential: lower the autovacuum_vacuum_scale_factor to 0.01 (trigger vacuum when 1% of rows are dead, instead of the default 20%) and increase autovacuum_vacuum_cost_limit to let vacuum do more work per cycle. For the trip_locations table, which is append-only, consider partitioning by date — old partitions can be detached and archived instead of vacuumed.

Key takeaways
  1. 01Separate hot (trips, payments) and cold (users, history) tables for different optimization strategies
  2. 02PgBouncer reduces connection overhead by 4-8x through transaction-level pooling
  3. 03Read replicas handle analytical and historical queries, freeing the primary for writes
  4. 04Shard by region_id to keep geographically related data co-located
  5. 05Partial indexes on active trip statuses keep index size minimal
  6. 06Aggressive autovacuum tuning is critical for high-write tables to prevent bloat
Deep Dive 08

Redis Architecture

Redis serves multiple roles in the platform: geospatial index for driver locations, state store for driver availability, session cache, and rate limiter. Each role has different durability requirements, different data shapes, and different failure characteristics.

What Lives in Redis

The Redis deployment holds four categories of data. Driver locations: a geospatial sorted set per availability state, containing every active driver's current coordinates. Driver availability: string keys (driver:{id}:status) caching the current state (available, assigned, on-trip, offline) for fast lookups; the actual assignment commitment uses an atomic conditional UPDATE in PostgreSQL. Session cache: user authentication tokens and session data with TTL-based expiration, avoiding a database round-trip on every API request. Rate limiting: sliding window counters per API key and per user, implemented with sorted sets and ZRANGEBYSCORE to count requests within the current window.

Memory Estimation

Sizing Redis correctly prevents both over-provisioning (wasted cost) and under-provisioning (OOM evictions). Driver locations: 1 million active drivers, each stored as a sorted set member with a ~30-byte key and an 8-byte geohash score. With Redis sorted set overhead (two pointers per element in the skip list, plus the hash table entry), each entry consumes roughly 200 bytes. Total: 1M x 200B = 200 MB. Driver availability: 1M string keys, each ~50 bytes with value. Total: ~50 MB. Session cache: 5M active sessions, each ~500 bytes with serialized session data. Total: ~2.5 GB. Rate limiting: 100K active API keys, each with a sorted set of ~100 recent timestamps. Total: ~500 MB. Grand total: ~3.25 GB — comfortably fits on a single large Redis instance, but spread across a cluster for redundancy and read throughput.

Redis Cluster for Horizontal Scaling

Redis Cluster divides the keyspace into 16,384 hash slots distributed across master nodes. Each master handles a subset of slots, and each has one or more replicas for failover. Key distribution is determined by hashing the key name (or, if present, the hash tag in curly braces). For the driver location sorted sets, hash tags ensure that all availability-state sets (driver-locations:available, driver-locations:on-trip) land on the same node: {driver-locations}:available. This enables Lua scripts to atomically move a driver between sets, since both keys are guaranteed to be on the same shard. For session cache and rate limiting keys, which are keyed by user_id, distribution is naturally even.

Persistence: RDB, AOF, or Neither

Redis offers two persistence mechanisms: RDB snapshots (periodic full dumps to disk) and AOF (append-only file logging every write command). For driver locations, neither is necessary — the data is ephemeral and fully reconstructed from driver heartbeats within seconds of a restart. Enabling persistence on the location data would add disk I/O overhead with no benefit. For session cache, RDB snapshots every 5 minutes provide a reasonable trade-off: on restart, most sessions are restored, and the few sessions created in the last 5 minutes simply require re-authentication. For rate limiting data, no persistence is needed — a restart effectively resets rate limits, which is an acceptable (even desirable) behavior during an outage.

What Happens When Redis Restarts

If the location Redis instance restarts (or a cluster node fails over), the geospatial index is empty. Drivers do not need to take any special action — their regular heartbeat (every 6 seconds) includes a GEOADD that repopulates the index. Within 6 seconds, a portion of drivers have re-announced; within 18 seconds, effectively all active drivers are back in the index. During this rehydration window, the matching service may see fewer candidates than expected. It handles this by expanding its search radius automatically when candidate count is below a threshold. Driver availability state (the cached status keys) is also lost on restart. The system recovers by having each driver's next heartbeat re-register their current status. During the recovery window, a driver might receive a duplicate dispatch if the matching service does not find their 'assigned' status — this is caught by the conditional update on the trips table in PostgreSQL, which serves as the ultimate source of truth.

Cache vs Primary Store

Redis plays two fundamentally different roles, and confusing them leads to architectural mistakes. As a cache (session data), Redis improves performance but is not authoritative — the database can regenerate the data if the cache is lost. Losing the cache causes increased latency and database load, but no data loss. As a primary store (driver locations, availability state), Redis holds the only copy of this data. Losing it means the data is gone until rehydrated. This is acceptable only because the data is short-lived and self-healing (drivers re-announce). If the system stored anything that cannot be reconstructed — such as pending fare calculations or unacknowledged notifications — using Redis as the primary store would be a design flaw. Those belong in PostgreSQL or Kafka.

Key takeaways
  1. 01Redis serves four distinct roles: geospatial index, state store, session cache, and rate limiter
  2. 02Memory estimation is straightforward: 1M drivers need only ~200 MB for locations
  3. 03Hash tags in Redis Cluster ensure related keys land on the same shard for atomic Lua scripts
  4. 04Persistence strategy varies by data type: none for ephemeral locations, RDB for sessions
  5. 05Driver heartbeats rehydrate the location index within seconds of a Redis restart
  6. 06Redis as primary store is safe only for data that is short-lived and self-healing
  7. 07PostgreSQL remains the ultimate source of truth — Redis is an optimization layer