Designing a Proximity Service

A system design interview guide to building a Yelp-style "nearby places" service that, given a location, returns the businesses around you fast — by indexing the world spatially instead of scanning every row.

Student edition · start with the guarantee

Find nearby points by a conservative spatial covering, then refine and rank without missing boundary neighbors.

Correctness contract: Candidate covering contains every eligible point within the requested region. The distance/ranking model and approximation are explicit.

Basic HTTP, databases, and programming are assumed. Specialized terms are explained below. Named products motivate the problem; this is an educational design, not a claim about their current internals. Capacity numbers are illustrative decimal payload estimates, not benchmarks. Add indexes, protocol overhead, replicas, headroom, and failure capacity before provisioning.

A proximity service answers a deceptively simple question: "what is near me?" Type that into a maps or reviews app and you expect a list of restaurants, gas stations, or pharmacies within a few kilometers, sorted by distance, in well under a second. The naive way to compute this — store every place with its latitude and longitude and, on each request, look at all of them — falls over the moment you have millions of places and thousands of queries per second. The interesting part of the design is not the distance formula; it is the spatial index that lets you look at a few hundred candidate places instead of all of them. This guide builds up from why the obvious query does not scale, through the indexing options an interviewer expects you to compare, to the read-heavy serving architecture that ties it together.

Contents

  1. Requirements and Clarifying Questions
  2. Back-of-the-Envelope Estimation
  3. API and Data Model
  4. The Range Query Problem
  5. Geohash
  6. Quadtree
  7. Grid and Cell Systems
  8. The Search Algorithm
  9. Handling Uneven Density
  10. Precomputation and Caching
  11. Serving Architecture
  12. Failure Modes
  13. Tradeoffs and Alternatives
  14. A 45-Minute Interview Walkthrough
  15. Common Mistakes
  16. Summary
  17. Check Yourself
  18. Key Terms
  19. Further Reading
  20. Concepts to understand first
  21. Worked example
  22. Runnable Python model

Concepts to understand first

Each definition includes a concrete example and the condition under which the mechanism is useful. You can read this guide without opening another guide.

Geohash

A string encoding a rectangular latitude/longitude cell by interleaving longitude and latitude subdivision bits and representing them in base32. Longer shared prefixes identify a smaller shared cell.

Example. Two cafes 20 metres apart across a cell boundary may have unrelated prefixes. A radius search must cover all cells intersecting the query region, then filter candidates by distance.

Limit. Eight neighbors suffice only under specific cell-size/radius assumptions. Longitude wraps at the antimeridian and widths shrink toward the poles. Haversine is a spherical approximation, not ellipsoidal exactness.

Quadtree and adaptive spatial indexing

A region tree splits a rectangle into four children when it is crowded, allowing smaller leaves in dense areas. Range search visits only children whose bounds intersect the query.

Example. A city block splits more finely than an empty rural area. A 2 km query still visits many intersecting leaves instead of only its containing leaf.

Limit. Coincident points can force endless splitting unless there is a depth/min-cell bound and overflow handling. A query's candidate count is not bounded merely because each leaf has a capacity.

ETA and uncertainty

An estimated time of arrival combines location, route, historical travel time, and current conditions. A distance-only estimate is not a road-network travel-time estimate; predictions should include uncertainty and freshness.

Example. A nearby driver across a river may take longer than a farther driver already on the correct road. Rank by road ETA after cheap geographic candidate filtering.

Limit. GPS timestamps can be stale or wrong. State the location age, update version, cancellation policy, and fallback when routing or traffic data is unavailable.

Inverted indexes and columnar storage

An inverted index maps searchable terms to document postings. A columnar file groups values by column so analytical scans can read only selected fields. They optimize different access patterns.

Example. 'ERROR' → document IDs {2,7}; 'service=api' → {1,2,7}. Intersection returns {2,7}. A monthly count may instead scan only time, service, and status columns.

