Design Uber | System Design Question
"Design Uber" is one of the most-asked system design interview questions, and for good reason: it packs almost every hard problem in distributed systems into one product. Millions of GPS pings a second, matching moving supply to moving demand in real time, money that must never be lost, and all of it under a three-second latency budget. This post walks the whole question end to end the way you would in a 60-minute interview: scope it, size it, build the architecture up one component at a time from real numbers, then go deep on the parts that actually make it hard.
We will define every term as it shows up, so if you have never heard of a "geospatial index" or a "quorum write," you will still be able to follow. If you want the interview process itself (timing, how to recover when you blank), the 60-minute system design framework covers that separately - here we apply it to Uber.
Step 1: Scope the problem (and expect messy answers)
The single biggest mistake is to start drawing boxes. You do not yet know what you are building. A ride-hailing app could mean a city taxi dispatcher or a global marketplace with surge pricing and pooled rides. So the first five to ten minutes are questions. Real interviewers do not all answer the same way, so here is the realistic range:
You: Are we designing for a single city or globally? Interviewer: Assume global, but a rider only ever cares about their own city.
You: How many active users and drivers should I size for? Interviewer: You tell me. You: Okay - Uber is public about roughly 130 million monthly riders and around 5 to 6 million drivers. Let me assume about 5 million drivers online at peak and 1 million concurrent ride requests. I will write that down as an assumption and size to it.
You: Do we need to cover food delivery and package delivery too? Interviewer: Yes, let us add Uber Eats while we are at it. You: That is really a second marketplace with its own supply (restaurants) and a different matching shape. It roughly doubles the surface area. I will keep the core ride-hailing design clean and, if we have time at the end, I will note the two or three places Eats reuses it. Is that fair? Interviewer: Fair, keep going.
That captures the three things that actually happen. Sometimes you get a clear answer - use it. Sometimes the interviewer punts ("you tell me") - propose a concrete number, say it out loud, and write it down as a stated assumption so nobody argues about it later. And sometimes a weaker interviewer bolts on scope ("add Eats") - name its one-line cost and defer it, rather than silently doubling your workload and running out of time.
Functional requirements (what the system must do)
- A rider can request a ride from a pickup location to a drop location.
- The system finds nearby available drivers and matches the rider to one.
- Rider and driver see each other's live location on the map during the trip.
- The trip moves through a lifecycle: requested, accepted, driver arriving, in-trip, completed.
- At the end, fare is computed and payment is charged.
- Drivers can go online or offline and accept or decline ride offers.
Non-functional requirements (the qualities that make it hard)
- Low latency: matching a rider to a driver should feel instant - target under 3 to 5 seconds end to end.
- High availability: the app must work during peak (New Year's Eve, airport rush). A few seconds of stale data is acceptable; being down is not.
- Consistency where money is involved: a rider must be charged exactly once, and one driver must be assigned to at most one trip at a time. Everywhere else we can trade consistency for speed.
- Massive write throughput: every online driver streams its location continuously.
- Geographically distributed: a rider in Mumbai must never wait on a server in Virginia.
Back-of-the-envelope estimate
Sizing is what turns hand-waving into engineering. If this step feels shaky, the back-of-the-envelope estimation walkthrough drills it; here is the version that matters for Uber.
The dominant load is not ride requests - it is location updates. Every online driver sends its GPS position every 4 seconds:
Online drivers at peak = 5,000,000
Update interval = 1 update / 4 seconds
Location writes per second = 5,000,000 / 4
= 1,250,000 writes/sec
That is 1.25 million writes per second, just for driver locations. Compare that to ride requests:
Concurrent ride requests ~ 1,000,000 at peak
Average trip ~ 15 minutes
New requests per second = 1,000,000 / (15 x 60)
~ 1,100 requests/sec
So writes of location outnumber ride requests by roughly 1000 to 1. That single ratio dictates the whole architecture: the location pipeline must be its own highly optimized system, separate from the trip and payment systems, which are comparatively low volume but need strong guarantees.
How big is the live location data? Each driver record is tiny - an id, a latitude, a longitude, a timestamp, maybe a status:
Per driver ~ 40 bytes
5,000,000 drivers = 5,000,000 x 40 bytes
= 200 MB
Two hundred megabytes. The entire live map of every driver on Earth fits comfortably in the memory of a single machine. That is a huge clue: live locations belong in an in-memory store, not a disk database. The history of every ping, on the other hand, accumulates forever (1.25M/sec x 40 bytes is roughly 4.3 TB per day) and belongs in cheap, write-optimized storage.
Step 2: High-level design, built from the numbers
Now we build the architecture. The rule I follow: never draw a box until a number forces it into existence. Each component below is reasoned in, not assumed.
The trip request path
A rider opens the app and requests a ride. That request hits an API Gateway - a single entry point that handles TLS, authentication, and routing to the right backend service. Behind it sits a Load Balancer spreading requests across many identical, stateless service instances (stateless means an instance holds no per-user data between requests, so any instance can serve any request and we can add or remove instances freely). At 1,100 requests/sec this tier is easy; we size it for the location traffic and traffic spikes, not the trips.
The gateway routes the request to the Trip Service, which owns the lifecycle of a ride. It creates a trip record in state REQUESTED and asks the Matching Service to find a driver. Here is that flow:
The location pipeline (the 1.25M writes/sec problem)
This is the component the numbers screamed for. Five million drivers streaming a position every 4 seconds cannot go through the normal request path - it would crush any SQL database. We need a dedicated Location Service fed by a firehose of updates.
The write path splits in two, because the same ping has two very different jobs:
The live index - "where is every driver right now?" This must be overwritten on every ping and read by matching thousands of times a second. It goes into Redis, an in-memory key-value store (data lives in RAM, so reads and writes are sub-millisecond). We sized the live set at ~200 MB, so it fits in memory with room to spare. On each ping we simply overwrite the driver's current position - we do not keep old ones here.
The history - "what path did this driver take?" needed for fraud checks, dispute resolution ("the app says I was charged for 8 km"), and training ETA models. History is append-only and enormous (~4 TB/day), so it goes to Cassandra, a distributed database built for very high write volume that spreads data across many machines. To keep the hot write path fast, the Location Service does not write Cassandra synchronously; it publishes each ping to Kafka (a durable, ordered log that decouples producers from consumers) and a pool of consumer workers drains Kafka into Cassandra in the background.
This "Redis for live, Cassandra for history, Kafka in between" split is the standard shape for ride-hailing and delivery systems, and it falls straight out of the write-throughput number.
How drivers and riders stay connected
Notice the box in front of the Location Service is a WebSocket Gateway, not the HTTP API Gateway. A WebSocket is a connection that stays open in both directions after a single handshake, so either side can send a message at any time without re-opening a connection. We need it because the server has to push to the client: push a ride offer to a driver, push the driver's moving dot to the rider's map. Managing hundreds of thousands of these long-lived connections (which server holds which user's socket, how to route a message to the right one) is its own problem; the WhatsApp design works through that connection-management machinery in detail, so we will not re-derive it here.
One subtlety worth calling out, because interviewers love it: the driver-to-server location upload is one-directional (the driver only sends), and high write volume alone does not justify WebSockets - plain HTTP could carry those pings. The reason we still use a persistent connection is the other direction: the server must push offers and live tracking back down. Do not answer "WebSockets because 1.25M writes/sec"; answer "WebSockets because the server also needs to push."
The trips database
Trip records - who, when, from, to, fare, status - are the source of truth for money and history. At ~1,100 new trips/sec and 15-minute lifetimes, volume is modest but the data is precious and relational (a trip references a rider, a driver, a payment). This is the one place a classic relational SQL database (Postgres or MySQL) earns its keep: it gives us transactions and strong consistency. We shard it - split the rows across many database machines - by trip id or city so no single box holds everything; the database scaling guide covers sharding and replication if that is new.
Assembling everything reasoned in so far - the request path, the location pipeline, the connection layer, the trips and payment stores, plus a Pricing/Surge service and a User/Auth service - gives the high-level architecture:
Now the interesting part. Two boxes hide the real difficulty: how the Matching Service finds nearby drivers fast, and how it picks one without ever double-booking. Those are the deep dive.
Step 3: Deep dive - finding nearby drivers fast
The core question: given a rider's location, find the available drivers within a few kilometers, out of millions, in milliseconds. This is called a proximity search or geospatial query.
Why the obvious approach fails
The naive approach: loop over every driver, compute the distance from the rider, keep the close ones. Distance between two latitude/longitude points uses the Haversine formula (great-circle distance on a sphere):
type Point = { lat: number; lng: number };
function haversineKm(a: Point, b: Point): number {
const R = 6371; // Earth radius in km
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
function toRad(deg: number): number {
return (deg * Math.PI) / 180;
}
// The naive query: check EVERY driver. This is the trap.
function findNearbyNaive(rider: Point, drivers: Driver[], radiusKm: number): Driver[] {
return drivers.filter((d) => haversineKm(rider, d.location) <= radiusKm);
}
findNearbyNaive is correct and completely unusable. It is O(n) per query - it touches all 5 million drivers for every one of the 1,100 requests per second. Worse, the drivers are moving, so you cannot pre-sort them once. We need to stop looking at drivers who are obviously far away without checking them one by one. That is exactly what a spatial index does: it groups the world into buckets so you only look inside the few buckets near the rider.
Idea 1: Geohash (turn a 2D point into a 1D string)
A geohash encodes a latitude/longitude into a short string like tdr1y, by repeatedly splitting the world in half and recording which half the point is in. The key property: nearby points usually share a prefix. All drivers whose geohash starts with tdr1 are in roughly the same small square. So instead of scanning everyone, we bucket every driver by their geohash prefix and, for a rider, look only in their bucket:
// Group drivers by a geohash prefix. In production this key lives in Redis.
type Driver = { id: string; location: Point; available: boolean };
function bucketByGeohash(drivers: Driver[], precision: number): Map<string, Driver[]> {
const buckets = new Map<string, Driver[]>();
for (const d of drivers) {
if (!d.available) continue;
const cell = geohashEncode(d.location, precision); // e.g. "tdr1y"
const list = buckets.get(cell) ?? [];
list.push(d);
buckets.set(cell, list);
}
return buckets;
}
Look-up is now roughly O(1): compute the rider's geohash, read that one bucket, and only run Haversine on the handful of drivers inside it. The longer the prefix, the smaller the square (more precision), so we choose a precision whose cells are about the size of a city block.
But geohash has a famous flaw - the edge problem. Two drivers 50 meters apart can sit on opposite sides of a cell boundary and share almost no prefix, so a single-cell lookup misses one of them. The fix is to always search the rider's cell plus its 8 neighbors (a 3x3 block of cells), which guarantees you catch drivers near the edges. It works, but square cells have an ugly quirk: a driver in a diagonal (corner) neighbor is farther away than one in an edge neighbor, even though both are "one cell away." That distance distortion biases matching.
Idea 2: H3 (Uber's hexagonal grid) - reasoning past the obvious
Geohash and its cousin the quadtree (a tree that recursively splits a square into four) both use squares, so both inherit the uneven-neighbor problem. Uber's answer was to build and open-source H3, a grid of hexagons instead of squares. Hexagons tile the plane so that all six neighbors are the same distance from the center - "one cell away" finally means one consistent distance in every direction, which is exactly what a fair "nearest driver" search needs.
H3 is also hierarchical: it has 16 resolution levels, from continent-sized cells down to cells under a square meter. Uber uses roughly resolution 7 to 9 (neighborhood to city-block sized) for matching. Google's S2, which uses square cells mapped along a space-filling curve, is the other common choice and Uber uses it too in parts of the matching engine; the trade-offs are similar, and either is a defensible interview answer as long as you can say why.
The live lookup then becomes a hash lookup, not a scan:
import { latLngToCell, gridDisk } from "h3-js";
const MATCH_RESOLUTION = 9; // ~city-block sized cells
// On every driver ping we store the driver in a Redis set keyed by its H3 cell.
// SADD cell:<h3index> <driverId> (and remove it from the old cell)
function driverCellKey(location: Point): string {
const cell = latLngToCell(location.lat, location.lng, MATCH_RESOLUTION);
return `cell:${cell}`;
}
// To find candidates: the rider's cell plus one ring of neighbours (7 cells total).
function candidateCells(rider: Point): string[] {
const cell = latLngToCell(rider.lat, rider.lng, MATCH_RESOLUTION);
return gridDisk(cell, 1).map((c) => `cell:${c}`); // center + 6 hex neighbours
}
For each rider we read 7 small Redis sets, union the driver ids, and only then run the expensive step - real road-network ETA - on that tiny candidate list. The flow:
The important reasoning move here is not "use H3 because Uber does." It is: naive scan is O(n) and dies -> bucket by geohash to get O(1) lookups -> but square cells distort distance and break at edges -> so use a hexagonal hierarchical index whose neighbors are uniform. Show that chain and the specific library name is almost a footnote.
Step 3 continued: matching without double-booking
Finding candidates is half the job. Now the Matching (dispatch) Service must pick one driver and assign the trip - and it must never hand the same driver to two riders, or the same rider two drivers.
Greedy versus batched matching
The simple approach is greedy: the moment a request arrives, grab the single closest available driver and offer the trip. It is easy but locally short-sighted - it can send a far driver to rider A right before a much closer driver frees up, leaving rider B stranded. Uber instead does batched matching: every couple of seconds it takes all pending requests and all available drivers in a city and computes a globally better assignment (minimizing total wait time across everyone). Batching trades a tiny bit of latency (a second or two) for markedly better matches, which in a marketplace is the right trade.
The double-booking problem needs a lock
Two riders can request at the same instant and both see the same nearby driver as available. If both offers go out, the driver gets double-booked. This is a classic race condition (two operations interleaving on shared state and corrupting it). The fix is a distributed lock: before offering a driver, atomically mark them as reserved, so the second request finds them taken. Redis gives us this cheaply with an atomic "set only if the key does not exist," with an expiry so a crashed matcher does not lock a driver forever:
import type { Redis } from "ioredis";
// Try to reserve a driver for a trip. Returns true only for the FIRST caller.
async function tryReserveDriver(
redis: Redis,
driverId: string,
tripId: string,
ttlSeconds = 15,
): Promise<boolean> {
// SET key value NX EX ttl -> sets only if absent (NX), auto-expires (EX).
const result = await redis.set(`lock:driver:${driverId}`, tripId, "EX", ttlSeconds, "NX");
return result === "OK";
}
async function releaseDriver(redis: Redis, driverId: string, tripId: string): Promise<void> {
// Only release if WE hold it, so a late release cannot free someone else's lock.
const current = await redis.get(`lock:driver:${driverId}`);
if (current === tripId) {
await redis.del(`lock:driver:${driverId}`);
}
}
If tryReserveDriver returns false, that driver was already offered to someone else this round, so the matcher moves to the next candidate. The 15-second TTL (time to live - how long the lock survives before auto-expiring) covers the case where the driver never responds or a matcher crashes mid-offer: the lock simply evaporates and the driver becomes matchable again.
The trip as a state machine
Because a trip is a long-lived thing that many services touch, model it explicitly as a finite state machine - a fixed set of states with only certain allowed transitions between them. This makes illegal moves (like completing a trip that was never accepted) impossible by construction:
type TripState =
| "REQUESTED"
| "MATCHING"
| "ACCEPTED"
| "ARRIVING"
| "IN_TRIP"
| "COMPLETED"
| "PAID"
| "CANCELLED"
| "NO_DRIVERS";
const ALLOWED: Record<TripState, TripState[]> = {
REQUESTED: ["MATCHING"],
MATCHING: ["ACCEPTED", "NO_DRIVERS"],
ACCEPTED: ["ARRIVING", "CANCELLED"],
ARRIVING: ["IN_TRIP", "CANCELLED"],
IN_TRIP: ["COMPLETED"],
COMPLETED: ["PAID"],
PAID: [],
CANCELLED: [],
NO_DRIVERS: [],
};
function canTransition(from: TripState, to: TripState): boolean {
return ALLOWED[from].includes(to);
}
Step 4: Follow-up questions to expect
Once the core design stands, the interviewer probes its weak points. These are the ones that come up almost every time.
What if the Matching Service is a single point of failure? A single point of failure (SPOF) is one component whose death takes the whole system down. We never run one matcher; we run many stateless instances behind the load balancer, partitioned by city or region so each handles a slice of the map. If one dies, its cities are reassigned to healthy instances and only that region blips for a moment. The Redis and Cassandra tiers are likewise replicated, not single boxes.
How do you handle a surge - New Year's Eve in one city? Two answers. Operationally, surge pricing raises the price in a hot zone, which both rations demand and pulls more drivers in - it is a load-shedding mechanism disguised as a business feature. Technically, because we partition by city, a spike in one city only loads that city's matcher and Redis shard; we autoscale that region without touching the rest of the world.
A single driver's cell is a hotspot (an airport queue with thousands of drivers). Reading one huge Redis set gets slow. Drop to a finer H3 resolution in dense areas so each cell holds fewer drivers, and cap how many candidates the matcher pulls (you do not need all 2,000 airport drivers, just the best 20).
How do you scale to 10x the traffic? The location pipeline is already horizontally scalable - add Kafka partitions and Redis shards, both partitioned by geography using consistent hashing so adding a shard only moves a small slice of keys. The stateless services scale by adding instances; the backend scaling guide covers that path. The trips SQL database is the tightest constraint, so it is sharded by city from day one.
How do you guarantee a rider is charged exactly once? Payment is the one place we refuse to trade away consistency. The Payment Service uses idempotency keys: each charge attempt carries a unique id, and if a network timeout makes us retry, the same id returns the original result instead of charging twice. Payments are recorded in a double-entry ledger (every transaction writes two balancing rows), which is auditable and makes money impossible to silently lose. The external card gateway is called through this idempotent layer.
Is the driver's location on the rider's map perfectly consistent? No, and it must not need to be. Showing a driver's dot one ping (a few seconds) stale is completely fine, so live tracking is an eventually consistent read from Redis. We spend our strong-consistency budget only on trip assignment and payment. Knowing which data can be stale and which cannot is the whole game.
What stops abuse - fake ride requests, bots? Rate-limit requests per user and per device at the gateway so no one can flood the matcher; the rate limiter design is the standard building block. Layer fraud detection on top using the Cassandra location history (impossible travel, GPS spoofing patterns).
What you just built
Walk back through it and notice that every component was forced into existence by a number, not chosen for taste:
- 1.25M location writes/sec forced a dedicated location pipeline: Redis for the live index, Kafka to absorb the firehose, Cassandra for history.
- 200 MB of live data told us the whole live map fits in memory, so Redis is the right home.
- Millions of moving drivers, queried in milliseconds forced a geospatial index, and the uneven-neighbor flaw of square cells pushed us to hexagonal H3.
- The server must push offers and tracking forced WebSockets, not because writes are heavy but because communication is bidirectional.
- Money must be exact forced a small island of strong consistency - locks, idempotency keys, a ledger - inside an otherwise eventually-consistent, speed-first system.
The bottleneck at the next scale curve is the trips database and the per-city matcher partitions; the failure modes to watch are a hot cell, a region-wide surge, and a payment retry storm. If you can build this up from the numbers, defend the geospatial choice, and name where consistency is spent versus saved, you have answered "Design Uber" the way a strong candidate does. For a different real-time-push system that reuses the same connection layer, the WhatsApp walkthrough is the natural next read.

