Design Uber
Design Uber the way a strong candidate builds it on the board: fare quotes, ride requests, matching and accept, one requirement at a time, then deep dives on 400k location writes a second in an in-memory geo index (Redis GEOSEARCH or H3 rings), adaptive pings, a Redis lock plus a conditional commit so a driver never gets two rides, a Kafka queue keyed by zone for stadium spikes, durable workflow timers, push instead of polling, and geo-sharding with Spanner.
On this page
🚕 What is a ride-hailing app like Uber?
It's a marketplace that pairs a rider who wants to go somewhere with a nearby driver who is free, then keeps both phones in sync until the trip ends. Think of an old taxi dispatcher with a radio and a city map covered in pins, except the pins move every few seconds for a million cars and the dispatcher has about a minute to find a match.
For this problem, we'll design the core ride flow: quoting a fare, requesting a ride, matching a nearby driver, and getting the driver to the pickup and the drop-off. We'll leave ratings, scheduled rides and payments internals out. Interviewers love Uber because it packs three classic problems into one prompt: a write-heavy stream of moving points, a "who's near me" search, and a booking that must never give one driver two rides.
The 30-second answer
"Riders get a fare quote, then request a ride against that quote. Driver apps send their location every few seconds to a location service that keeps an in-memory geo index (Redis geo or H3 cells), not the main database. A ride request goes onto a queue partitioned by pickup zone; a matcher reads nearby available drivers from the index, ranks them by ETA, and offers the ride to one driver at a time. A Redis lock with a 10 second TTL stops two matchers offering the same driver, and acceptance is one conditional transaction in a strongly consistent store. A durable workflow owns the 10 second offer timers, and updates reach the apps over a persistent push connection instead of polling."
What you’ll learn
- Build the design one functional requirement at a time: fare, request, match, accept
- Keep ~400k location writes/s in an in-memory geo index (Redis geo or H3 rings), not the ride database
- Cut ping load with adaptive intervals decided on the phone, with a server override
- Stop double offers with a Redis lock (SET NX PX 10000) plus one conditional transaction on accept
- Survive spikes and crashes with Kafka keyed by zone, commit-after-match and durable workflow timers
- Replace polling with push over a persistent stream, and geo-shard everything by region
Interview radar
What they probe: where you put hundreds of thousands of location writes a second, how you find nearby drivers without a scan, how you guarantee one driver gets one ride, and what happens at a stadium on a Saturday night.
Where candidates slip: writing every GPS ping to Postgres, WHERE distance < 2 km with no spatial index, a lock that lives inside one server's memory, and apps polling every second.
011. Understand the problem
Functional requirements
Here's the scope I'd agree on in the first few minutes. Users should be able to:
- Get a fare estimate. A rider enters a pickup and a destination and sees a price and an ETA.
- Request a ride from that estimate.
- Be matched with a nearby available driver. The system finds one, not the rider.
- Accept or decline as a driver, then navigate to the pickup and on to the drop-off.
Below the line
- Ratings for riders and drivers.
- Scheduled rides and airport queues.
- Ride categories like X, XL and Black (they change the filter, not the design).
- Pooled rides with several riders in one car.
- Payments internals (we call a payment provider when the trip ends).
Before reading on: what are the non-functional requirements? Think about speed, correctness, spikes and the location stream. Put a number on each.
Non-functional requirements
The system should:
- Match fast, or fail fast. A rider gets a driver within about 1 minute, or a clear "no drivers available". Nobody waits five minutes for a spinner.
- Be strongly consistent for matching. A driver is never offered or assigned two rides at the same time, and a ride never gets two drivers.
- Absorb spikes. When a concert ends, ~100k requests can arrive from a few blocks around one stadium in a few minutes. None of them should be dropped.
- Ingest locations with low latency. A driver's position should be searchable within a second or two of the ping, at hundreds of thousands of writes a second.
Below the line
- GDPR and location privacy (retention, deletion, consent).
- CI/CD and deployment.
- Monitoring, logging and alerting.
- Multi-region disaster recovery.
change the design and attach a number to each, then check in: "I'm going to prioritise consistency for matching and throughput for locations. Does that match what you care about?" That one sentence tells the interviewer where your deep dives will go.
022. The set up
Plan the approach
We'll build the design one functional requirement at a time, the simplest thing that works for each, with a picture at every step. Then the non-functional requirements drive the deep dives: the location stream, the double-booking problem, the stadium spike, timeouts, push and scale. Get something working on the board first, then make it fast and correct.
Core entities
Five entities carry the whole design:
- Rider: the person who requests rides. Profile and payment method on file.
- Driver: the person who drives. Car details and a status:
OFFLINE,AVAILABLE,OFFEREDorON_RIDE. - Fare: a priced quote for one pickup and destination, with an expiry. The rider requests a ride from a fare.
- Ride: one trip from request to drop-off. Holds the fare, the assigned driver and a status that moves
REQUESTED → ACCEPTED → PICKED_UP → COMPLETED(orCANCELLED). - Location: a driver's latest latitude, longitude and timestamp. Tiny, hot and rewritten every few seconds.
Five entities carry the whole design
API
One endpoint per functional requirement. Here's how I'd write them:
POST /fares { pickup, destination } -> Fare { fareId, price, eta, expiresAt }
POST /rides { fareId } -> Ride { rideId, status: "REQUESTED" }
POST /drivers/location { lat, lng } -> 204 No Content
PATCH /rides/:rideId { action: "accept" | "decline" } -> Ride | 409 Conflict
Why these verbs? POST /fares creates a quote resource, which is why it's a POST and not a GET: the server stores it and gives it an ID. POST /rides creates the ride from that ID. Location is a fire-and-forget write, so 204 keeps the response empty. PATCH changes one field of an existing ride, and it can return 409 because another driver, or a cancel, may have got there first.
JWT, not the body, so a driver can't accept on someone else's behalf. Timestamps are set by the server (a phone's clock can be minutes off). And the price is never sent by the client: the ride references a fareId the server created and stored, so nobody can request a ride for $1.
Back-of-envelope estimates
Only the numbers that change the design. All of these are assumptions to confirm, and the arithmetic is ours.
| Quantity | Assumption | Roughly |
|---|---|---|
| Drivers online at peak | ~10M registered, 1-2M online | 2M to be safe |
| Location writes | 2M drivers, one ping every 5 s | ~400k writes/s |
| Ping size | id, lat, lng, timestamp, status | ~100 bytes, so ~40 MB/s in |
| Live location state | 2M x ~100 B | ~200 MB, fits in memory |
| Ride requests | ~20M rides/day, peaks at 3x | ~230/s average, ~700/s peak (more at one stadium) |
Here's the catch these numbers show. Locations are a huge write rate on tiny state; rides are a low rate that must be exactly right. Those two want different storage, and most of this design follows from keeping them apart.
033. High-level design
Let's satisfy the functional requirements one at a time. Each picture is the previous one plus a few new boxes, drawn in colour; what we already had turns gray.
1) Riders should be able to get a fare estimate
The rider types a pickup and a destination and taps "See prices". We need three things:
- Rider app: the client. It talks to us over HTTPS.
- API gateway: one front door that checks the JWT, applies rate limits and routes each call to the right service.
- Ride service: owns fares and rides. For a quote, it needs a route.
- Maps API: a third-party routing provider (Google Maps, Mapbox or an in-house engine) that returns distance and ETA.
- Ride DB: a relational database. For now it holds one table,
Fare.
Step 1: the rider gets a fare estimate
- The rider app sends
POST /fareswith the pickup and destination to the API gateway. - The gateway authenticates the rider and routes the call to the Ride service.
- The Ride service asks the Maps API for the route, distance and ETA.
- It prices the trip (base + per km + per minute, times any surge), saves a Fare row with an
expiresAta few minutes out, and returns it to the rider.
Saving the fare is the important bit. It's what lets the next step trust the price.
2) Riders should be able to request a ride from that estimate
The rider taps "Confirm". We don't add a single service here: the Ride service already owns this. We only add a Ride table.
Step 2: the rider requests a ride from that fare
- The rider app sends
POST /rideswith{ fareId }. - The gateway routes it to the Ride service, with the rider ID taken from the JWT.
- The Ride service loads the Fare and checks it belongs to this rider and hasn't expired.
- It inserts a Ride row with
status = REQUESTEDand the fare's price, and returns therideId.
Use the fareId as an idempotency key as well: if the phone retries on a flaky network, the second POST /rides finds the existing ride instead of creating a second one.
3) The system should match the ride with a nearby available driver
Now the interesting part. To find a driver near the pickup, we need to know where drivers are. We add four things:
- Driver app: sends its location every few seconds while online.
- Location service: receives those pings and keeps the latest position per driver.
- Location store: where the latest positions live, queryable by "who's near this point?". Deep dive 1 decides what this really is.
- Matching service: given a ride, finds the best available driver near the pickup.
Step 3: match the ride with a nearby driver
- Every few seconds, each driver app sends
POST /drivers/locationthrough the gateway to the Location service. - The Location service upserts the driver's latest position into the location store.
- After the ride is created, the Ride service hands the
rideIdto the Matching service. - Matching asks the location store for available drivers within a few km of the pickup, ranks them (closest ETA first), and picks the best one.
4) Drivers should be able to accept or decline, then navigate
Matching has a driver in mind; now it has to ask her. We add one service:
- Notification service: sends push messages to phones through APNs (iOS) and FCM (Android).
Step 4: offer, accept, and keep both apps updated
- Matching sends an offer for the ride to the Notification service.
- The Notification service pushes it to the driver's phone through APNs or FCM.
- The driver taps Accept:
PATCH /rides/:id { action: "accept" }goes through the gateway to the Ride service. - The Ride service marks the ride
ACCEPTEDwith this driver, and the driverON_RIDE. - It tells the Notification service the ride has a driver.
- The rider gets a "driver on the way" push.
From here the driver app shows turn-by-turn directions with the maps SDK, first to the pickup and then to the destination, and keeps sending location pings. The driver taps "Picked up" and "Dropped off", which are two more PATCH calls that move the ride's status. If the driver declines, or says nothing, Matching tries the next driver; deep dive 5 makes that robust.
Any time the server has news the client didn't ask for (an offer, a driver's position, "your food is here"), you have a real-time update problem. The ladder is always the same: polling, then mobile push (APNs/FCM) for the app in the background, then a persistent connection (SSE, WebSocket or gRPC stream) for the app on screen. Deep dive 6 climbs it for Uber; the same moves show up in chat, food delivery and live scores.
This design works for a demo. Now let's break it with the non-functional requirements.
044. Deep dives
Each deep dive answers one non-functional requirement. Deep dives 1 and 2 are about location ingest, 3 is about consistency, 4 and 5 are about spikes and failures, 6 is about latency to the phone, and 7 is about scale. A senior candidate drives two or three of these unprompted and gets them right. A staff candidate also sees how they interact: the same region key shards the geo index, the queue and the ride store.
1) How do we handle frequent driver location updates and find nearby drivers fast?
Our estimate says ~400k location writes a second. Every matching request then asks "which available drivers are within 2 km of this point?". Both have to be fast, and they fight each other if they live in the wrong place.
Before reading on: you have 2M drivers, each with a latitude and longitude that changes every 5 s. Where do you store them, and what does one "nearby" query cost?
Naive approach: Write every ping to the main database
The Location service runs UPDATE drivers SET lat = ?, lng = ? WHERE id = ? for every ping, and Matching runs SELECT ... WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?. It's what you'd do for a few hundred drivers, and it reuses the database you already have.
Challenges: ~400k row updates a second is far beyond one Postgres primary, and each update churns indexes and the write-ahead log. Worse, a B-tree is one-dimensional. An index on (lat, lng) narrows latitude to a band, then scans every driver in that band across the whole city's width to filter longitude. And all of this shares a database with the ride bookings that must stay fast and correct.
Solid approach: Batch the writes and use a spatial index
Buffer pings in the Location service and write them in batches every second or two, into a database with a real 2D index: PostGIS with a GiST index (an R-tree), or a quadtree you maintain yourself. A quadtree splits the map into four squares, and splits any square with too many drivers again, so dense downtown gets small cells and the suburbs get big ones. "Nearby" becomes "walk down to my cell, check it and its neighbours".
Challenges: batching trades away freshness: a 2 s batch means Matching can see a driver who moved 30 m. And the index still lives on disk and is still rebuilt as drivers move; a quadtree in particular has to split and merge cells as density changes through the day. It's a fine answer at 50k writes a second; at 400k it's a lot of disk work for data that is stale after 5 seconds anyway.
Staff approach: An in-memory geo index, keyed by cell
Locations are tiny, hot and short-lived, so keep them in memory. Two ways to do it, both good:
Redis geo sets. Redis encodes each point as a 52-bit geohash and stores it in a sorted set, so updates and radius searches are both O(log N) plus the results:
GEOADD drivers:nyc -73.9857 40.7484 driver:42
GEOSEARCH drivers:nyc FROMLONLAT -73.9851 40.7488 BYRADIUS 2 km ASC COUNT 20
H3 cells. Convert each ping to an H3 hexagon ID (resolution 9 is about 0.1 km² per cell), keep cell -> set of driver IDs plus driver -> (cell, lat, lng, ts), and only move the driver between sets when the cell changes. A search reads the rider's cell, then ring 1, then ring 2, until there are enough candidates. That's what Uber built H3 for.
Either way, shard by region (one key or cluster per city), keep available drivers in their own set so Matching never filters out busy ones, and drop stale drivers: Redis can't expire one member of a set, so keep a second sorted set lastseen scored by timestamp and remove anyone silent for 30 s, or bucket keys by minute and let old buckets expire.
Challenges: memory isn't durable. If a Redis node dies, its drivers vanish from search until their next ping, about 5 s later. That's acceptable for data that's stale in 5 s anyway: run replicas, and treat the index as rebuildable, never as the source of truth for rides.
Where do 400k pings a second go?
Here's what the H3 version of the search looks like. The rider's cell first, then the six around it, then the twelve around those.
Find nearby drivers: look in the rider's cell, then the ring around it
H3, a hexagonal grid of the whole planet with 16 resolutions, where each finer cell is about one seventh the area of its parent (Uber). Hexagons were chosen because all six neighbours are the same distance from the centre; a square has edge neighbours and corner neighbours at two different distances. That makes "everything within k rings" a fair stand-in for a circle, and Uber uses the same cells to measure supply and demand for surge pricing.
GEOSEARCH, a quadtree, H3 rings), say where it lives (memory, sharded by region), and say what happens to a driver who stops pinging. The stale-driver answer is what separates a memorised answer from a designed one.
2) How do we cut the ping load without losing accuracy?
A fixed 5 s interval for every online driver is simple, but most of those pings say nothing new. A driver parked outside a café for 20 minutes sends 240 identical pings. Meanwhile, a driver 300 m from a pickup really does need to be precise.
Before reading on: which drivers need frequent updates, and which don't? Who should decide the interval: the phone or the server?
Naive approach: Ping faster for better accuracy
"Accuracy matters, so every driver pings every second." The map looks great in testing.
Challenges: 2M drivers at 1 s is 2M writes a second, five times our estimate, plus battery drain and mobile data the drivers pay for. Most of those writes are parked cars.
Solid approach: Drop the boring pings on the server
Keep a fixed interval on the phone, but have the Location service skip the index write when the driver moved less than ~20 m since the last stored point.
Challenges: it saves index writes but not the network calls, TLS work and battery: every ping still crosses the internet and hits the gateway. The expensive part is the request, not the write.
Staff approach: Adaptive intervals decided on the phone
The phone knows things the server doesn't: its speed, whether it's moving, its own status. So the app picks the interval:
- Offline: no pings at all.
- Available and parked: every ~30 s, a heartbeat that says "still here".
- Available and driving: every ~5 s.
- Heading to a pickup, under 1 km away: every ~2 s, because the rider is watching that car.
- On a trip: every ~4 s, enough to draw the route and compute the fare.
The server stays in charge by returning a suggested interval in each response (for example, faster in an area with a surge of requests). On the rider's side, the app animates the car smoothly between points, so 4 s updates still look continuous.
Challenges: a slower interval means a parked driver's position can be 30 s old when they pull away. Mitigate it by sending an immediate ping on any state change (moving after parked, going online, accepting) and by checking ts at match time. Old app versions that ignore the hint need a server-side throttle as a backstop.
The same 30 seconds, four drivers, four ping rates
3) How do we make sure a driver is never offered two rides at once?
Two riders 200 m apart request at the same moment. Two matcher instances each run the nearby search, and the same driver is the best candidate for both. Without coordination, she gets two offers, accepts one, and the other rider waits 10 seconds for nothing. Or worse, both accepts are recorded.
Before reading on: what stops two matcher instances from offering the same driver? Where does that check have to live, given there are dozens of matcher instances?
Naive approach: A lock inside the matching process
Keep a Set<driverId> of drivers with an open offer in the matcher's memory, guarded by a mutex. Check it before offering, remove the driver when she answers.
Challenges: it only works with one matcher instance. With 50 instances, each has its own set, so two of them happily offer the same driver. And if the instance restarts, every in-flight offer is forgotten.
Solid approach: A status column in the database, and an in-memory timer
Make the database the arbiter: a conditional update claims the driver, and only one caller can win.
UPDATE drivers SET status = 'OFFERED', offered_ride = :rideId
WHERE id = :driverId AND status = 'AVAILABLE'; -- 1 row: you own her. 0 rows: next driver.
The matcher then starts a 10 s timer in memory; on timeout or decline it sets the driver back to AVAILABLE.
Challenges: if the matcher crashes during those 10 s, nothing resets the driver. She sits in OFFERED and gets no rides until a sweeper job notices. It also puts a write per offer on the ride database, which is fine at ~700 rides/s but adds up with several offers per ride at peak.
Staff approach: A distributed lock with a TTL, plus a conditional commit
Claim the driver in Redis with a key that expires on its own after the offer window:
SET lock:driver:42 ride:9 NX PX 10000 -> OK (we own the offer for 10 s)
SET lock:driver:42 ride:7 NX PX 10000 -> nil (someone else does: try the next driver)
NX means only set it if it doesn't exist, and PX 10000 is the 10 s offer window. If the matcher dies, the key expires and the driver is offerable again with no sweeper. Release it with a small Lua script that deletes the key only if the value is still your ride ID, so a slow matcher can't delete someone else's lock. Matching also skips locked drivers when it reads candidates.
The lock is about experience: one offer per driver. The guarantee is the commit on accept, one transaction in the strongly consistent ride store:
BEGIN;
UPDATE rides SET status = 'ACCEPTED', driver_id = :d WHERE id = :r AND status = 'REQUESTED';
UPDATE drivers SET status = 'ON_RIDE', ride_id = :r WHERE id = :d AND status IN ('AVAILABLE', 'OFFERED');
-- either update touched 0 rows: ROLLBACK, return 409
COMMIT;
Challenges: a Redis lock isn't perfectly safe: a failover can lose a key that was just written, and a long GC pause can outlive the TTL. That's why it's not the only line of defence. The transaction catches anything the lock misses, and the worst case is a rare 409 that the driver app turns into "this ride was taken".
Two matchers, one driver: the lock decides
And here's the whole request-to-accept path that the lock sits in:
Request to accept: one driver, one ride, no double booking
- The rider requests; the Ride service creates the ride as
REQUESTED. - Matching reads available drivers from the rider's cells.
- It ranks them by ETA on the road network (a driver 300 m away across a river can be 10 minutes out).
- It locks the best driver for 10 s and sends the offer.
- The driver accepts.
- The Ride service runs the conditional transaction: ride still
REQUESTED, driver still free. Both change, or neither does.
one transactional store, or be ready to explain a Saga with compensating steps.
4) How do we make sure no ride request is dropped at peak, or when a matcher crashes?
The concert ends and 100k requests arrive in a few minutes from the cells around one stadium. In our current design, the Ride service calls Matching directly. Matching instances fill up, calls time out, and any request that was in a crashed instance's memory is simply gone. The rider sees a spinner, then an error.
Before reading on: how do you take a burst 100x bigger than normal without losing a request, and without making every matcher handle every region?
Naive approach: Match inline in the request
POST /rides calls Matching synchronously and holds the HTTP request open until a driver accepts, up to a minute.
Challenges: a minute-long request ties up a connection and a thread per rider, so 100k riders means 100k open calls through the gateway. A deploy or crash anywhere in the chain loses the request, and the client's retry creates confusion about which attempt is real.
Solid approach: An in-memory work queue in front of Matching
POST /rides returns right away with REQUESTED; the Ride service puts the ride ID on a queue that a pool of matcher workers drains.
Challenges: better, but if that queue is in a process's memory, a crash still drops whatever was queued. A single queue also mixes all regions together, so a stadium in one city slows matching for everyone else.
Staff approach: A durable log partitioned by zone, committed after the match
Write each ride request to Kafka, keyed by pickup zone (a coarse H3 cell or a city district), so every partition holds one area's requests in order. Matchers form a consumer group: each partition is read by exactly one matcher at a time, which also keeps that matcher's view of local drivers warm.
The rule that makes it lossless: commit the offset only after the ride is matched or definitively failed. If a matcher crashes mid-ride, Kafka rebalances its partitions to another matcher, which resumes from the last committed offset. Matching is idempotent per rideId, so a replayed request that was already matched is a no-op.
Autoscale matchers on consumer lag. A stadium spike shows up as lag in one zone's partitions, not as dropped calls, and every request carries its deadline: if it's still unmatched at 60 s, fail it and tell the rider.
Challenges: one partition is consumed by one matcher, so a single hot partition caps that area's throughput. Mitigate it with small zones (many partitions per city) so a stadium spreads over several, and keep enough partitions up front, because adding them later reshuffles keys.
Ride requests wait in a log, not in a matcher's memory
5) What happens if a driver doesn't respond to an offer?
Drivers miss offers all the time: phone in a cup holder, bad signal, just ignoring it. Each offer needs a 10 s timer, and when it fires we move to the next candidate. At ~700 rides a second, with a few offers per ride, that's thousands of live timers that must survive a matcher crash.
Before reading on: where does the 10 s timer live? What happens to it if the server holding it restarts at second 7?
Naive approach: A sleep or timer inside the matcher
offer(driver); wait up to 10 s for a reply; else next driver, with the timer in the matcher's memory.
Challenges: the timer dies with the process. After a deploy or a crash, rides sit in REQUESTED forever with nobody working on them.
Solid approach: A delay-queue message per offer
When you send an offer, also enqueue a message "check ride r9, driver 42" with a 10 s delay (SQS delay queues, a Redis sorted set scored by due time, or a delayed Kafka topic). When it's delivered, a worker checks: if the ride is still waiting on driver 42, release the lock and offer the next driver.
Challenges: it survives crashes, but the logic is now scattered across an offer handler, an accept handler and a timeout handler that race each other, with state stitched together in the database. Every new rule ("after 3 declines, widen the radius") means changing all three.
Staff approach: A durable workflow per ride
Write the offer loop as plain code and run it on a durable workflow engine like Cadence or Temporal:
for driver in rankedCandidates(ride):
if not lock(driver, ttl = 10s): continue
sendOffer(driver, ride)
reply = await signal("driver_reply", timeout = 10s) # durable timer
if reply == ACCEPT and commitRide(ride, driver): return MATCHED
return NO_DRIVER
The engine saves every step to a history store and keeps the timers itself. If the worker running this loop dies at second 7, another worker replays the history and the timer still fires at second 10. The accept from the driver app arrives as a signal to that ride's workflow, so offer, timeout and accept are handled in one place, in order.
Challenges: you operate another system (a Cadence or Temporal cluster with its own database), and workflow code has rules: it must be deterministic so replay gives the same decisions. For a ride, that's a good trade; this is exactly the kind of long-running, timer-heavy flow these engines exist for.
The offer loop as a durable workflow
One ride, two drivers, one timer
Cadence for exactly this kind of long-running, failure-proof business flow and open-sourced it (Cadence). Its original authors later forked it as Temporal. Saying "a durable workflow, like Cadence or Temporal" is a strong, concrete answer here.
6) How do we get updates to the rider and driver apps quickly?
During a trip, the rider's map shows the car moving. The driver's phone must ring with an offer within a second. Our design so far uses APNs and FCM for everything.
Before reading on: how does the rider's phone learn the driver moved 40 m? What happens with a million active trips if every phone asks once a second?
Naive approach: Polling
Each app asks "anything new?" every second or two.
Challenges: 2M open apps polling every 2 s is ~1M requests a second, and almost every answer is "no". It drains batteries and still adds up to 2 s of delay. At one point 80% of the requests to Uber's API gateway were polling calls (Uber).
Solid approach: Mobile push for everything
Use APNs and FCM for offers and status changes, as in the high-level design.
Challenges: they're best-effort: no ordering, no delivery guarantee, delays of seconds under load, and small payload limits (4 KB on APNs). Fine for "your driver has arrived" when the app is in the background; too slow and too lossy for a moving car or a 10 s offer.
Staff approach: A persistent connection through a push gateway
While the app is open, it holds one long-lived stream to a push gateway (SSE, WebSocket or a gRPC stream). A trigger service listens to ride and location events and decides when something is worth pushing and who cares; the gateway writes it down the right connection. A small registry maps userId -> gateway node so any service can reach any phone. Every message has a sequence number; on reconnect the app sends the last one it saw and the gateway resends the rest. When the app is backgrounded, fall back to APNs/FCM.
For the moving car, don't forward every ping. Push the driver's position to the rider every 2 to 4 s and animate in between.
Challenges: millions of open connections are stateful, so deploys must drain them slowly and clients must reconnect with jitter, or a gateway restart becomes a thundering herd. Rough math (ours): at ~100k connections per node, 2M phones need about 20 nodes plus headroom.
Polling asks "anything new?"; push answers only when there is
RAMEN (Realtime Asynchronous MEssaging Network) to replace polling. A service called Fireball decides when to push, the API layer builds the payload, and the gateway delivers it. The first version used Server-Sent Events with sequence numbers for at-least-once delivery; later Uber moved to gRPC bidirectional streaming so acks travel on the same stream (Uber).
7) How do we scale to many cities and keep rides consistent?
One Redis cluster, one Kafka cluster and one ride database work for a country. Globally, a rider in Mumbai shouldn't wait on a round trip to Virginia, and one region's outage shouldn't stop rides everywhere.
Before reading on: what's the natural shard key for Uber? What happens to a rider standing right on the line between two shards?
Naive approach: Scale up and add read replicas
A bigger ride database with read replicas, one big Redis cluster.
Challenges: replicas help reads, but our hard part is writes: locations and accepts. Replica lag also means a matcher could read a driver as free who was assigned 100 ms ago.
Solid approach: Shard the geo index and queue by region
Give each city or region its own location index, Kafka partitions and matchers. Requests are routed by the pickup's cell.
Challenges: the ride database is still one global bottleneck, and cross-region consistency for rides (a rider travelling, a driver near a border) is left to chance.
Staff approach: Geo-sharding end to end, with a strongly consistent ride store
Use one key, the region of the pickup cell, for everything on the hot path: a geo router sends the request to that region's stack, where the geo index, the queue partitions, the matchers and the workflows all live. Almost every ride starts and is matched inside one region, so almost nothing crosses. Only when a search ring crosses a shard line does Matching scatter-gather: query both neighbours, merge, rank.
For the rides themselves, use a database that gives transactions across rows and regions (Spanner, CockroachDB), so the accept transaction from deep dive 3 stays correct no matter which region a call lands in.
Challenges: border cases add latency, so keep shard lines away from dense areas (split along rivers and city limits, not through downtown). A globally consistent database costs write latency for cross-region commits; keep each ride's data homed in its region to keep most commits local.
Shard by geography: a city's rides stay in its region
Ringpop and store it in Cassandra, favouring availability, with Sagas for multi-entity changes. The rewrite moved rides and supply to Google Cloud Spanner for transactions across rows and tables with external consistency (Uber). The lesson to say out loud: for the booking itself, pay for strong consistency.
055. The final design
Here's everything on one board: the high-level design plus every deep dive.
Design Uber: the whole platform on one board
One ride, end to end:
- The rider requests a ride with a
fareId; the load balancer and API gateway route it to the Ride service. - The Ride service stores the ride as
REQUESTEDin the Spanner ride store (idempotent onfareId). - It writes the request to the Kafka ride queue, keyed by pickup zone.
- The matcher that owns that zone's partition consumes it.
- It reads available drivers from the nearby H3 cells in the Redis geo index.
- It ranks them by ETA with the ETA service.
- The offer workflow (Cadence) locks the best driver in Redis for 10 s.
- The Notification service pushes the offer down the driver's open stream through the push gateway (APNs/FCM if the app is in the background).
- The driver accepts through the API gateway; the Ride service runs the conditional transaction, and the rider is told the same way the driver was.
- All the while, the driver's GPS flows through the push gateway to the Location service, which moves the driver between cells in the geo index.
066. What interviewers look for by level
Mid-level
- A clean API with the fare quote as a server-side resource, and identity from the JWT.
- A working high-level design that covers all four requirements.
- Knowing that "nearby" needs a geospatial index (geohash, quadtree or H3), not a plain table scan.
Senior
- Separating the location stream from ride bookings, with numbers to justify it.
- An in-memory geo index with stale-driver cleanup.
- A lock with a TTL and a conditional transaction against double offers, and why you need both.
- A durable queue for requests with commit-after-match, and push instead of polling.
Staff+
- Treating the ping rate as a design lever (adaptive intervals with a server override).
- Durable workflows for offer timers, with a clear failure story at every step.
- Geo-sharding with one consistent key, scatter-gather only at borders, and a reasoned consistency choice per data type, backed by what Uber actually did.
077. Follow-up questions
What if we offer the ride to three drivers at once?
Matching gets faster in areas where drivers often ignore offers, because you don't wait out three 10 s timers in a row. The cost is drivers seeing offers they can't win. The design already handles it: lock three drivers, and the conditional transaction lets the first accept win while the others get a "ride taken" message. I'd use it only where acceptance rates are low.
What if a Redis geo shard goes down?
The index is rebuildable state. Promote the replica; any drivers it missed re-appear with their next ping, within 5 to 30 s. During the gap, matching in that region widens its search or retries for a few seconds. That's the point of keeping rides out of Redis.
What if the driver loses signal mid-trip?
The trip keeps going. Mark the location stale after ~30 s and show the rider the last known point with a "reconnecting" note. The app buffers pings and uploads them on reconnect, so the recorded route, and the fare computed from it, stay correct.
What if the rider cancels while an offer is out?
The cancel is a PATCH that flips the ride from REQUESTED to CANCELLED with the same conditional update, and it sends a signal to the ride's workflow. If the driver accepts a moment later, the accept transaction finds the ride isn't REQUESTED and returns 409. Whoever commits first wins, and there's no third outcome.
How would you add surge pricing?
Count open requests (demand) and available drivers (supply) per H3 cell every minute from the ride and location streams, and turn the ratio into a multiplier per cell. The Fare service reads it when quoting, and the multiplier is saved on the Fare, so the rider pays what they saw even if surge changes before they confirm.
088. How to say it in the interview
When I ask this question, I'm listening for three things: do you keep the location stream away from the bookings, can you find nearby drivers without a scan, and can you explain why a driver can't get two rides.
In the interview
"I'll quote fares as a stored resource, so a ride request only carries a fareId and the price can't be faked. Drivers ping roughly every 5 s, about 400k writes a second at 2M online drivers, so locations go to an in-memory geo index sharded by region, Redis GEOSEARCH or H3 cells, never the ride database, and drivers silent for 30 s drop out. The app adapts its ping rate: fast near a pickup, slow when parked. Ride requests go onto Kafka keyed by pickup zone, and I commit the offset only after a match, so a stadium spike becomes lag, not lost requests. A matcher reads nearby available drivers, ranks them by ETA, and locks the best one in Redis with SET NX and a 10 s TTL so no one else offers her. A durable workflow, like Cadence, owns the 10 s timer and moves to the next driver on timeout. Accept is one conditional transaction in a strongly consistent store like Spanner: ride still requested, driver still free, or 409. Updates go to the apps over a persistent stream with sequence numbers, falling back to APNs and FCM in the background, because polling was once 80% of Uber's gateway traffic."
On the whiteboard
- FRs: fare estimate, request ride, match driver, accept + navigate
- NFRs: match < 1 min or fail, no double offers, stadium spikes, fast location ingest
- API: POST /fares, POST /rides {fareId}, POST /drivers/location, PATCH /rides/:id (409)
- Locations: ~400k writes/s → Location svc → Redis geo / H3 cells per region, drop after 30 s
- Adaptive pings: 2 s near pickup, 5 s driving, 30 s parked
- No double offers: SET lock NX PX 10000 + conditional commit on accept
- Spikes: Kafka keyed by zone, commit after match, autoscale on lag
- Timeouts: durable workflow (Cadence / Temporal), 10 s timers survive crashes
- Push: persistent stream + seq numbers; APNs/FCM fallback; no polling
- Scale: geo-shard by region, scatter-gather at borders, rides in Spanner
Concepts used in this design
- Rate limitingSoon
- Load balancing
Go deeper in Articles
Optional long reads behind this design, for when you want the full story.
Further reading (7)
- H3: Uber's hexagonal hierarchical spatial index (Uber): Why hexagons (one neighbour distance), 16 resolutions with ~1/7 area per step, and surge measured per hexagon.
- Uber's real-time push platform (Uber): 80% of gateway calls were polls; RAMEN, Fireball deciding when to push, SSE with sequence numbers, then gRPC streaming.
- How Uber serves over a million concurrent users: fulfillment platform re-architecture (Uber): Ringpop and Cassandra favouring availability, Sagas, and the move to Spanner for transactions with external consistency.
- Designing Uber (High Scalability): A full interview-style walkthrough: location index, ETA on a road graph, GPS through Kafka, Redis in front of Cassandra.
- Uber Engineering reading list (Coders Guild): Links to more Uber engineering posts for further reading.
- Cadence: fault-oblivious stateful code (Uber, open source): The durable workflow engine Uber built for long-running flows with timers that survive crashes; Temporal is its fork.
- GEOSEARCH (Redis docs): Radius and box searches over a geo set, sorted by distance, with COUNT.