Limit. Tokenization affects meaning, indexes have storage/write cost, and incomplete shard responses must be marked. Search refresh is visibility, not the same as durable commit.

Cache-aside and invalidation

A read checks a cache, reads the authoritative store on a miss, then fills the cache. A write commits to the store and invalidates or refreshes the cached value. TTL means time to live, not a freshness proof.

Example. A reader fetches version 1, a writer commits version 2 and invalidates, then the delayed reader caches version 1. DB-first invalidation alone does not prevent this refill race.

Limit. Use invalidation-aware fill leases or version fences for stronger behavior; a TTL only bounds one entry's residence under additional assumptions. Authorization and revocation need an explicit stale-data policy.

1. Requirements and Clarifying Questions

Before drawing boxes, spend the first few minutes agreeing on what the system must do. The questions below are the ones that change the design; the answers in brackets are a sensible default if the interviewer lets you choose.

FunctionalNon-functional
Given a location and radius, return nearby places, nearest first.Low latency: p99 under about 200 ms for a search.
View one place's details (name, hours, photos, rating).High availability: searches keep working if a node dies.
Business owners add, update or delete a place.Read-heavy scale: tens of thousands of searches per second at peak.
Pagination of results.Privacy: user locations are not stored longer than needed.
Intuition

Think of a paper map cut into squares, with a folder for each square. To find coffee near you, open your square's folder and the 8 folders around it. You never look at the other thousands of folders. Every design in this guide is a smarter way to cut the map and label the folders.

2. Back-of-the-Envelope Estimation

200 million place entries at a nominal 16 bytes of ID+cell payload total 3.2 GB, but this excludes coordinates, index/tree/hash overhead, allocation, replicas, and snapshots. One-server fit is a hypothesis to benchmark, not a conclusion from payload alone. Place details at 1 KB each are 200 GB payload.

100M users×5 searches/day averages 5787 QPS; a stated 3× peak gives 17,361 QPS. Read replication may suffice if measured index memory and CPU fit; shard by geography or another scheme only when actual capacity/skew/availability requires it. Track update lag and stale closed/moved places.

3. API and Data Model

Keep the public API small. One search endpoint and a standard CRUD set for business owners is enough.

GET  /v1/search/nearby?lat=37.776&lng=-122.416&radius=500&limit=20&cursor=...
  -> 200 { "places": [ {"id": 42, "name": "Blue Cafe", "distance_m": 180, ...} ],
           "next_cursor": "..." }

GET    /v1/places/{id}          # full details
POST   /v1/places               # owner creates (authenticated)
PUT    /v1/places/{id}          # owner updates
DELETE /v1/places/{id}

Store two things separately: the place details, which are large and rarely change, and the small geo index, which is what every search touches.

-- source of truth for details
CREATE TABLE places (
  id          BIGINT PRIMARY KEY,
  name        TEXT, category TEXT, address TEXT,
  lat         DOUBLE, lng DOUBLE,
  rating      REAL,  updated_at TIMESTAMP
);

-- what the search reads: one row per place, keyed by cell
CREATE TABLE geo_index (
  geohash     CHAR(8),      -- store the long form; query any prefix
  place_id    BIGINT,
  PRIMARY KEY (geohash, place_id)
);
Why store a long geohash?

If you store the 8-character geohash, you can answer 4-, 5- and 6-character prefix queries from the same column with a range scan (geohash LIKE 'c22yz%', which a B-tree serves as one contiguous range). You avoid keeping one column per precision.

4. The Range Query Problem

Start with the design everyone reaches for first: a table of places, each with a latitude and a longitude, and a query that asks for everything inside a bounding box around the user. In SQL it looks like a simple range filter on two columns.

SELECT id, name, lat, lng FROM places
WHERE lat  BETWEEN :user_lat - d AND :user_lat + d
  AND lng  BETWEEN :user_lng - d AND :user_lng + d;

