The whole design, in writing
Learn system design by building a proximity / nearby-search service like Yelp, "find restaurants near me", or driver matching step by step. An interactive guide covering why SQL distance queries can't scale, the 2D indexing problem, geohash prefix encoding, quadtrees and S2 cells, the boundary problem, caching hot cells, geo-sharding, moving objects, and the unhappy paths.
Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.
The big idea
What is a proximity service?
"Show restaurants within 2km of me." "Find the 5 nearest drivers." "Which friends are close?" Every one is the same question: given a point, find the objects near it — fast, over millions of pins, potentially while both the searcher and the objects are moving.
A proximity service answers nearest/nearby queries at scale. The whole problem is indexing 2D space so "near this point" becomes a cheap lookup instead of measuring the distance to everything. The tools: geohash, quadtrees, and grid/S2 cells — plus caching, geo-sharding, and handling objects that move.
What the new pieces do
- Clientclient
- A user (or a moving driver) asking "what's within N km of this point?" — restaurants, drivers, friends.
Step 1 · Why SQL can't
The distance-scan trap
The naive query: store lat, lng per place and run SELECT … WHERE distance(loc, me) < 2km. Why does this melt over millions of places?
A SQL distance filter over a lat/lng column. Why doesn't it scale?
The formula per row is cheap; the problem is you must compute it for EVERY row — a full-table scan — because a normal index can't answer a 2D range query. You need a spatial index that narrows candidates first.
You can index each column, but a B-tree indexes ONE dimension — indexing lat and lng separately still can't efficiently answer "near this 2D point." That's the core 2D-indexing problem.
Ordinary indexes are 1D; a bounding-box on lat AND lng still can't prune well, so the database ends up computing distance to a huge fraction of rows. You need a spatial index that maps 2D → a lookup key.
A normal B-tree indexes one dimension. Nearness is inherently 2D — indexing latitude and longitude separately can't prune it, so the query degrades to computing distance against a huge fraction of the table. At millions of pins that's a full scan per query. You need an index that maps 2D space to a lookup key.
Why this piece earns its place
The cheaper thing people reach for before a spatial index is a bounding box — WHERE lat BETWEEN … AND lng BETWEEN … — because it at least sounds like it uses the indexes you already have. It loses, concretely. The planner seeks the range on one of the two columns and gets every row in a band that wraps the whole planet at that latitude; the second predicate is then a filter applied row by row, not a second seek. You have swapped a full scan for a slightly smaller full scan, and the band grows as the table grows. A composite index on the pair doesn’t rescue it either — the second column only narrows anything inside a single value of the first, and a coordinate is effectively continuous, so no two rows share one. Say this out loud in an interview, because “just index both columns” is the answer everyone tries first, and being able to explain exactly why the index can’t prune is the content of this step.
Step 2 · A service over the pins
Query, then filter
Put a proximity service in front of the places store. Its job splits into two phases regardless of the index used. What are they?
The service works in two phases: (1) candidate selection — use a spatial index to grab the small set of objects in the relevant area (cheap, approximate), then (2) refine — compute the exact distance to just those candidates, filter to the true radius, and rank/sort. The index gets you from millions to dozens; exact math runs only on the dozens.
Why this piece earns its place
This two-phase shape quietly assumes the caller hands you a radius. Half the real queries don’t: “the 5 nearest drivers” names a count, not a distance, and inventing a radius to make the pipeline work picks your failure for you — too small and a thin suburb comes back short while drivers sit just outside the circle, too large and the refine phase sorts a huge candidate list to hand back five. The fix is an expanding ring: start at a radius that covers the common case, and if too few candidates survive the distance filter, widen and query again until enough do. Two details keep that safe. Cap the expansion, so a query over open ocean fails fast with an honest empty result instead of walking outward forever. And cap the other direction too — bound how many candidates refine will accept, because everything after the index is per-candidate work, and that is where the latency budget actually goes.
What the new pieces do
- Proximity Svcgateway
- Turns a point + radius into the right index cells, gathers candidates, filters by exact distance, and ranks.
Step 3 · Encode 2D into 1D
Geohash
You want a single indexable key such that nearby points share it — so a query becomes a prefix/range lookup on an ordinary index. How do you turn (lat, lng) into that?
How do you turn a 2D coordinate into a 1D key where nearby points cluster?
Geohash recursively bisects the world, interleaving latitude and longitude bits into a base32 string. The longer the shared prefix, the closer two points are — so "nearby" becomes a prefix match / range scan on a normal index. A cell = a prefix; query its cell and neighbors.
Simple concatenation doesn't preserve 2D locality — points close in space can be far apart in the concatenated value. Geohash's bit-interleaving is what makes nearby points share a prefix.
A regular hash deliberately destroys locality (nearby inputs → distant outputs), the opposite of what you need. Geohash preserves locality so proximity maps to prefix similarity.
Use a geohash: recursively bisect the globe, interleaving latitude and longitude bits into a short base32 string. Longer shared prefix ⇒ closer in space, so a cell is just a prefix, and "nearby" becomes a prefix/range lookup on an ordinary index. A 6-char geohash is a ~1.2km cell; to search, look up the point's cell plus its 8 neighbor cells and refine.
Why this piece earns its place
What this buys is that a cell is just a string, so an ordinary B-tree, a sorted key-value store or a Redis sorted set does spatial lookups with no spatial extension installed anywhere. What it costs is that the database no longer understands your data. The key is a derived column your application computes, and it stays correct only while every write path recomputes it — an admin tool that corrects a restaurant’s coordinates without touching the geohash leaves a pin that exists in the places store and is invisible to every nearby query, with nothing failing anywhere to tell you. Derive the key inside whatever function writes the row, never at the call sites, and keep a backfill ready for the day it drifts anyway. The second cost is fan-out: a prefix scan is one contiguous range, but the search set is a cell and its neighbors, which is a handful of separate ranges, so your service owns issuing them, merging and deduplicating — work a native spatial index would have hidden.
- prefix= proximity
- 6 chars~1.2km cell
- cell + 8 neighborsthe search set
What the new pieces do
- Places Storedata
- Durable store of every place/object with its coordinates and details. The index is derived from this.
Step 4 · Adapt to density
Quadtrees & S2 cells
Geohash cells are a fixed grid — but the world isn't uniform. A downtown cell holds 10,000 restaurants while a rural cell holds 3. Fixed cells are too coarse where it's dense and too fine where it's empty. How do you adapt to density?
Use a quadtree: recursively subdivide a region into 4 quadrants only where density is high, so dense areas get many small cells and sparse areas stay one big cell — each leaf holds a bounded number of objects. Search descends to the leaf covering your point (and neighbors). Google's S2 takes a related approach (a hierarchy of cells over a sphere, avoiding geohash's rectangular distortion). Choice depends on data: geohash is simplest; quadtree/S2 adapt to skew.
Why this piece earns its place
The knob this introduces is the split threshold — how many objects in a leaf trigger a subdivision — and both directions cost you. Set it high and you’ve reinvented the problem you came here to fix, with one downtown leaf handing refine a candidate list as long as the fixed grid did. Set it low and the tree goes deep: each query pays more traversal to reach a leaf, and because leaves are now small, covering the same search area means visiting many more of them, so the per-query cell count climbs even as the per-cell count falls. Pick it from the candidate count you are willing to refine, not from a sense of tidiness. The other thing to say is that adaptivity is a structure you have to maintain. A geohash key is a pure function of the coordinates and writers share nothing; a quadtree leaf that splits or merges is a mutation several writers can reach at once. That maintenance is exactly why the moving-object index three steps from now reaches for a flat grid instead.
- quadtreesubdivide dense areas
- ~equal loadper leaf cell
- S2spherical, low distortion
What the new pieces do
- Spatial Indexservice
- Maps 2D space to lookup keys (geohash prefixes / quadtree cells) so "nearby" is a cell lookup, not a scan.
Step 5 · The boundary problem
Don't miss the pin across the street
Your point sits near the edge of its cell, and the nearest restaurant is just across the boundary in the next cell. Search only your cell and you'd miss it. How do you avoid this classic bug?
Always search the point's cell plus its neighboring cells (for geohash, the 8 surrounding cells; for a quadtree, adjacent leaves), so a nearby object just over a boundary is included in the candidate set. Then the exact-distance refine step naturally keeps only what's truly within the radius. Pick a cell size close to the typical search radius so "cell + neighbors" reliably covers it without pulling in too much.
Why this piece earns its place
Cell plus neighbors is a guarantee only while the radius is no bigger than a cell — it covers everything within roughly one cell’s distance, so a caller asking for a radius several cells wide walks straight past the ring and gets a quietly incomplete answer, which is the same bug this step just fixed wearing a different hat. And because area grows with the square of the radius, a much wider search doesn’t cost a few more cells, it costs a grid of them. So cell size is a per-query decision, not a schema constant: derive the prefix length from the requested radius so the ring is always about the right size, and clamp or reject a radius you aren’t willing to serve. That has a storage consequence people forget — to choose a level at query time, the pin has to be findable at that level. Geohash gives you this free, since a shorter prefix of the same key is the coarser cell; with cells you can’t truncate, you store the pin at every level you intend to query.
- + neighborsnever miss a boundary pin
- cell ≈ radiussize the grid to the query
- refineexact distance trims extras
Step 6 · Popular places, hot cells
Caching
Nearby-search is overwhelmingly read-heavy, and reads concentrate on dense, popular areas — a downtown cell is queried constantly with near-identical results. Recomputing it every time is wasteful. How do you cut that load?
Cache the results for hot cells. Since a cell's nearby-list changes slowly (places don't move; a new restaurant is rare), cache the candidate list per popular cell and serve repeat queries from cache, invalidating on the occasional update. For largely-static data (restaurants), the index and caches are very stable; caching the densest, most-queried cells removes most of the compute for the areas that get the most traffic.
Why this piece earns its place
Be precise about what actually goes in the cache, because the obvious choice ruins the hit-rate. Cache the finished response and the key has to carry the radius, whatever filters the caller sent — open now, cuisine, rating — and the ordering, which is by distance and therefore different for every point inside the cell. That key space is wide enough that even the busiest cell is never asked quite the same question twice. Cache the cell’s candidate list instead, keyed on the cell alone: the coarse half of the pipeline is the part every caller shares, while refine and ranking stay outside the cache, where they’re cheap now that the list is short. The other half of the rule is a cap. Hot cells and dense cells are the same cells, so the entries you keep longest are also the longest entries, and memory concentrates exactly where load already did. Bound the stored list, and let a cell past that bound fall through to the index rather than blowing out the cache for everyone else.
- read-heavycache-friendly
- hot cellsdowntowns dominate
- slow-changinglong cache life
What the new pieces do
- Hot-cell Cachecache
- Caches the nearby-list for the most-queried cells (busy downtowns) so popular lookups skip recomputation.
Step 7 · Moving objects & scale
Drivers, and a planet of pins
Two escalations: the objects move (finding nearby drivers means their cell changes every few seconds), and the dataset is global — too big for one machine. How do you handle motion and scale?
A driver's GPS updates every 4 seconds. What happens to their entry in the spatial index on each update?
A grid/geohash key is just a function of the current coordinates, so an update is a small, cheap operation: compute the new key, remove the old entry, insert at the new one — no rebuild of anything else.
Rebuilding a whole index (potentially covering millions of static places too) for one driver's move would be catastrophically wasteful — only that one entry needs to change.
Batching trades correctness for throughput here: "nearby drivers" needs positions fresh to seconds, not minutes, or dispatch would offer rides to drivers who already moved away.
For moving objects, reindex on update: each GPS ping moves the object to its new cell (cheap for a grid/geohash — recompute the key and update one entry). Keep this hot, frequently-written index in memory (Redis geo commands do exactly this) and don't cache it aggressively (it changes constantly). For scale, geo-shard: partition the index/data by region so each shard owns an area, and route a query to the shard(s) covering its search box — a natural partition since queries are inherently local. Add read replicas for hot regions.
Why this piece earns its place
Geography partitions the query cleanly, which is what makes this work — but it does not partition the load. Traffic follows population, so a shard owning a metro takes vastly more reads and far more GPS writes than one owning farmland and ocean, and cutting the world into tidy map regions therefore guarantees a hot shard on day one. Define shards as ranges of the cell key instead, the way any range-partitioned store does, and put the cuts where traffic is thin rather than where the map looks neat: a boundary drawn through a dense city turns the most common query there into a two-shard query, while the same boundary in open country is almost never crossed. Ranges also give you the operation you’ll actually need — splitting one shard that has grown hot without redrawing every other. And size the moving-object shards by writes: a fleet pinging every few seconds is a sustained write rate set by how many objects you track, whether or not anyone is searching there.
- reindexon each GPS update
- in-memoryfor moving objects
- geo-shardpartition by region
What the new pieces do
- Location Updaterservice
- Reindexes objects that move (drivers): on each GPS update, move them to their new cell.
- Region Shard Adata
- A geographic partition of the index/data so a huge dataset spreads across machines by area.
- Region Shard Bdata
- Another region shard. Queries route to the shard(s) covering the search area.
Step 8 · The sharp edges
Density, precision & borders
Spatial systems have their own gremlins: extreme density hotspots (a stadium), the precision/recall tradeoff of cell size, cross-shard border queries, and the earth being a sphere, not a plane.
Handle density hotspots with adaptive cells (quadtree/S2) or dynamic cell splitting so no cell returns 10,000 candidates. Tune cell size to the query radius to balance too-many-candidates vs too-many-neighbor-cells. Handle border queries (search box spanning shards) by querying the few overlapping shards and merging. Use proper spherical distance (haversine) and a projection-aware index (S2) for global accuracy. And separate static from dynamic objects so each gets the right indexing/caching strategy.
Why this piece earns its place
Adaptive cells bound how many objects live in a cell. They do not bound how many objects are genuinely near you. Inside a stadium the honest answer to “everything within 2km” really is enormous, so what has to change is the contract, not the index: return the nearest k with a cursor for the next page, and treat k as the thing you defend. If the candidate set is still too big after the distance filter, shrink the radius and say so in the response rather than truncating a list and presenting it as the whole answer — a silently clipped result is indistinguishable from a sparse neighborhood. Pagination then brings its own requirement: the sort has to be total, so equal distances need a stable tiebreaker such as the object id, or one place appears on two consecutive pages while another never appears at all. This is also where to be honest that recall is a budget — a service that always returns everything within any radius over a billion pins is not one you can hold to a latency target.
You did it
You just designed a proximity service.
Everything you assembled, in order
- SQL distance filters degrade to full scans because a B-tree indexes one dimension, not 2D nearness.
- The pattern is coarse index (candidates) then fine filter (exact distance) — never distance-to-everything.
- Geohash interleaves lat/lng bits so a shared prefix means proximity — 2D becomes a 1D range lookup.
- Quadtrees and S2 cells adapt to density so each cell holds a bounded number of objects.
- Always search the cell plus its neighbors so a pin just across a boundary isn't missed.
- Cache hot, slow-changing cells (static places); read-heavy nearby-search is cache-friendly.
- Reindex moving objects on each update (in-memory); geo-shard by region since queries are local.