Vibe Engines
YouTube
System Design

Design a Proximity Service

Step 1 / 9

Learn system design by building a proximity / nearby-search service like Yelp, "find restaurants near me", or driver matching step by step.

The numbers to beatprefix= proximity6 chars~1.2km cellcell + 8 neighborsthe search set

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.

Clientlat,lng + radius
New in this step: Client.

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?

Clientlat,lng + radius
The system as it stands at this step.

A SQL distance filter over a lat/lng column. Why doesn't it scale?

  1. 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.

  2. 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.

  3. 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?

Clientlat,lng + radiusProximity Svcnearby query
New in this step: Proximity Svc.

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?

Proximity SvcPlaces Store
New in this step: Places Store. · swipe to pan the diagram

How do you turn a 2D coordinate into a 1D key where nearby points cluster?

  1. 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.

  2. 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.

  3. 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?

Proximity SvcSpatial IndexPlaces Store
New in this step: Spatial Index. · swipe to pan the diagram

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?

ClientProximity SvcSpatial IndexPlaces Store
The system as it stands at this step. · swipe to pan the diagram

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?

Proximity Svcnearby queryHot-cell Cachedense areasSpatial Indexgeohash · quadtree
New in this step: Hot-cell Cache.

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?

Hot-cell Cachedense areasSpatial Indexgeo-shardedLocation Updatermoving objectsPlaces Storesource of truthRegion Shard Ageo partitionRegion Shard Bgeo partition
New in this step: Location Updater, Region Shard A, Region Shard B.

A driver's GPS updates every 4 seconds. What happens to their entry in the spatial index on each update?

  1. 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.

  2. 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.

  3. 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.

ClientProximity SvcHot-cell CacheSpatial IndexLocation UpdaterPlaces StoreRegion Shard ARegion Shard B
The system as it stands at this step. · swipe to pan the diagram

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.

ClientProximity SvcHot-cell CacheSpatial IndexLocation UpdaterPlaces StoreRegion Shard ARegion Shard B
The finished design, end to end. · swipe to pan the diagram

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.

Where an interviewer pokes next

