"Design Uber" comes down to one question that sounds easy and isn't: given a rider standing here, find the nearby available drivers — right now, among millions of them, while every one of those drivers is moving and pinging a new location every few seconds. Do it the obvious way — compute the distance from the rider to every driver — and you're doing millions of calculations per request. The entire interview is the data structure that turns "search everywhere" into "search the few blocks around the rider."
This is the condensed walkthrough; the full guide (estimates, API, data model, and the full production .NET 9 code) is on my site 👇
Full guide: https://prepstack.co.in/blog/design-uber-system-design
The design at a glance
| Concern | Decision |
|---|---|
| Find nearby drivers | Geospatial index (geohash / quadtree / H3) — search the rider's cell + neighbors |
| Driver locations | In-memory geo index (Redis GEO), sharded by region — not a DB table |
| Location ingestion | Persistent connections; update the index, don't write each ping to a DB |
| Matching | Rank nearby drivers by ETA, offer, and lock the driver to avoid double-dispatch |
| Surge | Per-cell demand ÷ supply ratio, updated continuously |
| Live trip | Stream driver location to the rider over a WebSocket |
Two forces: a write firehose and a proximity read
The write side is the monster:
Drivers: ~5M active, each pings location every ~4s
-> 5,000,000 / 4 ~ 1.25M location updates/sec
Ride requests: millions/day ~ hundreds/sec, each a nearby-driver search
You cannot write 1.25M rows/sec to a relational database, so driver location lives in an in-memory, sharded geo index updated in place; and the proximity search must be cell-bounded, never a scan over millions of drivers.
Drivers == location every ~4s ==> [ Location ingestion ] --> [ Geo index (Redis GEO), sharded by region ]
^
Rider request (pickup) --> [ Matching service ] -- search nearby cells
| rank by ETA - offer - LOCK driver
v
[ Ride store ] - [ Trip tracking (WebSocket) ] - [ Surge (per cell) ]
The hard parts
Geospatial indexing — the heart of it. The naive approach — distance from the rider to every driver — is O(N) per request and dies at millions of drivers. Instead, partition space into cells and bucket drivers by cell; a proximity search only examines the rider's cell and its neighbors.
- Geohash: interleave lat/lng bits into a string. Nearby points share a prefix, so a cell is a prefix and "nearby" is "matching prefix + the 8 neighbor cells." Simple, and what Redis GEO uses under the hood.
- Quadtree: recursively split space into four quadrants; dense areas (downtown) subdivide deeper — adaptive to density.
- S2 (Google) / H3 (Uber): hierarchical global cell systems; Uber's H3 uses hexagons (uniform neighbor distance, no corner problems).
A geohash cell + its 8 neighbors — a "nearby" search scans these 9 cells:
+-----+-----+-----+
| NW | N | NE |
+-----+-----+-----+
| W | * | E | * = the rider's cell
+-----+-----+-----+
| SW | S | SE |
+-----+-----+-----+
You examine ~9 cells' worth of drivers, never all N drivers.
The location write firehose. 1.25M updates/sec can't hit a database. Drivers stream location over persistent connections; the ingestion layer updates the driver's position in the in-memory geo index in place (one current position, not a history of rows). The index is sharded by region (geohash prefix), so each shard owns its slice of the map and the write load spreads. Hot regions get finer shards.
Matching — closest isn't the answer, best ETA is. A nearby search returns candidates; ranking by straight-line distance is wrong — a driver 200m away across a river is farther by road than one 500m away on the same street. Rank by ETA (road-network + traffic). Then offer the ride to the top driver and lock them (a short hold) so a second rider's search can't dispatch the same car; if they decline or time out, release and offer the next. Locking prevents double-dispatch.
Surge pricing. Per cell, compute demand ÷ supply — open requests vs available drivers. When demand outstrips supply, a surge multiplier rises for that cell, pricing the scarcity and nudging drivers toward it. A continuously recomputed, per-region number — and a feedback loop, so tune it or it oscillates.
Live trip tracking. Once matched, the driver's location streams to the rider in real time — the exact push-over-WebSocket problem from the chat design.
Scaling gotchas
- Ingestion firehose: in-memory geo index, sharded by region; persistent connections, not per-ping HTTP.
- Hot cells (downtown, a stadium at closing): split finer; the index adapts to density, or that cell melts.
- Proximity search: cell-bounded and served from memory — the whole point.
- Consistency: driver location is eventually consistent (seconds stale is fine); dispatch uses a lock where it matters.
- Durability split: rides persist relationally; location stays ephemeral in Redis (a lost ping just re-arrives in 4s).
- Privacy: streaming precise movement of millions is a serious responsibility — minimize retention.
I shipped this in production (Mattrx)
Mattrx isn't ride-hailing, but its geo-analytics run on exactly this proximity-search machinery. Conversions carry coordinates, and marketers ask location questions: "how many conversions within 5 km of this store?" (footfall attribution) and "render a heatmap of engagement." V1 answered those with a Haversine distance filter scanned across the CampaignEvents table (1.2B rows) — a full range scan per query, p95 ~1,800 ms that pinned the DB whenever a marketer dragged the map. We moved recent conversion events into per-tenant Redis GEO sets on ingestion, so a radius question becomes a geohash-bounded GEOSEARCH over a few cells:
| Metric | Before | After |
|---|---|---|
| "Conversions within 5 km of a store" p95 | ~1,800 ms (Haversine scan) |
~12 ms (Redis GEOSEARCH) |
| Rows examined per query | full CampaignEvents range scan |
a few geohash cells |
| Store-attribution DB load | heavy per query | offloaded to the Redis geo index |
| Geo-heatmap render | seconds, laggy panning | sub-second, smooth |
GeoAddAsync upserts a conversion's position by encoding it into a geohash score, and GeoSearchAsync with a GeoSearchCircle does the cell-bounded radius query natively — so a "near this store" question touches a handful of geohash cells in memory instead of Haversine-scanning a billion rows in Azure SQL, the whole difference between an 1,800 ms map drag and a 12 ms one. (Full .NET 9 geo index is in the post.)
The model to carry forward
Ride-hailing is a proximity-search problem wrapped in a location firehose. You never search all drivers — you bucket the map into cells, drop drivers into cells, and a nearby search touches only the cells around the rider. Keep that index in memory because the write rate is enormous, shard it by region so it scales and adapts to density, match on ETA rather than distance, and lock a driver during an offer so you never book one car twice.
Three habits it teaches: reach for a spatial index immediately ("distance to every driver" fails; "cell + neighbors" scales); keep the firehose out of your database (a current-location-per-driver index in memory beats a million writes/sec to disk); match on ETA, lock on dispatch (distance is a lie the map tells; the driver lock keeps matching correct).
The full guide has the estimates, API, data model, all the hard parts in depth, scaling, the complete production .NET 9 Redis GEO code, and the "when it's overkill" section:
https://prepstack.co.in/blog/design-uber-system-design
Originally published on PrepStack.
Top comments (0)