This works on a small dataset and is completely correct. The trouble is performance at scale. Latitude and longitude are two independent dimensions, and a standard B-tree index can only order rows along one of them at a time. An index on lat narrows the search to a horizontal band of the planet; an index on lng narrows it to a vertical band. Neither narrows it to the small square you actually want. The database ends up scanning one long band and checking the other coordinate row by row, so the work grows with the total number of places, not with how many are genuinely nearby.

The root cause is that nearness in two dimensions has no natural single ordering. Two places can be close on the map yet far apart in any one-column sort. To make "near me" cheap, you need a way to map 2D location into something a single index can order, so that points which are close on the ground end up close in the index. That is exactly what spatial indexes do, and the three approaches below are the ones an interviewer will want you to weigh against each other.

The reframing that unlocks the problem: do not try to make a 2D range query fast. Instead, assign every place a one-dimensional cell id that preserves locality, index that, and the range query becomes a cheap lookup of a handful of cells.

5. Geohash

A geohash interleaves longitude/latitude subdivision bits into base32 cells. Shared prefixes identify the same rectangle, but nearby points can lie across a high-level cut with unrelated prefixes. Dimensions vary by precision and latitude; odd bit counts give unequal axis subdivisions, not a universal 'odd character length means rectangles' rule.

Query a conservative covering of every cell intersecting the radius's bounding region, then refine distances. A query cell plus eight neighbors suffices only if radius fits the cell geometry at that latitude and wrap/pole handling is correct. For larger radii, finer cells, or polar cases, enumerate a larger covering or use S2/H3/PostGIS with documented geometry semantics.

Haversine gives spherical great-circle distance, not exact ellipsoidal geodesic or road travel distance. Ranking by walking/driving ETA requires routing after candidate filtering. Validate finite lat/lon, permitted bounds, positive radius, and geohash precision.

6. Quadtree

A quadtree subdivides a rectangle into four children where density is high. Range search visits all children whose bounds intersect the query; radius search uses a conservative bound and distance refinement. The result can touch many leaves, so per-leaf capacity does not bound the entire query cost.

Coincident points may never separate: enforce max depth/minimum cell size and allow overflow leaves. Define half-open boundary ownership to avoid lost/duplicated points. Updates and replicated publication need consistent snapshot/version handling; adaptive resolution trades indexing complexity for reduced density skew.

7. Grid and Cell Systems

The third family treats the planet as a set of named cells produced by a well-defined cell system, the best known being Google's S2. S2 projects the sphere onto the six faces of a cube and recursively subdivides each face into a hierarchy of cells, giving every cell a 64-bit id. Like a geohash, an S2 cell id is a single sortable number whose ordering preserves locality, so nearby cells have nearby ids; like a quadtree, the cells form a hierarchy you can query at coarse or fine levels.

Uber's open-source H3 is the other well-known cell system. It tiles the globe with hexagons (plus 12 pentagons per resolution, which are needed to close the sphere). A hexagon's six neighbors are all the same distance from its center, while a square has 4 edge neighbors and 4 farther corner neighbors, so "k rings of hexagons around me" approximates a circle well. That makes H3 popular for ride-hailing and delivery analytics, such as surge pricing per hexagon.

What a mature cell system buys you over a hand-rolled grid is correctness on a sphere and a clean, ready-made notion of "the cells covering this region." Given a query area, the library returns the small set of cell ids that cover it, and you look up places tagged with those ids. The two-column lat/long range query becomes a set-membership test on a handful of cell ids — exactly the kind of thing a single index handles well.

ApproachCell sizingStorageBest when
GeohashFixed, by prefix lengthPlain indexed string columnSimple, uniform-ish data; easy to store in any DB
QuadtreeAdaptive to densityIn-memory treeVery uneven density; bounded candidate sets matter
Grid / S2Hierarchical, sphere-correctIndexed cell-id columnLarge scale, want a battle-tested covering library
H3Hierarchical hexagonsIndexed cell-id columnUniform neighbor distance; aggregation per area (surge, heat maps)