Getting the boxes right is the easy half. These are the questions that separate a candidate who drew the diagram from one who has run the thing. Answer each one out loud before you open it.

  1. Why can't a normal database index answer "what's near me" efficiently?

    Because standard indexes (B-trees) are one-dimensional: they order rows along a single key and answer range queries on that one dimension. Nearness is inherently two-dimensional — it depends on latitude and longitude together — and indexing the two columns separately doesn't help, because a point that's close in 2D can be far apart in either single dimension's ordering. So a query like "within 2km of this point" can't prune effectively with ordinary indexes; the database ends up applying the distance formula to a huge fraction of the table, which is a full scan per query and collapses at millions of pins. Spatial indexes exist precisely to fix this by mapping 2D space onto a structure where "near in space" corresponds to "near in the index" — a geohash prefix, a quadtree leaf, or an S2 cell — so a nearby query becomes a cheap cell lookup that returns a small candidate set, and the exact distance math runs only on those few candidates.

  2. How does a geohash turn 2D coordinates into a locality-preserving 1D key?

    A geohash recursively bisects the world: it alternately splits the longitude range and the latitude range in half, emitting a bit each time for which half the point falls in, and interleaves those latitude and longitude bits into one binary string (usually rendered in base32). The key property is that this preserves spatial locality — two points that are close together on the map agree on more of their leading bisections, so they share a longer prefix, whereas a plain hash would scatter nearby points to distant values and a naive concatenation of lat and lng wouldn't keep 2D neighbors adjacent. Because proximity now corresponds to prefix similarity, a geohash effectively defines a grid of cells (a prefix of a given length is a cell of a known size — six characters is roughly a 1.2km cell), and "find nearby" becomes a prefix/range lookup on any ordinary sorted index or key-value store. To search around a point you look up its cell plus its eight neighboring cells (to handle the boundary problem) and then refine by exact distance.

  3. Geohash vs quadtree — when do you pick each?

    They both map 2D space to a fast lookup, but they differ in how they carve space and thus how they handle uneven density. A geohash (or any fixed grid) uses cells of a uniform size determined by prefix length, which is dead simple, works on any sorted KV store, and is easy to shard — but it's a poor fit when data is skewed, because a single fixed cell size is simultaneously too coarse in a dense downtown (one cell returns tens of thousands of candidates) and too fine in the countryside (mostly empty cells). A quadtree instead subdivides adaptively: it splits a region into four quadrants only where the number of objects exceeds a threshold, recursing so that dense areas end up with many small cells and sparse areas remain a single large cell, and every leaf holds a bounded number of objects. That adaptivity makes quadtrees (and Google's S2, which builds a hierarchy of cells over the sphere to also avoid geohash's rectangular distortion near the poles) much better for highly non-uniform data and for keeping per-cell candidate counts balanced. The tradeoff is complexity: geohash is trivial to implement and store, while quadtrees/S2 need tree maintenance. So you pick geohash for simplicity and uniform-ish data, and quadtree/S2 when density varies a lot or you need global spherical accuracy.

  4. What is the boundary problem and how do you avoid missing nearby objects?

    The boundary problem is that cell edges are arbitrary lines that have nothing to do with actual distance, so if your query point happens to sit near the edge of its cell, the closest object might be just across that edge in an adjacent cell — and if you only searched the point's own cell you'd miss it entirely, returning a worse "nearest" result than reality. The fix is to always search the point's cell plus its neighboring cells (the eight surrounding cells for a geohash grid, or the adjacent leaves for a quadtree), which guarantees that any object within roughly a cell's distance is included in the candidate set regardless of which side of a boundary it fell on. The subsequent exact-distance refinement step then simply discards the extra candidates that are actually outside the requested radius. To keep this efficient you size cells close to the typical search radius, so "cell plus neighbors" reliably covers the query without dragging in an excessive candidate set — too-large cells return too many candidates, too-small cells force you to gather too many neighbor cells.

  5. How do static places and moving objects lead to different designs?

    They violate opposite assumptions, so they get opposite treatments. Static places (restaurants, ATMs) essentially never move and are queried far more than they change, which makes the workload read-heavy and cache-friendly: you build the spatial index once, keep it very stable, and aggressively cache the nearby-lists of hot, dense cells (a downtown cell's answer is valid for hours), invalidating only on the rare new/closed place. Moving objects (drivers, users sharing live location) flip this: each object emits a position update every few seconds, so the index is write-heavy and constantly changing, which means you must cheaply reindex on every update (recompute the object's cell key and move its single entry — Redis's geospatial commands do exactly this in memory) and you cannot cache results aggressively because they go stale almost immediately. Both, however, geo-shard cleanly, because geography itself is the partition key: a proximity query only concerns a bounded area, so you partition the index by region and route each query to the shard(s) covering its search box, with cross-shard work needed only at region borders. So the same coarse-index-then-refine core is tuned two ways: heavy indexing plus caching for static data, and lightweight in-memory constant reindexing with little caching for moving data.

Check yourself — the answers, and why

Nine steps in, these are the calls you should be able to make cold. Pick one, then read why.

  1. A SQL distance filter over lat/lng doesn't scale because…

    You need a spatial index that maps 2D → a 1D lookup key to prune candidates first.

  2. A geohash makes nearby points…

    Interleaved lat/lng bits mean shared prefix = proximity, so "nearby" is a prefix/range lookup.

  3. Quadtrees (and S2) improve on a fixed geohash grid by…

    Adaptive cells keep each leaf's candidate count balanced under skewed density.

  4. You search the cell plus its neighbors to…

    Boundaries are arbitrary; neighbor cells + a distance refine catch the pin across the street.

  5. Moving objects (drivers) differ from static places in that they…

    Positions change every few seconds, so the index is write-heavy and results go stale fast.

How you’d open this design in an interview

Before any boxes: agree what it must do, pin the qualities that shape everything, then build — naming each trade-off as you make it. The walkthrough above is that exact order.

What it must do

Agree on these before drawing a single box.

  • Nearby query: given a lat,lng + radius, return the objects near that point — fast, over millions of pins.
  • Index 2D space: turn “near this point” into a cheap cell lookup instead of measuring distance to everything.
  • Coarse then fine: use the index for candidate cells, then compute exact distance on just those and rank.
  • Never miss a boundary pin: search the point’s cell plus its neighbors so the pin across the street counts.
  • Moving objects + scale: reindex drivers on each GPS update, and geo-shard the index by region.

The qualities that shape everything

Each one names the mechanism that buys it.

Answer nearby fast over millions of pins
A two-phase pipeline: the spatial index yields a small candidate set (coarse), then exact distance runs only on those to filter and rank (fine) — never distance-to-everything.
Map 2D nearness to a 1D lookup
A geohash interleaves latitude and longitude bits so a shared prefix means proximity — a cell is a prefix, and “nearby” becomes a prefix/range lookup on an ordinary index.
Balanced per-cell load under skew
Quadtree/S2 adaptive cells subdivide only where density is high, so each leaf holds a bounded number of objects rather than 10,000 in a downtown cell.
Never miss a pin across a boundary
Always search the point’s cell plus its neighboring cells, then let the exact-distance refine step keep only what’s truly within the radius.
Cheap reads on hot, dense areas
Cache the nearby-list for the most-queried cells; since static places change slowly, a downtown cell’s answer is valid for hours.
Handle motion and global scale
Reindex a moving object’s single entry per GPS update (in-memory), and geo-shard the index by region since queries are inherently local.

The trade-offs you say out loud

Senior signal isn’t the boxes — it’s naming what you gave up and why it was the right price.

A spatial index (2D → 1D key) over a SQL distance filter over lat/lng

A B-tree indexes one dimension, so a 2D “near” query can’t prune and degrades to a full-table distance scan — you need an index that narrows candidates first.

Geohash bit-interleaving over concatenating or hashing coordinates

Concatenation doesn’t preserve 2D locality and a hash deliberately destroys it; interleaving lat/lng bits makes nearby points share a prefix so proximity is a prefix match.

Adaptive cells (quadtree/S2) over a fixed geohash grid

One fixed cell size is simultaneously too coarse in a dense downtown and too fine in the countryside; adaptive subdivision keeps each cell’s candidate count bounded under skew.

Reindex a moving object’s single entry over rebuilding the index per GPS ping

A cell key is just a function of the coordinates, so an update recomputes one key and moves one entry — rebuilding the whole index for one driver’s move is catastrophically wasteful.

Geo-shard by region over one global index

A proximity query only touches its own area, so partitioning by geography scales without cross-shard fan-out except at region borders.

The answer, out loud

What a strong answer to “Design a Proximity Service” sounds like, first question to last trade-off. It is about 7 minutes of talking; the whiteboard and the interviewer fill the rest of the 45. Read it aloud once, then close the page and give it yourself.

  1. 0–3 min

    Pin down what near means

    Before I draw anything I want to nail down what near means here, because two different products hide behind that word. One is static places — restaurants, ATMs — where the pins basically never move and the traffic is overwhelmingly reads. The other is drivers, where the objects move constantly and the index is written to about as hard as it’s read. I’d also ask whether callers want everything within a radius or just the nearest few, because those turn into different queries.

  2. 3–8 min

    Why the database answer doesn’t work

    The first thing I want to establish is why the obvious storage answer fails, because it shapes everything after it. If I keep latitude and longitude as two columns and filter by distance, there’s no index that can prune the search — an ordinary index orders rows along a single key, and being near a point depends on both coordinates together. So the database ends up applying the distance formula to a huge share of the table on every request. At millions of pins that’s a full scan per query.

    Built in step 1: The distance-scan trap
  3. 8–13 min

    Get to a small candidate set

    So the whole design is about getting to a small candidate set cheaply. I’d put a proximity service in front of the places store and give it two phases. Coarse first: use a spatial index to grab the objects in the relevant area — approximate, but it takes me from millions to dozens. Then fine: compute the real distance to just those, drop whatever’s outside the radius, and rank. The property I care about is that the exact maths only ever runs on what the index already narrowed down, so its cost tracks the candidate count rather than the size of the dataset.

    Built in step 2: Query, then filter
  4. 13–20 min

    Geohash as the first index

    For the index itself I’d start with a geohash, because it’s the simplest thing that works. The trick is bit interleaving: you cut the world in half over and over, alternating longitude and latitude, writing down which side the point fell on each time. Because the cuts alternate, two points that are close on the map agree on more of the leading ones, so they share a longer prefix. A cell is then just a prefix — six characters is roughly a 1.2 km cell — and finding what’s nearby becomes a prefix or range lookup on an ordinary sorted index.

    Built in step 3: Geohash
  5. 20–26 min

    When a fixed grid stops working

    The weakness of a fixed grid is that the world isn’t evenly populated. One cell size that’s sensible in a suburb hands back ten thousand restaurants downtown and three out in the country. Where density is that skewed I’d move to a quadtree, which splits a region into four quadrants only where there are too many objects, so dense areas end up with many small cells, empty areas stay one big cell, and each leaf holds a bounded number. S2 does something comparable over a sphere, which also avoids the rectangular distortion a geohash grid carries.

    Built in step 4: Quadtrees & S2 cells
  6. 26–31 min

    The pin across the street

    The bug I’d raise before being asked is the boundary. Cell edges are arbitrary lines and distance doesn’t care about them, so when my point sits near the edge of its cell the closest restaurant can be just across it. Search only my own cell and I return a worse nearest than reality, and nothing looks broken — I still get results. So I always search the point’s cell plus its neighbors, the eight around it on a grid, and let the distance filter throw away the extras. Being slightly too broad is nearly free; being too narrow is a wrong answer nobody notices.

    Built in step 5: Don't miss the pin across the street
  7. 31–36 min

    Caching the busy cells

    This workload is read-heavy and the reads pile onto a few dense areas, so caching pays well here. Static places barely change — a restaurant opening is rare and none of them move — so a downtown cell’s answer stays good for hours. I’d cache the busiest cells and invalidate on the occasional update. I’d also be explicit that this is a property of the data and not of the design: it holds for places, and it stops holding the moment the objects I’m indexing are drivers.

    Built in step 6: Caching
  8. 36–42 min

    Drivers, and a planet of pins

    Which is the last piece. For moving objects the cell key is just a function of the current position, so a GPS update is cheap: recompute the key, move that one entry, touch nothing else. Rebuilding an index over millions of static places because one driver moved would be absurd, and batching updates up doesn’t work either — dispatch needs positions fresh to seconds or it offers rides to drivers who’ve already gone. I’d keep that index in memory since it’s written constantly, and not cache over it, because it goes stale in seconds. For size I’d geo-shard: a nearby query only concerns its own area, so region is a natural partition key.

    Built in step 7: Drivers, and a planet of pins
  9. 42–45 min

    Close on the trade-off

    So the trade-off runs through cell size the whole way down. Bigger cells mean fewer lookups and more candidates to refine; smaller cells mean less refining and more cells to gather; adapting them to density buys balance at the price of a structure you have to maintain. Everything else — the cache, the shards, the in-memory index for drivers — is that same coarse-then-refine core tuned to the data it holds. With more time I’d work through proper spherical distance and a projection-aware index so the maths stays honest at global scale, and how a search box that straddles two shards gets queried and merged.

What this teaches

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.

Key takeaways

  • 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.

Concepts covered

  • What is a proximity service?
  • The distance-scan trap
  • Query, then filter
  • Geohash
  • Quadtrees & S2 cells
  • Don't miss the pin across the street
  • Caching
  • Drivers, and a planet of pins
  • Density, precision & borders
built to answer "what's near me?" over a billion pins in milliseconds — make the calls, drop the index, run the gauntlet.
Finished this one? 0 / 65 System Designs done

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More System Designs