- Geospatial search at a scale no SQL spatial index survives.
- A location firehose: hundreds of thousands of tiny writes per second.
- A two-sided match where both the driver and the rider move by the second, and double-booking a driver is a visible, angry-customer failure.
If you remember five things
The whole answer in one glance. Everything below is the defense of these five calls.
- Two planes, not one. Split a location plane (huge write volume, in-memory, eventually consistent) from a trip plane (modest volume, durable, strongly consistent). Forcing one model on both is the classic mistake here.
- Location is memory, not a database. Current driver positions are a tiny hot dataset (about 400 MB) indexed by geohash in Redis, sharded by city. 600k writes/sec never touches disk.
- Serialize the match on the driver. A compare-and-set flips one driver from available to offered. First writer wins, the loser moves to the next candidate. That one atomic flip is the entire no-double-booking guarantee.
- Lock fast, commit slow. Take the cheap recoverable lock in Redis, then commit the authoritative assignment transactionally in Postgres. Redis holds the lock, Postgres holds the truth.
- Push, do not poll. The driver's existing pings get forwarded to the one subscribed rider over a persistent socket. Fan-out is near 1:1, so push wins cleanly and polling is never on the table.
1Clarifying the requirements#
Pin down scope before drawing a single box. Matching and location branch hard on these answers, so spend real minutes here. Here is how that conversation usually goes.
Candidate: Are we designing just the ride-hailing core, or the whole thing including Eats, payments, driver payouts, and pricing?
Interviewer: Focus on the ride-hailing core. Request a ride, find nearby drivers, match, and track the trip live. Assume payments and pricing exist as separate services you can call.
Candidate: One city or global? And do riders pick pooled rides or just a private car?
Interviewer: Global, many cities, but each ride stays within one city. Just private car for now, no pooling.
Candidate: How fresh does driver location need to be on the rider's map? And how fast must matching feel?
Interviewer: Driver dots should move roughly every 4 seconds. A rider should get a match within about 10 seconds of requesting, when supply exists.
Candidate: One more. Is it a hard failure if a driver gets offered to two riders at once, or just annoying?
Interviewer: Hard failure. One driver, one assignment. Never two.
What those answers just told me:
- One ride stays in one city: no cross-city join, so city becomes my natural shard key everywhere.
- "Never two" is the thesis. It sets the consistency bar for the whole matching path. Everything else can be eventually consistent, assignment cannot.
- Private car, no pooling keeps the matching problem one-driver-one-trip, which is the only version I can fit in 45 minutes honestly.
Functional requirements
- A rider requests a ride from pickup A to drop B.
- The system finds available drivers near A and offers the ride to the best candidate.
- A driver accepts or declines; on accept, the two are matched into a trip.
- Both sides see each other's live location during pickup and the trip.
- The trip has a clean lifecycle: requested, matched, driver arriving, in progress, completed, cancelled.
- Drivers continuously publish their GPS while online.
Non-functional requirements
- Match latency: p99 under 10 seconds when supply exists.
- Location freshness: driver positions no older than about 5 seconds on the map.
- High availability for request and tracking paths. A dropped match attempt is worse than a slightly stale map.
- Consistency where it counts: one driver is never matched to two riders at the same time.
- Scale to tens of millions of trips a day across hundreds of cities.
- Location writes are the firehose; the whole thing lives or dies on how we handle them.
I am scoring this opening before you draw anything. I want to hear you separate what must be consistent (assignment) from what can be stale (the map) on your own. If you treat every path with the same consistency model, you have told me you have not run this system. Name the two planes early, then go spend your time on the hard one.
2Back-of-the-envelope estimation#
Get a feel for the load, because the numbers decide the architecture here more than usual. Find the one number that dominates, then build around it.
You are not computing storage to three decimals. You are surfacing one load-bearing number: location writes outweigh trip writes by nearly three orders of magnitude. That gap is what forces current location out of the database and into memory. Find it fast, then move on.
Say 100 million riders, 5 million drivers, and 20 million trips a day.
Trip request writes.
- 20M trips / 86,400 seconds is about 230 requests/sec average.
- Peak is maybe 5x on a Friday night, so call it ~1,200 requests/sec.
- That is small. A single Postgres primary handles 1,200 writes/sec in its sleep. Trip creation is not our problem.
Location writes (the firehose, and the number that drives the design).
- Assume 2 million drivers online at peak, each pinging GPS every 4 seconds: 2,000,000 / 4 = 500,000 updates/sec.
- Riders also stream location while on a trip, add maybe another 100,000/sec.
- So roughly 600,000 writes/sec of pure location churn, dwarfing trip creation (600,000 vs 1,200).
- This tells you immediately that current location cannot touch a relational database.
Location reads.
- Every active rider watching a map, and every match query scanning nearby drivers.
- A match scans a small cell set (a handful of reads); tracking is one subscription per active trip.
- Call it a few hundred thousand reads/sec, comparable to writes, so the read path has to be just as cheap.
Hot location state (the number that unlocks the design).
- 2M online drivers, each maybe 200 bytes of state (id, lat, lon, heading, status, timestamp).
- 2,000,000 x 200 bytes = 400 MB. Tiny. It fits in memory on a few machines with room to spare.
- That is the insight the 600k number points to: current location is a small, hot, in-memory dataset, not a database problem.
Bandwidth for location.
- 600k writes/sec x 200 bytes = 120 MB/sec inbound just for GPS.
- Real but manageable with good connection reuse: a fresh TLS handshake costs more than the payload it carries, so pings ride a persistent socket, never a new HTTPS request each time.
Trip history storage.
- 20M trips/day x 2 KB each = 40 GB/day, about 15 TB/year.
- Durable storage, partitioned by time and city. Boring, solved.
Takeaway. The design must absorb about 600k tiny writes/sec into memory, answer geo queries fast, and keep matching strongly consistent for the one thing that must be: a driver's assignment. The 400 MB working set is the reason all of that can live in RAM.
3Core APIs#
A few endpoints carry the system. I keep matching server-driven, so the rider never picks a driver. That choice is load-bearing for the no-double-booking guarantee, and I call it out explicitly in the room.
POST /v1/trips
Authorization: Bearer <rider_token>
{
"pickup": { "lat": 17.4239, "lon": 78.4738 },
"dropoff": { "lat": 17.4474, "lon": 78.3762 },
"product": "standard"
}
200 OK
{ "trip_id": "trp_8fa2", "status": "searching" }
GET /v1/trips/trp_8fa2
200 OK
{
"trip_id": "trp_8fa2",
"status": "driver_en_route",
"driver": { "id": "drv_19", "lat": 17.4251, "lon": 78.4702, "eta_sec": 180 }
}
Drivers push location over a persistent connection, not a fresh request each time:
// WebSocket frame: driver -> location service, every 4s
{ "type": "ping", "driver_id": "drv_19", "lat": 17.4251, "lon": 78.4702, "heading": 240, "ts": 1730600000 }
POST /v1/offers/ofr_77/accept
Authorization: Bearer <driver_token>
200 OK // first writer wins; late accepts get 409 Conflict
{ "trip_id": "trp_8fa2" }
Two request shapes on purpose, and I say why:
- Trip create and offer accept are plain HTTP. They are rare, and they want a clean request/response and a status code I can act on.
- Location pings are WebSocket frames. At 600k/sec I cannot afford a request envelope and a handshake per ping, so they ride one long-lived socket.
4High-level design#
The spine is a split between two planes, and the split falls straight out of the estimation.
- Location plane: huge write volume, in-memory, eventually consistent. 600k writes/sec wants its own machines.
- Trip plane: modest volume, durable, strongly consistent for matching. 1,200 trips/sec of authoritative state wants a database.
- Keeping these apart is the whole trick. Nothing good happens when you let GPS pings and authoritative trip state share a store.
How the pieces connect:
- Drivers hold a long-lived WebSocket to a fleet of location gateways.
- Those gateways dump pings into an in-memory geo-index sharded by city.
- The matching service reads that index to find candidates, then hands a chosen driver to the trip service, which owns authoritative trip state in a relational store.
- Riders track trips by subscribing through the same gateway layer.
Flow in words.
- A driver's app streams GPS to a Location Gateway, which updates the Geo Index.
- A rider requests through the API Gateway; the Trip Service records a pending trip and asks Matching for candidates.
- Matching queries the Geo Index, scores drivers with the ETA Service, and offers the ride.
- On accept, the Trip Service flips the trip to matched and the Push Notifier tells both apps.
- From then on, the driver's location updates fan out to the rider over the gateway.
5Data model#
Use the right store for each plane. The trip store is relational because the lifecycle has real invariants and I want transactions on assignment; current location is pure in-memory state because durability buys nothing there.
trips (Postgres, sharded by city, because trips never cross cities so there is no cross-shard join):
| Field | Type | Notes |
|---|---|---|
| trip_id | uuid | primary key |
| rider_id | uuid | indexed |
| driver_id | uuid | null until matched |
| city_id | int | shard key |
| status | enum | searching, matched, en_route, in_progress, completed, cancelled |
| pickup_lat | double | |
| pickup_lon | double | |
| dropoff_lat | double | |
| dropoff_lon | double | |
| requested_at | timestamptz | |
| matched_at | timestamptz | null until matched |
| completed_at | timestamptz | null until done |
| version | int | optimistic lock for state changes |
driver_state (in memory / Redis, not Postgres):
| Field | Type | Notes |
|---|---|---|
| driver_id | string | key |
| lat | double | last ping |
| lon | double | last ping |
| geohash | string | 6-char cell, updated per ping |
| status | enum | offline, available, offered, on_trip |
| last_ping | epoch | for staleness eviction |
| city_id | int | routing |
offers
| Field | Type | Notes |
|---|---|---|
| offer_id | uuid | primary key |
| trip_id | uuid | indexed |
| driver_id | uuid | |
| status | enum | pending, accepted, declined, expired |
| expires_at | timestamptz | ~15s window |
- The
driver_state.statusfield is the single lever for no-double-booking. When a driver is offered or on_trip, matching skips them. - One enum field on a hot in-memory record does the work a naive design would try to do with a database row lock, and at a fraction of the cost.
6Deep dives#
6.1 The geo-index: finding nearby drivers at 600k writes/sec#
A SQL spatial query is a non-starter at this write rate.
SELECT ... WHERE distance < ragainst a PostGIS index means re-balancing a B-tree on every one of 600,000 position changes per second.- No relational index survives that. So I keep current location in memory and index space itself.
My approach: geohashing.
- Encode each driver's lat/lon into a short string where nearby points share a prefix.
- A 6-character geohash covers roughly a 1.2km by 0.6km cell, a sensible neighborhood granularity for a city.
- On each ping, compute the geohash and store the driver id in a Redis set keyed by that cell, like
cell:tdr1w2 -> {drv_19, drv_44, ...}. - Updating is O(1): remove from the old cell set, add to the new one, refresh a sorted-set of last-seen timestamps for eviction.
- Three O(1) Redis ops per ping is how 600k writes/sec stays flat.
Querying for candidates reads a cell plus its 8 neighbors.
- Compute the pickup's geohash, then read that cell and the 8 around it.
- Why the neighbors? A rider near a cell edge may have the closest car one box over. Refusing to offer a car 50 meters away is a terrible experience.
- Nine small set reads give a candidate pool, usually a few dozen drivers.
- Then compute true haversine distance in the service to rank them, because a prefix match is approximate and does not mean "closest."
Sharding and recovery.
- Redis is sharded by city, so Hyderabad's cells never sit on the same node as Mumbai's.
- Within a city, shard further by a coarse geohash prefix if one city gets huge.
- Current location is pure in-memory state: if a node dies we lose a few seconds of positions, drivers re-ping, and we are whole again in 4 seconds.
- That recoverability is exactly why I keep it out of the durable store. There is nothing here worth paying for durability on.
One honest caveat on geohash.
- Cell sizes shift with latitude, and the prefix trick has edge cases near the poles and the antimeridian.
- For a city-scoped product that does not matter; no Uber city sits on the antimeridian.
- If I wanted uniform cells I would reach for H3 hexagons, which also make neighbor lookup cleaner (exactly six neighbors, not eight irregular ones).
- For this design I defend geohash as the simpler choice that meets the bar.
6.2 Matching without double-booking a driver#
This is the signature hard part, and it is where the "never two" answer from the clarifying round earns its keep.
- The race: two riders half a block apart can both see
drv_19as their best candidate at the same instant. Exactly one may get him.
Reveal the answer
The flow is server-driven and serializes on the driver.
- I do not let the rider pick. When matching selects
drv_19for trip A, it tries to move hisdriver_state.statusfromavailabletoofferedwith a compare-and-set. - In Redis that is a Lua script or a
WATCH/MULTIguard. The first writer wins; the second sees the status already taken and moves on to the next candidate. - Only the winner gets an offer sent. The CAS is the entire concurrency control. There is no separate lock service to go wrong.
drv_19's status is the serialization point: whoever wins it is the only one allowed to offer him. The durable assignment lands last, in Postgres, with an optimistic version bump.The offer has a 15-second TTL.
- If the driver ignores it, a background sweeper flips his status back to available and matching re-runs for trip A, with him excluded for a short cooldown so we do not immediately re-offer an unresponsive driver.
- If he accepts, the Trip Service commits the assignment inside a transaction with an optimistic version check, so a stale retry cannot overwrite a newer state.
- The
409 Conflicton a late accept falls straight out of this: by the time a slow driver taps accept, the offer already expired and someone else may hold the trip.
Why lock in Redis first, then commit in Postgres.
- Locking Postgres per candidate would melt under offer churn: every near-miss would take and release a row lock.
- The authoritative record still lands in Postgres, so a crash mid-match leaves a
searchingtrip that a reconciler can safely retry, never a double assignment. - Redis holds the fast, cheap, recoverable lock; Postgres holds the truth. If they disagree after a crash, Postgres wins and the Redis status rebuilds from it.
Reaching for a distributed lock service, or a Postgres SELECT ... FOR UPDATE per candidate, to prevent double-booking. It sounds safe and it quietly dies under offer churn. The cheap answer is one atomic status flip on a record you already hold in memory. If you cannot name where exactly you serialize, I have not seen you solve the core of this problem.
6.3 Live location fan-out to the rider#
Once matched, the rider wants to watch the car approach.
- The naive answer is polling
GET /trips/:idevery second, and it is wrong twice over. - It is a read-amplification bomb: millions of active trips each polling once a second.
- It still feels laggy, because you are averaging a one-second poll against a four-second ping.
Instead, forward the driver's existing pings.
- The driver's pings already flow to the Location Gateway. When a trip is active, the Trip Service tells the gateway "rider R cares about driver D."
- The gateway keeps a small in-memory subscription table and, on each of driver D's 4-second pings, pushes the position down R's WebSocket.
- No database in the hot path at all. One write from the driver fans out to exactly one interested rider (a couple if we later add a "share my trip" feature).
Why push wins here when it loses elsewhere.
- The trade is cost versus freshness, and the fan-out ratio decides it.
- Here fan-out is near 1:1, so pushing every ping is cheap and I would not even entertain polling.
- Compare a social feed, where one write fans out to millions of followers and pull or hybrid can beat push. Different ratio, different answer.
7Bottlenecks and trade-offs#
The location firehose is the first thing to break.
- 600k writes/sec will crush a single Redis. I shard the geo-index by city, and within hot cities by geohash prefix, so no node owns more than its slice.
- The gateways are stateless and scale horizontally behind a connection-aware load balancer.
- The real pressure is connection count: millions of live WebSockets. I size the gateway fleet on concurrent sockets per box (tens of thousands each), not request rate.
- 2M sockets at 50k per box is about 40 boxes, a fleet I can reason about.
Hot cells at surge time.
- Friday night near a stadium, one geohash cell holds thousands of available drivers and gets hammered by match queries. That is a hot key, for both reads and set size.
- Sub-shard busy cells, and cap how many candidates a match pulls: I do not need all 3,000 cars, I need the nearest 20.
- Read a bounded slice, not the whole set, so a dense cell costs the same as a sparse one on the read path.
Stale drivers.
- A driver loses signal mid-shift. His last position lingers and matching might offer him a ride he cannot take.
- Evict any driver whose
last_pingis older than about 15 seconds from the available pool, using the timestamp sorted-set, so he silently drops out until he reconnects. - 15 seconds is a bit over three missed 4-second pings: loose enough to tolerate one flaky ping, tight enough that we rarely offer a ghost.
Consistency versus availability (the trade I most want seen).
- The location plane is AP: lose a node, lose a few seconds of dots, nobody cares, it self-heals on the next ping.
- The assignment is CP: I would rather fail a match attempt and retry than ever hand one driver to two riders.
- Picking the same model for both would be wrong in both directions: overkill for location, dangerous for assignment.
The ETA service is a hidden dependency.
- Ranking by straight-line distance is cheap but wrong: the nearest car might be across a river with no bridge for 3km.
- Real ranking needs road-network ETA, a heavier call. I cache ETAs at coarse grid granularity and compute precise routes only for the final shortlist, not every driver in the pool.
- If ETA is down, fall back to haversine distance and accept worse matches rather than no matches. Degrading match quality beats refusing to match.
Thundering herd on reconnect.
- If a gateway crashes, tens of thousands of drivers reconnect at once and can take the next gateway down with them.
- Jitter reconnect backoff on the client, and keep the geo-index decoupled from the gateways so a gateway restart does not touch location state.
- The reconnecting drivers just re-ping into the same Redis cells they left.
Let the fan-out ratio and the consistency bar pick the architecture, not habit.
8Summary#
The decisions I would defend in the room:
- Two planes. A location plane (in-memory, AP) and a trip plane (durable, CP). They have opposite needs, and forcing one model on both is the classic mistake here.
- Location is memory, not a database. Current driver position is a small hot dataset (about 400 MB), indexed by geohash in Redis sharded by city, absorbing 600k writes/sec with three O(1) ops per ping.
- Candidate search reads a cell plus its 8 neighbors, then ranks the small pool by road ETA with a haversine fallback.
- Matching is server-driven and serializes on the driver with a compare-and-set in the fast store, then commits the assignment transactionally in Postgres. First writer wins; nobody gets double-booked.
- Live location is pushed, not polled: the driver's existing pings get forwarded to the one subscribed rider over a persistent connection.
- Out of scope by choice: surge pricing (a supply-and-demand feedback loop per cell), pooled rides (a much harder many-trips-per-driver match), and payout reconciliation. Naming them keeps me honest about what the hour covered.
"Two riders pick the same driver in the same millisecond. Who wins?"
"Why not run a spatial query against PostGIS to find nearby drivers?"
"A driver loses signal mid-shift. How do you stop offering him rides?"
"A Redis geo-index node dies. What do you lose?"
"The ETA service goes down during peak. Do you stop matching?"
What you can now explain in the room
- Why Uber splits into a location plane (AP, in-memory) and a trip plane (CP, durable), and why forcing one model on both fails.
- How to derive the load: 600k location writes/sec against 1,200 trip writes, and the 400 MB hot working set that follows.
- Why a SQL spatial index dies here, and how geohash cells plus 8-neighbor reads replace it with O(1) ops.
- Exactly where you serialize a match (a compare-and-set on the driver's status) and why not in Postgres.
- Why you lock in Redis but commit the assignment in Postgres, and what happens on a crash.
- Why live tracking is push over a socket, not database polling, and when that trade would flip.
For more on the geospatial and real-time patterns here, see our related company guides on designing location-based and real-time systems.
Get a prep plan built around your Design Uber interview
Tell us the role and your interview date. We will send back a dated, day by day plan. Free.