Choose a distance/geometry model first: planar metres for a local toy example, spherical great-circle distance for an approximation, or ellipsoidal geodesic distance for tighter geographic accuracy. Driving/walking time is a separate routing model.

  1. Build a conservative covering of the radius's bounding region, including every intersecting cell and proper pole/antimeridian handling.
  2. Read candidates from the index, deduplicating IDs if multiple covers/levels overlap. Bounds and density determine cost; leaf capacity alone does not bound an entire query.
  3. Compute distance under the chosen model and remove candidates outside the requested radius.
  4. Apply current eligibility/visibility, rank with deterministic ties, and paginate with an explicit snapshot/freshness policy.

A fixed cell plus eight neighbors is sufficient only under documented cell-size/radius/location assumptions. Haversine is a spherical calculation, not exact ellipsoidal distance. Benchmark candidate volume and stale-update handling rather than assuming a cell lookup always returns a small bounded set.

9. Handling Uneven Density

Real-world places are not spread evenly. A dense city center may hold orders of magnitude more businesses per square kilometer than a rural area, and this lopsidedness is what punishes a one-size-fits-all index. With fixed cells, a downtown cell can return tens of thousands of candidates while a rural cell returns three — the same query costs wildly different amounts depending on where you stand.

This is precisely where the quadtree earns its keep: because it splits busy regions finely and leaves empty regions coarse, every leaf holds roughly the same bounded number of places. A query in a dense downtown descends a few extra levels and still lands on a small candidate set, so cost stays predictable everywhere. The fixed-grid alternative forces an awkward compromise on cell size, and the usual mitigations — capping results per cell, or maintaining different precisions for different regions — are really just approximations of what the quadtree does for free.

RegionFixed grid cellQuadtree behavior
Dense downtownOne cell, thousands of candidatesSplits deep; each leaf stays small
SuburbOne cell, a few hundredSplits a couple of levels
Rural / waterOne cell, near-emptyStays a single coarse node

10. Precomputation and Caching

A proximity service is overwhelmingly read-heavy, and most of the work is the same from request to request: the set of places near a given area changes slowly, while queries for that area arrive constantly. That mismatch is an invitation to precompute and cache. Rather than recomputing candidate sets on every request, you can keep per-cell results warm and serve repeated queries straight from memory.

Because the underlying data changes slowly and reads vastly outnumber writes, a proximity service is one of the rare systems where caching by cell can absorb the large majority of traffic — the database mostly handles the cold long tail and the occasional update.

11. Serving Architecture

Putting it together, the production shape reflects the read-heavy nature of the workload: a tier of stateless servers, a cache in front of the spatial index, and a separate, lower-traffic path for updating places. Reads scale horizontally; writes are comparatively rare and can be handled more deliberately.

The recurring theme is the split between a cheap, cacheable coarse lookup and an exact fine filter, served by a fleet that is mostly read replicas and caches. That separation — plus indexing at write time so reads stay light — is what lets a proximity service answer "what is near me?" in milliseconds across millions of places.

12. Failure Modes

What breaksEffectHow to handle it
A query server diesIts in-flight requests fail.Servers are stateless; the load balancer's health check removes it and clients retry.
Cache cluster restarts coldEvery search misses and hits the index at once (thundering herd).Warm the cache from the most popular cells before taking traffic; use request coalescing so one miss per cell goes to the database.
Primary database failsOwners cannot edit places.Searches still read from replicas; promote a replica. Edits being down for a minute is acceptable.
Replica lagA just-edited place shows old data.Acceptable by the requirements; show the owner their own edit by reading from the primary for them.
A viral hotspot (festival, stadium)One cell's key gets hammered.That cell is served from cache on every node; a hot key can also be replicated to several cache shards.

13. Tradeoffs and Alternatives

