The problem
You’re asked to design a location-matching system — Uber’s rider-driver matching, DoorDash’s dispatch, Yelp’s “restaurants near me.” The interviewer says something like: “Design the service that, given a user’s location, finds the nearby drivers (or restaurants, or couriers) and matches them — in real time, as everyone moves.”
Sounds like a database query with a WHERE distance < 5km. It isn’t. The trap is that
proximity is not a value you can index on directly — latitude and longitude are two
independent dimensions, and a standard B-Tree indexes one column at a time. A range scan
on latitude returns a horizontal band spanning the whole planet; intersecting it with a
longitude band is a scatter-gather across millions of rows. The entire question is about
how you turn 2D proximity into a 1D key you can index, and how you keep that index fresh
when a million drivers move every few seconds.
Important
Key takeaway: A geospatial system is a write-heavy index problem wearing a read-query costume. Drivers report location every few seconds (millions of writes/sec); riders query occasionally. The core tension is maintaining a spatial index that’s cheap to update and cheap to range-query — the two goals pull in opposite directions.
Below is how I’d walk through this, start to finish.
1. Requirements
First 3–5 minutes. Questions I’d ask to pin down what we’re building:
Functional requirements
- R1. Drivers (suppliers) continuously report their current location.
- R2. A rider (consumer) requests a match from their location.
- R3. The system returns nearby available drivers within a radius, ordered by proximity (or ETA).
- R4. The system matches a rider to one driver and holds that driver until accept/decline.
- R5. “Nearby” queries also power a browse view (show all drivers on the map near me).
Scope
This design covers location ingestion and proximity matching. Explicitly out of scope:
- Routing / ETA computation (a separate system — a road-network graph problem, not a proximity one)
- Pricing / surge (peer system that consumes supply-demand density, doesn’t shape the index)
- Trip lifecycle after match (payments, navigation — a stateful workflow, its own question)
- Map rendering / tiles (client-side concern)
Non-functional requirements
- Scale. How many active drivers per city? How often do they report location? What’s the peak concurrent rider QPS? For a geospatial system, the update rate matters more than the query rate — that’s the reverse of most systems.
- Freshness. How stale can a driver’s location be before a match is wrong? For ride-hailing, a driver 4 seconds stale is fine; 60 seconds stale means the match is a street away.
- Match latency. A rider waiting on “finding your driver” tolerates ~1–2 seconds, not 10.
- Accuracy vs recall. Is a “nearby” result allowed to miss a driver just across a boundary? A browse view tolerates approximation; a match should not systematically miss the closest driver.
- Availability over consistency. A slightly stale or slightly incomplete driver list is acceptable; a matching service that’s down strands riders. AP over CP for the location index.
Say the interviewer confirms: 3M active drivers globally, ~600K in the busiest city at peak, each reporting location every 4 seconds, ~100K rider match requests/sec at peak, freshness tolerance ~5 seconds, match latency budget ~1 second, ride-hailing (single-match) semantics.
Note
Interview signal: Asking about the location-update rate before the query rate is what separates a strong requirements phase. Most candidates fixate on rider QPS; the driver-update firehose is 24× larger here (600K drivers ÷ 4 sec = 150K updates/sec in one city alone) and it’s what actually dictates the index choice. Interviewers notice when you find the real load first.
2. Capacity estimate
- Location updates (the firehose): 3M drivers ÷ 4 sec ≈ 750K updates/sec globally, ~150K/sec concentrated in the busiest city. This is the number that decides the architecture.
- Match queries: 100K/sec peak. Each query reads a small spatial neighborhood (tens to low-hundreds of drivers), not the whole city.
- Storage (hot state): 3M drivers × ~100 bytes (id, lat, lng, status, timestamp) ≈ 300 MB of current location. This fits in memory — the live index is small; it’s the update churn that’s expensive, not the size.
- Historical location (for analytics/replay) is a separate append-only firehose to cold storage; not on the matching path.
I’d say out loud: “The live index is tiny — 300 MB fits in RAM. The whole problem is that it’s being rewritten 750K times a second. So I’m optimizing for cheap updates and in-memory range queries, not for storage.”
3. High-level architecture
Before committing to an index, I’d sketch the topology. The system has three logical subsystems:
- Location ingestion — accepts the driver-location firehose, updates the live spatial index.
- Spatial index — the in-memory structure that answers “who’s near this point?” This is where the design’s character lives.
- Matching service — takes a rider request, queries the index for candidates, runs the match/hold protocol.
flowchart TD
Driver[Driver App] --> LB[Location Ingest Gateway]
Rider[Rider App] --> API[Match API]
LB --> LocSvc[Location Service]
LocSvc --> GeoIndex[(Geo Index · in-memory)]
API --> MatchSvc[Matching Service]
MatchSvc --> GeoIndex
MatchSvc --> MatchState[(Match State)]
classDef store fill:#eef2f1,stroke:#2c5f5d,stroke-width:2px,color:#1f2937;
class GeoIndex,MatchState store
The spatial index is the part that decides the system’s character.
4. Architecture evolution
A geospatial system evolves through recognizable levels. Naming them early frames why the design looks the way it does:
| Level | Architecture | Trigger for next level |
|---|---|---|
| 1. Naive SQL | SELECT ... WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? | Two-dimensional range scan doesn’t use a single index; slow past a few thousand rows |
| 2. Geohash column | Encode (lat,lng) → geohash string, index it, prefix-match | Fixed cell size causes uneven density; boundary misses |
| 3. In-memory grid + Geohash | Live index in Redis/in-process, sharded by region | Dense cells (downtown) become hot; sparse cells waste scans |
| 4. Adaptive index (quad-tree / S2 / H3) | Cell size adapts to density; hierarchical | (Production; refinements beyond interview scope) |
Each level preserves the previous as a fallback — the in-memory grid can rebuild from the durable location log; a quad-tree degrades to a fixed grid if rebalancing lags.
The design in this walkthrough is Level 3, with Level 4 named: an in-memory Geohash grid, sharded by geographic region, with a discussion of when to switch to an adaptive structure.
I’d say out loud: “I’m designing for Level 3 — an in-memory Geohash grid — and I’ll name exactly the density problem that pushes you to a quad-tree at Level 4. Landing at Level 3 with awareness of the density trade-off is the signal here.”
Note
Interview signal: Naming the evolution levels and placing your design within them shows you see the index as a spectrum of choices with triggers, not a single right answer. It also arms you for the inevitable “what if downtown is way denser than the suburbs?” follow-up.
Target architecture (Level 3)
This is the complete system we’re designing. Every section that follows explains one part of this diagram:
flowchart TD
Driver[Driver App] -->|location every 4s| Ingest[Location Ingest Gateway]
Ingest --> Kafka[(Location Log · Kafka)]
Kafka --> LocSvc[Location Service]
LocSvc --> GeoIndex[(Geo Index · Redis GEO / grid)]
Kafka --> ColdStore[(Historical Locations)]
Rider[Rider App] --> API[Match API]
API --> MatchSvc[Matching Service]
MatchSvc --> GeoIndex
MatchSvc --> Availability[(Driver Availability)]
MatchSvc --> MatchState[(Match State · holds)]
classDef store fill:#eef2f1,stroke:#2c5f5d,stroke-width:2px,color:#1f2937;
class Kafka,GeoIndex,ColdStore,Availability,MatchState store
5. Core design choice: the spatial index
Important
Key takeaway: The central question is how to reduce 2D proximity to a 1D indexable key. Every architectural decision — sharding, update cost, boundary handling — follows from which spatial-index scheme you pick.
This is the question. Three options:
(a) Geohash. Interleave the bits of latitude and longitude and Base32-encode them into a string. Nearby points share a common prefix, so a proximity query becomes a prefix match — a 1D range scan on a plain string index. Cell size is fixed by prefix length (e.g., 6 chars ≈ 1.2km × 0.6km).
(b) Quad-tree. Recursively subdivide space into four quadrants, splitting a cell only when it holds more than K points. Dense areas (downtown) get deep, fine subdivisions; sparse areas (rural) stay coarse. A query descends the tree to the relevant leaves.
(c) S2 / H3. Google’s S2 projects the sphere onto a cube and uses a Hilbert curve for 1D ordering; Uber’s H3 uses hexagonal cells. Both fix the equal-area and boundary problems that plague naive Geohash.
Why Geohash wins for the interview (with H3 named)
The justification is topic-specific: Geohash turns the hard part — 2D proximity — into the easy part — a 1D prefix scan that any string index or Redis sorted set supports natively. For an interview, it’s the scheme you can explain and defend in ninety seconds, and it’s what Redis GEO commands implement under the hood. I’d commit to Geohash for the core design and name H3 as the production upgrade specifically because hexagons have uniform neighbor distance (every neighbor is equidistant, unlike a square grid’s diagonal-vs-orthogonal problem) — which matters when “nearest” must be precise.
Tip
Why (junior/mid-level shortcut):
If you’re interviewing at the junior-to-mid level — or you just need a working baseline before going deeper — the simplest correct answer is to lean entirely on Redis’s built-in geospatial commands. GEOADD region_key lng lat driver_id on every location update, then GEOSEARCH region_key FROMLONLAT <lng> <lat> BYRADIUS 5 km ASC to get the nearby drivers sorted by distance. Redis stores each member’s position as a 52-bit geohash score internally and handles the boundary search and exact-distance ranking for you — no manual cell math, no 9-cell scan, no haversine pass. It’s roughly 40 lines of code and it’s genuinely how a small production system would start.
Say this out loud as your starting point, then earn the depth: “At small scale I’d just use Redis GEO commands and be done. The reason I wouldn’t stop there at scale is that a single Redis key doesn’t shard by region, and at 150K updates/sec in one city I need to control the index layout myself — which is where geohash cells, region sharding, and the boundary handling come back in.” Naming the managed shortcut and the reason it breaks is a stronger signal than jumping straight to hand-rolled cells.
Tradeoff matrix
| Dimension | Geohash | Quad-tree | S2 / H3 |
|---|---|---|---|
| Key type | 1D string prefix — trivially indexable | Tree pointer traversal | 1D cell id (Hilbert / hex) |
| Density adaptation | Fixed cell size — poor in skewed density | Excellent — splits on load | Good — multi-resolution cells |
| Update cost | O(1) — recompute hash, move to new cell | O(log n) — may trigger split/merge | O(1) — recompute cell id |
| Boundary problem | Severe — neighbors can share no prefix | Handled by tree structure | Handled (hex adjacency / cell neighbors) |
| Implementation complexity | Low — encode + prefix scan | Medium — rebalancing logic | High — library required |
| Interview explainability | High | Medium | Low (hand-wave the projection) |
The Geohash cost: fixed cell size and the boundary problem (below). The payoff: it’s simple, updates are O(1), and it maps directly onto infrastructure you already have (Redis, any sorted index). For a system whose real bottleneck is update throughput, the O(1) update wins — and the boundary problem has a bounded, well-known fix.
The boundary problem (and its fix)
Caution
Common mistake: Querying only the rider’s own Geohash cell. Two points a meter apart on opposite sides of a cell edge have completely different geohashes (no shared prefix), so a driver just across the street gets missed. This is the single most common geospatial bug, and interviewers probe for it directly.
The fix: query the rider’s cell plus its 8 neighbors (a 3×3 grid of cells). Geohash libraries expose a neighbors() function that computes the 8 adjacent hashes. You scan all 9 cells, gather candidates, then compute exact haversine distance to filter and sort. The cell is a coarse recall filter; the precise distance is the ranking pass. Choose the prefix length so one cell is comfortably larger than the typical search radius, so 3×3 always covers it.
Architecture decisions
| Decision | Chosen | Rejected | Rationale |
|---|---|---|---|
| Spatial index | Geohash grid | Raw lat/lng B-Tree | 2D range scan can’t use a single index; Geohash makes it a 1D prefix scan |
| Index location | In-memory (Redis GEO) | Disk-backed SQL | Live index is 300MB but rewritten 750K/sec; only memory sustains the update rate |
| Query strategy | 9-cell scan + exact haversine | Single-cell scan | Single cell misses drivers across boundaries; the 3×3 recall + exact-distance rank fixes it |
| Sharding | By geographic region (city) | By driver_id | Proximity queries are inherently local; region sharding keeps a query on one shard |
| Density handling | Fixed cell now, H3 later | Quad-tree from day one | Fixed grid is simpler and enough at Level 3; name the density trigger for the upgrade |
Core data structures
Before the paths, the structures that make this work:
| Structure | Key | Role |
|---|---|---|
| geo_index | geohash_prefix → set of driver_id | The live spatial index; answers “who’s in this cell?“ |
| driver_location | driver_id → (lat, lng, geohash, updated_at) | Current position, for O(1) update + cell move |
| driver_availability | driver_id → status | Filters the index to matchable drivers |
| match_hold | driver_id → (rider_id, expires_at) | Short-lived exclusive hold during the accept window |
Full schemas are in §9. What matters now: the index and locations are in-memory (Redis), sharded by region so every proximity query is single-shard.
6. Location ingestion path [R1]
Extending the target architecture with the write-path components:
flowchart TD
Driver[Driver App] -->|lat,lng every 4s| Ingest[Location Ingest Gateway]
Ingest --> Kafka[(Location Log · Kafka)]
Kafka --> LocSvc[Location Service]
LocSvc --> GeoIndex[(Geo Index · Redis)]
Kafka --> ColdStore[(Historical Locations)]
classDef store fill:#eef2f1,stroke:#2c5f5d,stroke-width:2px,color:#1f2937;
class Kafka,GeoIndex,ColdStore store
Step by step:
- Driver app sends
(driver_id, lat, lng, timestamp)every ~4 seconds to the ingest gateway. Connections are pooled/persistent to avoid per-update handshake cost. - Gateway appends the raw update to a Kafka log, partitioned by
region_id(so all of a city’s updates land on ordered partitions), and returns immediately. The driver never waits on the index write. - Location service consumes the log and updates the live index:
- Compute the new geohash from
(lat, lng). - Look up the driver’s previous geohash from
driver_location. - If the cell changed, remove
driver_idfrom the old cell’s set and add it to the new one. If unchanged, only refresh the timestamp — no index mutation.
- Compute the new geohash from
- Kafka also fans the same log to cold storage for analytics/replay — a separate consumer, no impact on the hot path.
Tip
Why a Kafka log in front of the index: Three reasons, each tied to the firehose specifically:
- Decouples the driver’s request latency from index-write contention. At 150K updates/sec into one city’s index, synchronous writes would couple every driver’s heartbeat to index lock contention.
- Absorbs bursts and smooths hot regions. Rush hour in one city spikes that region’s partition; the log buffers it while consumers scale.
- Enables index rebuild. The in-memory index is a materialized view of the log. If a Redis shard dies, replay the last few seconds of the log to rebuild current positions — nothing is lost.
Warning
Production reality: Most location updates don’t change the cell — a car moving 40m in 4 seconds usually stays in the same ~1km cell. Real systems exploit this: only ~5–10% of updates trigger an index mutation; the rest are timestamp refreshes. Recognizing that the effective index write rate is an order of magnitude below the raw update rate is a strong optimization to name out loud.
7. Matching path [R2, R3, R4]
Important
Key takeaway: Matching is two phases: a recall phase (cheap, approximate — scan the 9 cells) and a precision phase (exact haversine distance, then the hold protocol). Keeping them separate lets the recall stay index-cheap while the precision stays correct.
flowchart TD
Rider[Rider App] --> API[Match API]
API --> MatchSvc[Matching Service]
MatchSvc -->|9-cell scan| GeoIndex[(Geo Index · Redis)]
MatchSvc -->|filter available| Availability[(Driver Availability)]
MatchSvc -->|hold winner| MatchState[(Match State)]
classDef store fill:#eef2f1,stroke:#2c5f5d,stroke-width:2px,color:#1f2937;
class GeoIndex,Availability,MatchState store
Rider match, step by step:
- Rider requests a match with their
(lat, lng). Matching service computes the rider’s geohash. - Recall: compute the 9 cells (own + 8 neighbors) and scan their driver sets from the geo index. This yields the candidate set — tens to low-hundreds of drivers.
- Filter: drop drivers whose
statusisn’tavailable(join against availability) and whose location timestamp is older than the freshness SLO (5s) — a stale driver may have moved. - Rank: compute exact haversine distance (or call the ETA service if the budget allows) for each candidate; sort ascending.
- Expanding-radius fallback: if the 9-cell scan yields too few available drivers (sparse area), widen to a coarser prefix (a larger cell) and re-scan. Cap the expansion so a rider in the countryside doesn’t scan the whole state.
- Hold: attempt an atomic hold on the closest driver (see the protocol below). If the driver declines or the hold expires, offer the next-closest. This is the dispatch window.
- Return the matched driver to the rider.
The match/hold protocol [R4]
The correctness core: one driver must not be offered to two riders simultaneously. The hold is a short-lived exclusive lock.
The mechanism, concretely: when the matching service picks the closest driver, it does an atomic conditional write to match_state — SET driver_id → (rider_id, expires_at) IF NOT EXISTS, with a TTL of, say, 15 seconds (the accept window). If the write succeeds, this rider owns the offer; the service pushes the request to that driver. If the write fails (another rider grabbed the driver microseconds earlier), the service moves to the next-closest candidate. On driver accept, the hold converts to a confirmed match; on decline or TTL expiry, the hold auto-releases and the driver returns to the available pool. The state we store per hold is just { rider_id, expires_at } keyed by driver_id — cheap, self-expiring, and the TTL guarantees no driver is stranded if a matching service instance crashes mid-hold.
Caution
Common mistake:
Matching without an exclusive hold — just returning the nearest available driver. Under concurrency, two nearby riders both see the same idle driver and both dispatch to them. The SET IF NOT EXISTS + TTL hold is what makes single-match semantics correct; the TTL is what keeps a crashed matcher from locking a driver forever. This is the fencing-adjacent detail interviewers push on.
8. API design [R1, R2, R3]
POST /api/drivers/location [R1 — report location]
body: { driver_id, lat, lng, timestamp }
returns: { ok } // fire-and-forget; 202 Accepted
GET /api/drivers/nearby?lat=&lng=&radius= [R5 — browse view]
returns: { drivers: [{ driver_id, lat, lng, distance_m }], ... }
POST /api/match [R2, R3, R4 — request a match]
body: { rider_id, lat, lng }
returns: { match_id, driver: { driver_id, eta_s, distance_m } }
Non-obvious decision: the location endpoint returns 202 Accepted, not 200 OK. The write is asynchronous — it lands in Kafka, not the index — so the API must not imply the index reflects it yet. Pretending the write is synchronous invites a client to immediately query and expect its own move reflected. Worth saying out loud.
Caution
Common mistake:
Exposing raw geohash or cell ids in the nearby API. The cell scheme is an internal index detail — leaking it couples clients to the index and blocks a later switch to H3. The API speaks lat/lng/radius; the cell scheme stays hidden behind it.
The POST /api/match endpoint abstracts the entire recall → rank → hold pipeline. The client doesn’t know whether the index is Geohash or H3, or how many cells were scanned — so the backend can evolve from Level 3 to Level 4 without an API change.
9. Data model and storage
Requirements → components
| Requirement | Component | Role |
|---|---|---|
| R1 — Report location | Location Service + Geo Index | Ingest firehose, maintain live index |
| R2 — Request match | Matching Service | Orchestrate recall → rank → hold |
| R3 — Nearby, ranked | Geo Index + haversine rank | Recall candidates, sort by exact distance |
| R4 — Single match | Match State | Exclusive TTL hold per driver |
| R5 — Browse nearby | Geo Index | Read-only 9-cell (or radius) scan |
| Freshness (NFR) | Kafka + timestamp filter | Bound staleness; drop stale candidates |
| Availability (NFR) | In-memory index + log rebuild | Serve matches even after a shard loss |
Access frequency
| Query | Frequency | Drives |
|---|---|---|
| Update driver location | Very high (750K/sec) | In-memory index, O(1) cell move, region sharding |
| Read own previous cell | Very high (per update) | driver_location keyed by driver_id |
| 9-cell candidate scan | High (100K/sec) | Geo index keyed by geohash_prefix |
| Filter by availability | High | driver_availability keyed by driver_id |
| Hold a driver | High | match_state keyed by driver_id, atomic + TTL |
Storage technology choices
| Store | Technology | Serves | Why this, not alternatives |
|---|---|---|---|
| Geo index | Redis (GEO / sorted sets) | R3, R5 | Native geospatial commands; in-memory sustains 750K/sec updates a disk store can’t |
| Driver location | Redis hash | R1 | O(1) read of previous cell for the move-or-refresh decision |
| Location log | Kafka | R1 | Durable, replayable, region-partitioned; decouples ingest from index writes |
| Match state (holds) | Redis (SET NX + TTL) | R4 | Atomic conditional write + auto-expiry is exactly the hold semantics |
| Historical locations | Cassandra / object store | analytics | Append-only, time-partitioned firehose; off the hot path |
Access pattern matrix
| Req | Access pattern | Store | Key used | Caller |
|---|---|---|---|---|
| R1 | Append location update | Kafka | region_id (partition) | Ingest gateway |
| R1 | Move driver between cells | Geo index | geohash_prefix (add/remove member) | Location service |
| R1 | Read previous cell | driver_location | driver_id | Location service |
| R3/R5 | Scan 9 cells | Geo index | geohash_prefix × 9 | Matching service |
| R3 | Check availability | driver_availability | driver_id | Matching service |
| R4 | Acquire exclusive hold | match_state | driver_id (SET NX) | Matching service |
Schemas
Geo index [R3, R5]
geo_index (Redis, per region shard):
key: geohash_prefix # e.g. 6-char cell "9q8yyk"
value: SET of driver_id # members currently in this cell
# Redis GEO alternative: GEOADD region_key lng lat driver_id
# (Redis stores the geohash internally as a sorted-set score)
The prefix length is the tuning knob: shorter prefix = larger cell = more candidates per scan but fewer cells to touch. Pick it so one cell exceeds the typical search radius, keeping the 3×3 scan sufficient.
Driver location [R1]
driver_location (Redis hash):
key: driver_id
fields: lat, lng, geohash, updated_at
Read on every update to decide move vs refresh. Keeping the current geohash here avoids re-deriving the old cell.
Warning
Production reality: A fixed-prefix cell scheme means a dense downtown cell can hold thousands of drivers while a rural cell holds zero. The 9-cell scan of a dense cell returns a huge candidate set (expensive rank); the scan of a sparse region returns nothing (triggers expansion). This density skew is the exact trigger to move to H3 or a quad-tree, whose cells adapt to load. Naming this as the Level 4 trigger is a Staff+ signal.
Match state [R4]
match_state (Redis):
key: driver_id
value: { rider_id, expires_at }
op: SET driver_id ... NX EX 15 # atomic, 15s TTL
The TTL is load-bearing: it guarantees a driver is never held forever if the matching service instance that placed the hold crashes before resolving it.
Driver availability [R2]
driver_availability (Redis):
key: driver_id
value: status (available | on_trip | offline)
Filters the recall set to matchable drivers. Kept separate from location so a status flip doesn’t churn the geo index.
10. Failure modes
Important
Key takeaway: Every failure here threatens one of three properties: freshness (is the index current?), latency (is the match fast?), or correctness (is the match valid and exclusive?). Which property a failure threatens tells you what degradation is acceptable.
Six scenarios, grouped by what they threaten:
Freshness failures
Location consumers fall behind (Kafka lag)
Threatens: freshness.
The index reflects positions from N seconds ago. Matches point riders at where drivers were. Kafka absorbs the backlog with no data loss. Mitigation: scale consumers per region; monitor consumer lag as the primary freshness metric and drop candidates whose updated_at exceeds the freshness SLO so a stale driver is never matched. The feed is stale, not wrong.
A driver goes dark (app crash, tunnel, dead battery)
Threatens: freshness / correctness.
The driver stops reporting but stays in the index at their last cell. Mitigation: every index entry carries updated_at; the recall filter drops entries older than the SLO, and a sweeper evicts long-silent drivers. A driver in a tunnel reappears and re-adds themselves on the next heartbeat.
Latency failures
Hot region (rush hour downtown)
Threatens: latency.
One region’s shard takes disproportionate update and query load; dense cells return huge candidate sets. Mitigations, in order: shard hot regions more finely (a city subdivides into district shards); cap the candidate set and rank only the closest K; and this is the concrete moment to adopt an adaptive index (H3/quad-tree) whose cells split under density.
Redis geo shard loss
Threatens: latency (briefly), then recovered.
A shard dies; its region’s live index is gone. Mitigation: the index is a materialized view of the Kafka log — replay the last few seconds of that region’s partition to rebuild current positions. During rebuild, matches in that region degrade to a wider-radius scan against a neighboring shard or queue briefly. Nothing is permanently lost because the log is the source of truth.
Correctness failures
Double-match under concurrency
Threatens: correctness.
Two riders target the same idle driver simultaneously. The SET NX hold makes this safe: exactly one write wins, the loser falls through to the next candidate. Without the atomic hold, both would dispatch. This is the failure the hold protocol exists to prevent.
Boundary miss
Threatens: correctness (recall).
A closer driver sits just across a cell edge and is silently excluded. The 9-cell (own + neighbors) scan is the fix; a single-cell scan is the bug. Under the expanding-radius fallback, always expand symmetrically so the neighbor cells stay covered.
Note
Interview signal: Distinguishing the freshness failures (stale index) from the correctness failures (boundary miss, double-match) — and giving each a bounded, specific mitigation — is what separates Senior from Staff on this question. The boundary miss especially: naming it unprompted signals you’ve actually thought about geohash edges.
11. What I’d skip, and say I’m skipping
Time check — five minutes left. Things I’d explicitly defer:
- Road-network ETA. I’ve been ranking by straight-line haversine distance. Real dispatch ranks by ETA over the road graph — that’s a routing system (Dijkstra/contraction hierarchies over a map graph), a whole separate question. I’d say “assume an ETA service” and move on.
- Surge pricing. It consumes the supply-demand density the index already exposes, but it doesn’t shape the index. One sentence, not a section.
- Trip lifecycle after match. Navigation, payment, ratings — a stateful workflow (a saga), its own design.
- Batched dispatch windows. Advanced dispatch batches requests over a short window and solves a global assignment (minimize total wait) rather than greedy nearest-first. I’d name it as the optimization beyond greedy matching, not build it.
- Multi-region driver roaming across shard boundaries (a driver near a city edge). Handle with overlapping shard boundaries; name it, don’t detail it.
Saying “I’d skip this, and here’s why” shows I know the full surface and am scoping deliberately.
Interview flow summary
Architecture at a glance
INGESTION PATH MATCHING PATH
Driver location (4s) Rider match request
↓ ↓
Ingest Gateway (202) Matching Service
↓ ↓
Kafka (region-partitioned) Recall: scan 9 geohash cells
↓ ↓
Location Service Filter: available + fresh
↓ ↓
Move-or-refresh in Geo Index Rank: exact haversine distance
↓
Hold: SET NX + TTL → dispatch
The walkthrough order for your whiteboard
1. Requirements & scope — find the update firehose, not just rider QPS
2. Capacity estimate — 750K updates/sec; index fits in RAM
3. Architecture evolution — naive SQL → geohash → in-memory grid → adaptive
4. Spatial index choice — geohash; reduce 2D to 1D prefix; H3 later
5. Boundary problem — 9-cell scan + exact-distance rank
6. Ingestion path — Kafka log → move-or-refresh index
7. Matching path — recall → filter → rank → hold protocol
8. API — 202 on location; hide cell scheme
9. Data model — in-memory index, region-sharded schemas
10. Failure modes — freshness / latency / correctness
If the interviewer pushes deeper:
| Depth level | They’re probing for | Where to go |
|---|---|---|
| Level 1 | Why not WHERE lat/lng BETWEEN | 2D range scan can’t use one index (§ problem, §5) |
| Level 2 | Geohash mechanics | Bit-interleaving, prefix = proximity, cell size (§5) |
| Level 3 | The boundary problem | 9-cell scan + exact haversine rank (§5) |
| Level 4 | Density skew | Hot dense cells → H3 / quad-tree trigger (§9 warning, §10) |
| Level 5 | Correct single-match | SET NX + TTL hold, crash safety (§7) |
12. Wrap-up
One crisp sentence before the interviewer’s next question:
This design reduces 2D proximity to a 1D geohash prefix so the index is a simple in-memory range scan, absorbs the location firehose through a replayable log that keeps updates O(1), covers the geohash boundary problem with a 9-cell recall plus exact-distance ranking, and guarantees single-match correctness with an atomic TTL hold — while naming density skew as the trigger to graduate from a fixed grid to an adaptive H3 index.
What separates levels on this question
- SDE II reaches for a managed geospatial store —
Redis GEO(GEOADD+GEOSEARCH BYRADIUS) — which is a correct, sufficient answer at small scale and the right place to start. The gap: treating it as the final answer, querying a single cell if hand-rolling (the boundary bug), and matching without an exclusive hold. - SDE III names the boundary problem and fixes it with the 9-cell scan + exact haversine rank, treats location ingestion as a firehose fronted by a log (O(1) updates, move-or-refresh), and makes single-match correct with a TTL hold.
- Staff/Principal identifies the update rate (not query rate) as the real load, names density skew as the concrete trigger to move from a fixed grid to H3/quad-tree, treats the in-memory index as a materialized view rebuildable from the log, and separates recall (cheap/approximate) from precision (exact/ranked) as an explicit design seam.
The difference isn’t knowing what a geohash is. It’s naming the asymmetry — that the write firehose dwarfs the read load, that proximity must become a 1D key, and that cell boundaries and density skew are the two failure modes the whole design has to answer for.
Further reading
- Designing Data-Intensive Applications — Kleppmann. Chapter 3’s discussion of multi-dimensional indexes (R-trees, and why one-dimensional indexes fail for geospatial) is the primary source behind this question’s core tension.
- System Design Interview Vol. 2 — Alex Xu. The “Proximity Service” chapter is the textbook walkthrough for geohash vs quad-tree.
- Redis Geospatial — official docs for
GEOADD/GEOSEARCH; shows how the sorted-set-of-geohashes index works in practice. - H3: Uber’s Hexagonal Hierarchical Spatial Index — the operator’s own explanation of why hexagons beat squares for dispatch, and the density-adaptation this walkthrough defers to Level 4.
- S2 Geometry — Google’s sphere-to-cube + Hilbert-curve library; the primary source on equal-area cells and 1D ordering of 2D space.
- Geohash — the encoding itself: bit-interleaving, Base32, and the prefix-similarity property the whole design rests on.