Trade-offs & Decisions
Every architectural decision is a trade-off. This section walks through the major decisions in our ride-hailing platform design, showing the problem, the options we considered, what we chose and why — and critically, under what conditions we would choose differently. There are no universally correct answers, only answers that fit specific requirements. This is a generic ride-hailing design built for teaching; it is not a description of Uber's (or any company's) internal architecture.
PostgreSQL vs NoSQL for Trip Data
Choosing a primary datastore is one of the earliest and most consequential decisions in any system design. The answer depends on data model complexity, access patterns, consistency requirements, and realistic scale projections.
The Problem
Trip data is inherently relational. A trip references a rider, a driver, pickup and dropoff locations, a fare calculation, a payment method, and a rating. Users query this data in multiple ways — by rider, by driver, by time range, by geographic area. We need ACID transactions for fare calculations and payment processing, where partial writes would mean lost revenue or overcharges.
Option A: PostgreSQL
Relational Strengths
PostgreSQL provides ACID transactions (critical for payments), flexible ad-hoc queries via SQL, strong consistency guarantees, mature tooling for migrations and backups, and a rich ecosystem of extensions like PostGIS for geospatial queries. The trade-off is that vertical scaling has limits, and horizontal scaling (sharding) adds significant operational complexity.
Option B: Distributed NoSQL (Cassandra / DynamoDB)
NoSQL Strengths
Distributed NoSQL databases offer near-linear horizontal scaling, high availability through replication, and predictable latency for known access patterns. The trade-off is that you lose transactions across partitions, flexible querying (you must model around your queries), and the tooling ecosystem is narrower. Schema changes and data migrations are harder to reason about.
Our Decision
We choose PostgreSQL for trip and user data. At our projected scale, a well-configured PostgreSQL instance with read replicas handles the load. The transactional guarantees are non-negotiable for payment processing — a trip that charges a rider but fails to credit a driver is a business-critical bug. The relational model maps naturally to our domain, and SQL's flexibility lets us support the diverse query patterns that operations, analytics, and customer support teams need.
Under different requirements, NoSQL may be the better choice. If our access patterns were purely key-value (look up trip by ID, nothing else), if we needed to handle millions of writes per second across dozens of regions, or if our data model were denormalized and document-shaped, a distributed NoSQL store like Cassandra or DynamoDB would scale more predictably. The key question is: do you need the relational model's power, or are you paying for capabilities you won't use?
- 01PostgreSQL is chosen for transactional integrity and flexible querying of trip data
- 02NoSQL would be preferred for simple key-value access at extreme write volume
- 03The decision hinges on data model complexity and consistency requirements, not just scale
Redis vs Database for Driver Location
Not all data has the same durability requirements. Driver location updates arrive at 166,000 writes per second, but a driver's position from five seconds ago has almost zero value. Recognizing the difference between transient and durable data unlocks a much simpler architecture.
The Problem
Active drivers send GPS coordinates every 6 seconds (10 updates per minute). With 1 million active drivers at peak, that produces roughly 166,000 location updates per second. Each update must be stored so the matching service can find nearby drivers for new ride requests. Simultaneously, completed trip routes must be stored durably for billing, disputes, and analytics.
The Options
Option A: PostgreSQL with Aggressive Tuning
We could tune PostgreSQL for high write throughput — batch inserts, disable fsync for location data, use unlogged tables. This keeps the stack simple with one storage technology. However, 166K writes/sec of ephemeral data alongside transactional trip data creates resource contention, bloated WAL logs, and vacuum pressure. We would be fighting the database's durability guarantees for data that does not need them.
Option B: Redis with Geospatial Support
Redis GEOADD and GEOSEARCH (which supersedes the deprecated GEORADIUS) commands are purpose-built for this workload. Writes are typically sub-millisecond, geospatial queries return nearby drivers in well under a few milliseconds for modest result sets, and memory footprint is modest (each driver's location is a few hundred bytes). The trade-off is that Redis is not durable by default — a crash loses recent data. But for current driver positions, this is acceptable because a fresh update arrives within seconds.
Option C: Specialized Geospatial Store
Systems like Tile38 or PostGIS-backed services offer geospatial-optimized storage with geofencing and pub/sub capabilities. These are powerful but introduce operational overhead for a specialized system that the team must learn, deploy, and maintain. The capabilities beyond what Redis provides (geofencing, complex spatial queries) are not critical for driver matching.
Our Decision
We use a two-tier storage strategy. Redis stores current driver locations — the transient, high-frequency data that powers real-time matching. PostgreSQL stores completed trip records — the durable, transactional data that powers billing, history, and analytics. Each store handles the workload it was designed for, and neither is burdened with the other's requirements.
If Redis feels like overkill or the team lacks Redis operational experience, a perfectly valid alternative is an in-memory data structure within the Location Service itself, with periodic snapshots. This trades cluster-wide availability for simplicity. The right answer depends on team capabilities and operational maturity.
This decision is about where location data lives — a storage-system question. A related but distinct question is how that data is indexed for fast proximity lookups once it's there: see the Geohash vs Quadtree vs H3 comparison in the Architecture Deep Dive.
- 01Redis handles 166K location writes/sec with sub-millisecond latency using native geospatial commands
- 02PostgreSQL remains the system of record for durable trip data
- 03Matching storage technology to data characteristics often beats a one-size-fits-all approach
Rule-Based Multiplier vs. ML-Based Demand Prediction for Surge Pricing
Once you have a live supply/demand ratio per area, you need a way to turn it into a price multiplier — fast, and in a way you can explain to a rider, a regulator, or a support agent when someone asks why their fare doubled.
The Problem
The signal itself — the ratio of open ride requests to available drivers per area — is the easy part; it's covered in the Architecture Deep Dive's surge pricing engine. The harder decision is the function that converts that ratio into an actual multiplier, quickly enough to update in real time, and in a way whose behavior is predictable and defensible after the fact.
The Options
Option A: Rule-Based Thresholds
Map ratio bands directly to fixed multipliers — for example, a ratio under 1.2 gets 1.0x, 1.2-1.5 gets 1.3x, 1.5-2.0 gets 1.8x, and so on. This is simple to implement, trivial to explain to a rider or a regulator ("demand is currently double supply in your area, hence the 1.8x"), and easy to audit after the fact since the mapping is a fixed, published table. The downside is that it's blunt: a rule-based system can't distinguish a ratio of 1.51 from 2.9 within the same band, and thresholds tuned for one city's typical patterns may be badly miscalibrated for another.
Option B: ML-Based Demand Prediction
Train a model on historical and real-time signals (time of day, day of week, local events, weather, trailing ratio trend) to predict near-future demand and set a multiplier that's more finely tuned to actual conditions than a fixed threshold table. This adapts to patterns a rule-based system misses entirely — a model can learn that Friday evenings near a stadium behave differently from Friday evenings in a business district, without anyone hand-tuning a separate rule for each. The costs are real: a model's output is harder to explain in a support ticket or a regulatory inquiry than a published threshold table, and it has a cold-start problem — a new market has no historical data to train on, so it either inherits another market's model (often a poor fit) or runs on rule-based thresholds until enough data accumulates.
Our Decision
We favor rule-based thresholds as the default, with ML-based prediction layered in only for markets with enough operating history to train and validate a model reliably. Explainability is not a nice-to-have for pricing systems — it is close to a hard requirement, given regulatory scrutiny that ride-hailing pricing has attracted in multiple markets. A model that can't produce a rider-facing explanation of "why this fare" carries a risk that a simple threshold table does not. Where ML is used, we keep the rule-based thresholds as a fallback and a sanity check: if the model's suggested multiplier deviates far from what the threshold table would produce, that's flagged for review rather than applied automatically.
For the mechanics of how either approach actually computes and smooths the live multiplier — including the feedback-loop and anti-gaming considerations — see the Surge Pricing Engine deep dive; this decision is about which family of approach to use, not how the chosen approach is implemented.
- 01Rule-based thresholds are simple, explainable, and auditable — the safer default given regulatory scrutiny on pricing
- 02ML-based prediction adapts to patterns rule-based systems miss, but is harder to explain and has a cold-start problem in new markets
- 03A hybrid works well in practice: ML where there's enough data to trust it, rule-based thresholds as the default and as a sanity-check fallback
- 04This decision governs the multiplier function's approach, not its real-time computation — see the Architecture Deep Dive for mechanics
Polling vs WebSocket for Real-Time Updates
When a rider is watching their driver approach, they expect smooth, near-real-time movement on the map. The choice of how to push those updates involves a spectrum of trade-offs between simplicity, efficiency, and operational complexity.
The Problem
During an active trip, riders need to see the driver's location updated every few seconds. Between ride requests, riders might check the app infrequently or not at all. The update pattern is bursty — zero updates when idle, continuous updates during a trip. We need a communication model that handles both modes efficiently without wasting resources during idle periods.
The Options
Short Polling
The client sends HTTP requests at a fixed interval (e.g., every 2 seconds). This is the simplest approach — stateless, works through all proxies and firewalls, and easy to implement. The downside is waste: if no new data exists, the request-response cycle is pure overhead. At 1 million active riders polling every 2 seconds, that is 500,000 requests per second, most returning no new data.
Long Polling
The client sends a request, and the server holds it open until new data is available or a timeout expires. This reduces empty responses but creates complications: each held connection consumes a server thread or connection slot, timeouts must be tuned, and load balancers may struggle with long-lived HTTP connections. It is a half-measure that inherits some costs of both polling and persistent connections.
WebSocket
A persistent, bidirectional connection between client and server. After the initial HTTP handshake upgrades to WebSocket, both sides can send messages freely with minimal framing overhead. This is highly efficient for frequent updates but requires connection state management, sticky sessions or a connection registry, and graceful handling of disconnections and reconnections.
Server-Sent Events (SSE)
A unidirectional push channel from server to client over a standard HTTP connection. SSE is simpler than WebSocket — it uses regular HTTP, supports automatic reconnection, and works through most proxies. However, it is one-directional (server to client only), and some older environments have connection limits per domain.
Our Decision
We choose WebSocket for active trip tracking. During an active trip, updates arrive every 6 seconds and the rider is actively watching the map. The persistent connection eliminates per-request overhead, and the bidirectional channel lets us push updates instantly rather than waiting for the client to ask. We establish the WebSocket when a trip begins and tear it down when the trip ends, limiting the number of concurrent connections to the number of active trips rather than total users.
For non-trip interactions — ETA estimates on the home screen, surge pricing updates, promotion notifications — short polling or SSE is preferable. These updates are infrequent and do not justify persistent connections. The key principle is matching the communication pattern to the update frequency.
- 01WebSocket is chosen for active trip tracking due to high-frequency updates
- 02Short polling or SSE is preferred for infrequent, non-urgent updates
- 03Scope persistent connections to the duration they are actually needed
Synchronous vs Asynchronous Processing
When a rider requests a ride, a cascade of operations must happen: match a driver, notify both parties, calculate pricing, create billing records, log analytics events, send push notifications. Not all of these are equally urgent, and treating them as if they are creates fragile, slow request paths.
The Problem
A single ride request triggers at least six downstream operations. If all are processed synchronously in the request path, the total latency is the sum of all operations, and any single failure causes the entire request to fail. A slow analytics write or a notification service timeout should not prevent a rider from getting matched with a driver.
Defining the Critical Path
The critical path includes only operations that the user is directly waiting for: validating the ride request, finding a nearby available driver, confirming the match, and returning the assignment to the rider. These MUST be synchronous because the rider needs an immediate answer — 'your driver is on the way' or 'no drivers available.' Everything else — sending the push notification to the driver, creating the billing record, updating analytics dashboards, notifying the ETA service — can happen after we respond to the rider.
The Options
Option A: Everything Synchronous
All downstream operations execute within the request lifecycle. The advantage is simplicity — no message queues, no eventual consistency, easy to reason about and debug. The disadvantage is compounding latency and a large failure domain. If the notification service takes 500ms, that adds 500ms to every ride request. If the analytics service is down, ride creation fails.
Option B: Critical Path Sync, Rest Async
Synchronous processing handles ride validation, driver matching, and assignment confirmation. All other operations are published as events to a message queue and processed asynchronously. This isolates the user-facing latency from downstream processing and limits the failure domain. The trade-off is eventual consistency — the billing record might lag the ride creation by a few seconds, and debugging async failures requires distributed tracing.
Our Decision
We process the critical path synchronously and publish events to Kafka for everything else. After the Ride Service confirms a driver match, it publishes a 'ride.created' event. The Notification Service, Billing Service, Analytics Service, and ETA Service each consume this event independently. If the Billing Service is slow, riders and drivers are unaffected. If the Analytics Service is down, ride creation continues and analytics events are processed when the service recovers.
There are cases where full synchronous processing is appropriate. If you are building an internal tool with low traffic, the simplicity of synchronous calls outweighs the latency cost. If strong consistency across all operations is a regulatory requirement (e.g., financial transaction auditing), async processing introduces complexity that may not be worth the trade-off. The decision depends on traffic volume, latency requirements, and consistency needs.
- 01Only ride validation, driver matching, and assignment sit on the critical synchronous path
- 02Notifications, billing, analytics, and reporting are processed asynchronously via Kafka
- 03Async processing isolates downstream failures from the user experience
Kafka vs Direct Service Calls
Once we decided to process non-critical operations asynchronously, the next question is how. The choice between event streaming and direct service calls is fundamentally a question of coupling, durability, and how many consumers need to react to each event.
The Problem
When a trip is created, at least four services need to know about it: Notification, Billing, Analytics, and ETA. When a trip completes, a similar set of services must react. The producing service (Ride Service) should not need to know about every consumer, and adding a new consumer should not require modifying the producer.
The Options
Option A: Direct HTTP Calls
The Ride Service calls each downstream service directly via HTTP. This is straightforward — easy to implement, easy to trace, and provides immediate feedback on success or failure. However, the Ride Service becomes tightly coupled to every consumer. Adding a fifth consumer means modifying and redeploying the Ride Service. If any consumer is slow or down, the Ride Service must handle retries, timeouts, and circuit breaking for each one.
Option B: Kafka Event Streaming
The Ride Service publishes a 'trip.created' event to a Kafka topic. Each consumer subscribes independently. Kafka provides durable storage of events (configurable retention), parallel consumption via consumer groups, and replay capability for reprocessing. The trade-off is operational complexity — Kafka clusters require careful management of partitions, replication, and consumer lag monitoring.
Option C: Lightweight Message Queue (RabbitMQ / SQS)
A traditional message queue provides decoupling and buffering without Kafka's full feature set. RabbitMQ supports flexible routing, priority queues, and dead-letter exchanges. It is lighter to operate than Kafka but its classic queues lack durable log-based storage and replay (RabbitMQ Streams add a log, but that is a different structure from a classic queue). Once a message is consumed and acknowledged from a classic queue, it is gone. If a consumer had a bug that processed events incorrectly, you cannot replay the events to fix it.
Our Decision
We choose Kafka. The durability and replay capability are decisive factors. Trip events are high-value — a lost billing event means lost revenue, a lost analytics event means incomplete reporting. Kafka's log-based storage means events persist for a configurable retention period, and any consumer can reprocess events from any point in time. When the Analytics team deploys a new aggregation pipeline, they can replay the last 30 days of events without asking the Ride Service to re-emit them.
Direct HTTP calls are the right choice when you have few consumers (one or two), low event volume, and you need strong consistency between the producer and consumer. If the Billing Service must confirm that it recorded the charge before the Ride Service considers the trip complete, a synchronous HTTP call is simpler and more reliable than an async event with a callback. Similarly, if you are building a system with a small team and low traffic, the operational cost of Kafka may exceed its benefits — RabbitMQ or even direct calls with retry logic may be sufficient.
Direct Calls: Ride Service → Notification Service
Ride Service → Billing Service
Ride Service → Analytics Service
(Producer knows all consumers)
Kafka: Ride Service → [trip.created topic] → Notification Service
→ Billing Service
→ Analytics Service
→ (future consumers)
(Producer knows only the topic)- 01Kafka decouples producers from consumers — new consumers require zero producer changes
- 02Log-based storage enables event replay for debugging, reprocessing, and new consumer bootstrap
- 03Direct calls remain valid for low-consumer-count, strong-consistency scenarios
Vertical vs Horizontal Scaling
When load exceeds capacity, there are exactly two options: a bigger machine or more machines. The answer is almost always 'vertical first, horizontal when necessary' — but knowing when 'necessary' arrives is the real skill.
The Problem
Our application server is saturated under peak load. At 166,000 requests per second across the platform, a single server cannot handle the traffic regardless of how powerful it is. We need to decide how to add capacity.
The Options
Option A: Vertical Scaling
Upgrade to a more powerful machine — more CPU cores, more RAM, faster storage. This is the simplest scaling strategy because the application does not need to change. No load balancers, no distributed state management, no consensus protocols. The limits are hard: physical hardware has a ceiling, a single machine is a single point of failure, and the cost curve is superlinear (a machine with 2x the capacity costs more than 2x the price).
Option B: Horizontal Scaling
Run multiple instances of the application behind a load balancer. This provides theoretically unlimited capacity and eliminates single points of failure. The cost is complexity: the application must be stateless (or use external session stores), load balancing must distribute traffic effectively, deployments must handle rolling updates across instances, and health checks must detect and remove unhealthy instances.
“Don't distribute a system until you have to. A single well-tuned server can handle more than most engineers assume. But when you do distribute, do it deliberately — design for horizontal from the start rather than bolting it on after.”
— Common distributed systems principle
Our Decision
We design for horizontal scaling. At 166,000 requests per second, vertical scaling simply cannot keep up — even the most powerful single machine has finite CPU and network bandwidth. Our services are designed to be stateless, with all shared state in Redis or PostgreSQL, so adding instances is a configuration change rather than an architectural one.
However, vertical scaling is the correct first answer for most systems. If your application handles 500 requests per second and your current server is a 2-core VM, upgrading to an 8-core machine is faster, cheaper, and less risky than building a horizontally scaled architecture. The break-even point depends on your application, but horizontal scaling is worth the complexity cost only when vertical scaling demonstrably cannot meet your requirements or when you need fault tolerance that a single machine cannot provide.
- 01Vertical scaling is simpler and should be the first approach until its limits are reached
- 02Horizontal scaling is necessary at 166K req/sec — no single machine handles this load
- 03Stateless service design enables horizontal scaling as a configuration change
Read Replicas vs Sharding
Database scaling is one of the hardest problems in system design because the costs of getting it wrong are severe — data loss, downtime, and migrations that take months. The right strategy depends on whether your bottleneck is reads, writes, or both.
The Problem
As trip volume grows, the database faces increasing load from both read and write operations. Trip history queries from riders, analytics dashboards for operations teams, and real-time lookups from active services all compete for database resources. We need to scale the database without sacrificing consistency or dramatically increasing operational complexity.
The Options
Option A: Read Replicas
Create one or more read-only replicas of the primary database. Read-heavy workloads (trip history, analytics, search) are routed to replicas, while writes go to the primary. This is straightforward to set up — PostgreSQL streaming replication is mature and well-documented. The limitations are: the primary still handles all writes (no write scaling), replicas have replication lag (reads may return slightly stale data), and the total data must fit on a single machine's storage.
Option B: Sharding
Partition data across multiple database instances by a shard key (e.g., user_id or city_id). This distributes both reads and writes, and each shard holds only a fraction of the data. The complexity is substantial: choosing the right shard key is critical (a bad choice creates hotspots), cross-shard queries are expensive or impossible, rebalancing shards as data grows requires careful orchestration, and every application query must be shard-aware.
Our Decision
We start with read replicas and aggressive caching. Most of our database pressure is read-heavy — riders checking trip history, the matching service querying nearby drivers, analytics computing metrics. Read replicas handle this workload effectively. We cache frequently accessed data (user profiles, recent trip details, fare estimates) in Redis to further reduce database load. This combination handles significant scale with minimal architectural complexity.
Sharding becomes necessary when write volume exceeds what a single primary can handle, or when total data size exceeds single-machine storage limits. For a ride-hailing platform, this threshold might be reached when operating in dozens of large cities simultaneously. At that point, sharding by city or region is a natural partition — trips in New York rarely need to join with trips in London, so cross-shard queries are infrequent.
- 01Read replicas handle read-heavy workloads with minimal complexity
- 02Aggressive caching further reduces database load for hot data
- 03Sharding is deferred until write volume or storage size exceeds single-primary limits
- 04When sharding is needed, city or region is a natural partition key for ride-hailing data
Single Region vs Multi-Region
Multi-region architecture is often presented as a best practice, but it introduces some of the hardest problems in distributed systems — cross-region consistency, data sovereignty, and operational complexity that multiplies with every region added. It should be adopted deliberately, not by default.
The Problem
A ride-hailing platform operating in multiple countries faces three pressures: latency (a rider in Tokyo should not wait for a round trip to a US data center), availability (a regional outage should not take down the entire platform), and data locality (regulations in some jurisdictions require user data to remain within geographic boundaries).
The Options
Option A: Single Region
All services and data live in one cloud region. Operations are simple — one deployment pipeline, one database cluster, no cross-region replication. The downsides are clear: users far from the region experience higher latency, a regional cloud outage affects all users, and data sovereignty requirements cannot be met. For a platform serving a single country or metro area, these downsides may not matter.
Option B: Multi-Region
Services are deployed in multiple regions, with data replicated or partitioned across them. Users are routed to the nearest region for low latency, and regional failures are isolated. The costs are enormous: cross-region data replication introduces consistency challenges (a rider's account updated in one region must be visible in another), deployment pipelines multiply, debugging requires correlating logs across regions, and infrastructure costs increase significantly.
Our Decision
We adopt a regional architecture only when geographic expansion and traffic volume justify it. For a platform operating in a single city or country, a single region with availability zone redundancy provides sufficient reliability and performance. The latency from a well-chosen region to users within the same country is typically acceptable for a ride-hailing application.
When expansion to distant geographies occurs, ride-hailing data has a natural regional partition: a trip in Sao Paulo has no relationship to a trip in Berlin. This means we can deploy independent regional stacks that share only global data (user accounts, payment methods) rather than replicating everything everywhere. Global data that tolerates concurrent updates can use conflict-free replicated data types (CRDTs); data that needs strong consistency, such as payment methods, is better served by a designated primary region for writes, with async replication to other regions for reads.
- 01Single region with availability zone redundancy is sufficient for single-country operations
- 02Multi-region is justified by geographic expansion, latency requirements, or data sovereignty regulations
- 03Ride-hailing data partitions naturally by geography, simplifying regional architecture
- 04Global data (accounts, payments) requires careful cross-region consistency strategy
- 05Each layer of distributed complexity should be justified by a specific, measurable requirement