ChoiceOption AOption BRule of thumb
Index structureGeohash in a normal DB columnIn-memory quadtreeGeohash if you want simple operations; quadtree if density is extremely uneven and you need bounded candidate sets.
Off-the-shelfPostGIS / Elasticsearch geo queriesHand-built indexUse a database's geo features first; build your own only at very large scale or with special ranking needs.
Shard or replicateShard the index by regionReplicate the whole indexAt 3 GB the index fits in RAM, so replicate. Shard by region only when it no longer fits.
Result freshnessUpdate index on every editRebuild nightlyUpdate on edit is easy with geohash rows; a quadtree is often rebuilt in the background and swapped in.
DistanceStraight-line (haversine)Travel time on roadsStraight-line for "nearby places"; travel time when the user will actually drive there.

Worked example: neighbors across a grid boundary

Use a planar metre grid for the learning model; production geography requires globe-aware covering.

Worked example, in orderPlace query near a cell edge: Query (99,50) with radius 5 lies in a 100 m cell. Point (101,50) is only 2 m away but belongs to the next cell.. Cover the query bounds: Enumerate every cell intersecting [x−r,x+r]×[y−r,y+r], not only the containing cell.. Refine candidates: Filter by planar distance in this toy model. Real services use an explicit spherical/geodesic model; haversine approximates great-circle distance on a sphere.. Increase the radius: A 250 m radius with 100 m cells needs more than a fixed 3×3 neighborhood. Near poles/antimeridian, globe-aware covers avoid longitude wrap and cell-width mistakes.1Place query near a cell edge2Cover the query bounds3Refine candidates4Increase the radius
Read top to bottom. The table below explains the state and guarantee at each step.
StepState, calculation, and reasoning
1. Place query near a cell edgeQuery (99,50) with radius 5 lies in a 100 m cell. Point (101,50) is only 2 m away but belongs to the next cell.
2. Cover the query boundsEnumerate every cell intersecting [x−r,x+r]×[y−r,y+r], not only the containing cell.
3. Refine candidatesFilter by planar distance in this toy model. Real services use an explicit spherical/geodesic model; haversine approximates great-circle distance on a sphere.
4. Increase the radiusA 250 m radius with 100 m cells needs more than a fixed 3×3 neighborhood. Near poles/antimeridian, globe-aware covers avoid longitude wrap and cell-width mistakes.
Check your understanding: Do eight neighbors always cover any radius?

No. Only under suitable radius/cell-size/location assumptions. Build a conservative query covering or use a reviewed spatial library/database.

Run a small, testable model

Download concepts.py. It uses only the Python 3.10+ standard library. From the directory where you saved it, run:

python3 concepts.py geo
python3 concepts.py test

The grid model tests boundary neighbors and an expanded radius against a brute-force reference.

The program contains deterministic assertions and failure traces. It is a learning model, not a production service: it does not implement network consensus, distributed durability, authentication, or third-party delivery. Read its contracts and try changing an input before extending it.

Primary references for these concepts

14. A 45-Minute Interview Walkthrough

MinutesWhat to doWhat to say or draw
0–5ClarifyPlaces, not drivers. Fixed radii. Rank by distance. Edits can take a day.
5–10Estimate~17K peak QPS, 200 M places, index ~3 GB in RAM, reads >> writes.
10–15API and dataGET /search/nearby, places table, geo index table.
15–25High-level designLoad balancer → stateless search servers → cache → geo index replicas; separate business service and primary DB.
25–38Deep diveWhy a 2D range query is slow; geohash vs quadtree vs S2; boundary problem and the 3×3 neighbors; choosing precision from radius.
38–45Wrap upFailure modes, hotspots, how you would add ranking by rating, what you would monitor (p99 latency, cache hit rate).

15. Common Mistakes

Mistake 1: searching only the query cell

A café 10 m away but across a cell edge is missed. Always search the neighbors too.

Mistake 2: returning cell contents without a distance check

Cells are rectangles and the radius is a circle, so some candidates are outside the radius. Filter by exact distance before ranking.

Mistake 3: sharding a 3 GB index

Sharding adds routing and fan-out for no benefit when the data fits in memory. Replicate first.

