Design Uber — Geospatial Matching, Live Location, and Surge Pricing
A system design walkthrough of Uber: geospatial indexing (geohash/quadtree/H3), finding nearby drivers, rider matching, live location, and surge pricing.
- Author
- Randhir Jassal
- Published
- Reading time
- 13 min read
- Views
- 9 views
"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," plus the firehose of location updates feeding it.
A ride-hailing system matches riders to nearby drivers in real time, tracks the trip live, and prices it dynamically. Two forces dominate the design: a write firehose (millions of drivers streaming their location every few seconds) and a proximity read (find the closest available drivers to a point, fast). Both are solved by the same idea — a geospatial index that partitions the map into cells so you only ever look at the handful of cells near the rider. (New to the method? Start with the framework.)
TL;DR — 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 |
- The core is "find nearby among millions" without O(N) — a spatial index bucketed by cell.
- Geohash / quadtree / S2 / H3 all do the same job: encode a location into a cell so nearby points share a bucket; a search scans the target cell + neighbors, not the whole map.
- Driver locations are a write firehose (millions/sec) — keep them in an in-memory geo index, updated in place, never one DB row per ping.
- Shard the index by region (geohash prefix); a city-center cell can be split finer than a rural one.
- Match on ETA, not straight-line distance, and lock a driver while an offer is out so two riders can't book the same car.
- Surge is just supply vs demand per cell — raise price where open requests outnumber available drivers.
- Live trip tracking is the same push-over-WebSocket problem as chat.
The one mental shift: you never "search all drivers." You bucket the world into cells, drop each driver into a cell, and a proximity search only ever touches the rider's cell and its neighbors. That single move turns an O(N)-per-request distance computation into O(a few cells) — everything else (ingestion, matching, surge) is built around keeping that index fresh and sharded.
Step 1 — Requirements & scope
Functional:
- Drivers stream their location while available.
- A rider requests a ride at a pickup location; the system finds nearby available drivers.
- Match rider ↔ driver and dispatch.
- Track the trip live; compute ETA and a fare with surge.
Parked: payments (a system of its own), turn-by-turn navigation internals, ratings/pooling, driver onboarding.
Non-functional:
- Real-time — matching within seconds; location freshness within seconds.
- Low-latency proximity search at massive scale.
- Highly available — a failed match is a lost ride.
- Huge write throughput — millions of location updates per second.
Step 2 — Back-of-envelope estimates
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
Two takeaways: 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.
Step 3 — API
# Driver (over a persistent connection)
POST /drivers/location { driverId, lat, lng, status } ~every 4s
# Rider
POST /rides/request { pickupLat, pickupLng }
-> { rideId, matchedDriver, etaSeconds, priceEstimate }
# Live trip
WebSocket /rides/{rideId} driver location streamed to the rider
Driver pings ride a persistent connection (not a fresh HTTP request every 4 seconds); the rider's request triggers the nearby search + match.
Step 4 — Data model
# HOT, ephemeral — an in-memory geo index, NOT a table:
geo index cell -> { driverId, lat, lng, status } (Redis GEO / sharded by region)
# Durable, low-write:
rides rideId, riderId, driverId, pickup, dropoff, status, fare, createdAt
drivers driverId, profile, vehicle, currentStatus
The key insight is the split: driver location is ephemeral and enormous-write → in-memory geo index; ride state is durable and low-write → relational store. Don't put the firehose in your database.
Step 5 — High-level architecture
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
▼
[ Ride store ] · [ Trip tracking (WebSocket) ] · [ Surge (per cell) ]
The ingestion layer keeps the geo index fresh; the matching service does a cell-bounded search, ranks, and dispatches; ride state persists relationally; the trip tracker streams live location to the rider (the chat/real-time spine again).
Step 6 — Deep-dive: 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 latitude/longitude 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 than sparse ones — adaptive to density.
- S2 (Google) / H3 (Uber): hierarchical global cell systems; Uber's H3 uses hexagons (uniform neighbor distance, no corner problems). Same principle, better geometry.
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 (a driver has 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 naturally. Hot regions (a city center) get finer shards.
Matching — closest isn't the answer, best ETA is
A nearby search returns candidate drivers; ranking them 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, from a routing service). 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 the driver declines or times out, release and offer the next. Locking is what prevents double-dispatch.
Surge pricing
Per cell, compute demand ÷ supply — open ride requests versus available drivers. When demand outstrips supply, a surge multiplier rises for that cell, which both prices the scarcity and nudges more drivers toward it. It's a continuously recomputed, per-region number, not a global one.
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: a persistent connection, the driver's pings routed to the rider's socket.
Step 7 — Bottlenecks & scaling
- Ingestion firehose: in-memory geo index, sharded by region; persistent connections, not per-ping HTTP.
- Hot cells (downtown): split finer; the index adapts to density.
- Proximity search: cell-bounded and served from memory — the whole point.
- Consistency: driver location is eventually consistent (a few seconds stale is fine); dispatch uses a lock for correctness where it matters.
- Durability split: rides persist relationally; location stays ephemeral in Redis (a lost location just gets re-pinged in 4 seconds).
Step 8 — Trade-offs & wrap-up
- Geohash vs quadtree vs H3: prefix simplicity vs density-adaptive vs uniform hexagons. All partition space; pick for your geometry and tooling (Redis GEO gives you geohash for free).
- In-memory index vs DB spatial index: location is high-write and ephemeral → in-memory. A durable spatial DB is for slower, persistent geo queries.
- Distance vs ETA matching: distance is cheap and wrong; ETA is right and needs a routing service.
- Location freshness vs load: pinging more often is fresher but heavier — 4 seconds is a typical balance.
- Eventual location vs strong dispatch: stale-by-seconds positions are fine; the driver lock during an offer is not negotiable.
The design checklist
- Geospatial index (geohash/quadtree/H3) — search cell + neighbors, never O(N).
- Driver location in an in-memory geo index (Redis GEO), sharded by region.
- Persistent connections for location streaming; update in place.
- Rank by ETA, offer, and lock the driver to prevent double-dispatch.
- Surge as per-cell demand ÷ supply.
- Live trip streamed over WebSocket.
- Split storage: ephemeral location vs durable ride state.
The honest stuff: caveats and when it's overkill
- A small fleet doesn't need cells. With a few hundred drivers in one city, a single Redis GEO set (or even a bounding-box SQL query) is the whole system. Sharded H3 indexes are for millions of drivers.
- Straight-line "nearest" will send the wrong car. Distance ignores rivers, one-ways, and traffic. If you match on distance to save building an ETA service, expect angry riders watching a "close" driver take ten minutes.
- Don't persist every location ping. Millions of writes/sec to a database is a self-inflicted outage. Location is a current value, not a history — update in place, in memory.
- Double-dispatch is the classic race. Two riders search the same cell simultaneously and both get offered the same driver. Without a lock during the offer, you book one car twice.
- Hot cells break uniform grids. A stadium at closing time is a million requests in one cell. You need density-adaptive sharding (or H3 resolution changes), or that cell melts.
- Surge is a feedback loop, not a price tag. Set it too aggressively and it oscillates. It's a control system over supply and demand, tuned carefully.
- Location is privacy-sensitive. Streaming and storing precise movement of millions of people is a serious responsibility — minimize retention, and don't keep the firehose you don't need.
In production at 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 happened within 5 km of this store?" (footfall attribution) and "render a heatmap of engagement for this campaign." The first version answered those with a Haversine distance filter scanned across the CampaignEvents table (1.2B rows) — a full range scan per query, with p95 around 1,800 ms that pinned the database 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 instead of a Haversine scan over a billion rows — the same "search cells near the point, not the whole map" idea Uber uses to find drivers.
| 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 |
Radius and heatmap queries stopped hitting Azure SQL at all, and the map interactions that used to stutter now pan smoothly — because the query only ever looks at the cells around the point, not the whole billion-row table.
The architecture at Mattrx
React map dashboard ──▶ [ Azure App Service · .NET 9 geo service ]
│ GeoSearch (radius)
▼
[ Azure Cache for Redis · GEO sets per tenant ]
▲
Kafka ingestion ────────┘ GeoAdd(conversion lat/lng) as events land
Production implementation (Mattrx)
Here is the geo index behind Mattrx's store-attribution and heatmap queries on ASP.NET Core / .NET 9: the Kafka ingestion consumer adds each conversion's coordinates to a per-tenant Redis GEO set, and a radius query becomes a native GEOSEARCH — cell-bounded, served from memory, never a Haversine scan over CampaignEvents.
using StackExchange.Redis;
namespace Mattrx.Analytics.Geo;
public sealed record NearbyConversion(string EventId, double DistanceKm);
public sealed class ConversionGeoIndex(IConnectionMultiplexer redis)
{
private readonly IDatabase _db = redis.GetDatabase();
// Hash-tag on tenantId keeps a tenant's geo set on one Redis Cluster slot.
private static RedisKey GeoKey(Guid tenantId) => $"geo:{{{tenantId:N}}}:conversions";
// Called from the Kafka ingestion consumer as conversion events land.
// GEOADD encodes (lng, lat) into a geohash score under the hood — O(log n) upsert.
public Task IndexAsync(Guid tenantId, string eventId, double lat, double lng) =>
_db.GeoAddAsync(GeoKey(tenantId), longitude: lng, latitude: lat, member: eventId);
// "Conversions within radiusKm of (lat,lng)" — bounded to the geohash cells around
// the point, not a distance filter over the 1.2B-row CampaignEvents table.
public async Task<IReadOnlyList<NearbyConversion>> WithinRadiusAsync(
Guid tenantId, double lat, double lng, double radiusKm, int limit = 500)
{
GeoRadiusResult[] hits = await _db.GeoSearchAsync(
GeoKey(tenantId),
longitude: lng,
latitude: lat,
new GeoSearchCircle(radiusKm, GeoUnit.Kilometers),
count: limit,
demandClosest: true,
order: Order.Ascending,
options: GeoRadiusOptions.WithDistance);
return Array.ConvertAll(hits, h => new NearbyConversion(
EventId: (string)h.Member!,
DistanceKm: h.Distance ?? 0d));
}
// Footfall attribution: how many conversions fell within radiusKm of a store.
public async Task<int> CountNearStoreAsync(
Guid tenantId, double storeLat, double storeLng, double radiusKm)
{
var hits = await WithinRadiusAsync(tenantId, storeLat, storeLng, radiusKm, limit: 10_000);
return hits.Count;
}
}
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, which is the whole difference between an 1,800 ms map drag and a 12 ms one.
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. Nail "search the cells, not the map," and geohash/quadtree/H3, ingestion, matching, and surge all hang off that one idea.
Three habits this problem teaches:
- Reach for a spatial index immediately. "Distance to every driver" is the answer that fails; "cell + neighbors" is the one that scales.
- Keep the firehose out of your database. A current-location-per-driver index in memory beats a million writes per second to disk, every time.
- Match on ETA, lock on dispatch. Distance is a lie the map tells; the driver lock is what keeps matching correct.
Further reading
- How to Crack Any System Design Interview: A Repeatable Framework
- Design WhatsApp — Real-Time Messaging, Delivery Receipts & Presence
- We Replaced REST with Kafka and Cut Failures by 90%
Prepping for a system design round and want the Uber follow-ups drilled — or the next "Design X" walked through the same way? I'm always happy to help; reach me at randhir.jassal@gmail.com.
Get the next issue
A short, curated email with the newest posts and questions.