Mistake 4: treating a lat/lng degree as a fixed distance

One degree of longitude is 111 km at the equator but about 56 km at 60° latitude. Use haversine, not raw degree differences.

Mistake 5: forgetting that geohash prefixes can break at big cuts

Two points on either side of the equator or the prime meridian can be meters apart with no shared prefix. Neighbor lookup handles this; prefix matching alone does not.

16. Summary

A proximity service is fundamentally a spatial-indexing problem dressed up as a search feature. The design is a chain of choices that each reduce work:

ConcernMechanism
Why doesn't a lat/long range query scale?Two independent columns; a 1D index can't narrow both, so cost grows with total places.
How do we make nearness one-dimensional?Geohash: encode location into a string whose shared prefix means spatial nearness.
How do we cope with uneven density?Quadtree: recursively subdivide only the crowded regions so every leaf stays small.
What if we want a sphere-correct, hierarchical scheme?A cell system like S2: 64-bit cell ids that preserve locality and cover any region.
How does a search actually run?Compute query cell + neighbors, gather candidates, filter by exact distance, rank.
Why include neighboring cells?Places near a cell boundary would otherwise be missed; neighbors fix the edge problem.
How do we keep latency low under load?Index at write time, cache results per cell, tolerate slight staleness.
What does the serving tier look like?Stateless query servers + cache over the index, with a separate write path.
The through-line: convert 2D location into a locality-preserving cell id, let the index do a cheap coarse filter, then finish with an exact distance check — and cache the coarse part, because the data changes far more slowly than people ask about it.

17. Check Yourself

  1. Why can't a composite B-tree index on (lat, lng) answer a bounding-box query efficiently?
    Answer

    The index is sorted by lat first. It can narrow to the latitude band quickly, but inside that band rows are not sorted by lng in one contiguous run, so the database scans the whole band and filters longitude row by row.

  2. A user searches with a 2 km radius. Which geohash length do you pick, and why?
    Answer

    Length 5. Its cell is about 4.9 km × 4.9 km, which is at least the radius, so the 3×3 block covers the whole circle. Length 6 cells (0.61 km short side) are smaller than the radius, so 3×3 would not be enough.

  3. Two coffee shops are 20 m apart but their geohashes share no prefix. How is that possible, and how does the search still find both?
    Answer

    They sit on opposite sides of a major cut such as the equator or the prime meridian, where the first bit already differs. The search includes the 8 neighbor cells of the query cell, so the shop across the cut is still in the candidate set.

  4. When would you choose a quadtree over geohash?
    Answer

    When density is very uneven and you need every lookup to return a bounded number of candidates, for example a mix of dense cities and empty countryside. The cost is an in-memory tree you must build and rebuild.

  5. Your index is 3.2 GB. Do you shard it? What do you do instead?
    Answer

    No. It fits in RAM on one server. Replicate it across many servers for read throughput and availability. Shard by region only when it no longer fits.

  6. Why is caching by cell id so effective here?
    Answer

    Many users in the same area query the same few cells, and place data changes slowly, so a cached cell list is reused many times before it goes stale.

18. Key Terms

Geohash
A string encoding a rectangular latitude/longitude cell by interleaving longitude and latitude subdivision bits and representing them in base32. Longer shared prefixes identify a smaller shared cell. Example and limits above.
Quadtree
A region tree splits a rectangle into four children when it is crowded, allowing smaller leaves in dense areas. Range search visits only children whose bounds intersect the query. Example and limits above.
S2
Google's library that projects the sphere onto a cube and numbers cells along a Hilbert curve with 64-bit ids.
H3
Uber's hexagonal hierarchical grid system.
Haversine
The formula for great-circle distance between two lat/lng points on a sphere.
Covering
The set of cells that together contain a query region.
Boundary problem
Nearby points that fall in different cells; solved by also searching neighbor cells.
Thundering herd
Many requests missing the cache at once and all hitting the database.

19. Further Reading


⌂