Design Proximity Search

Design a "places near me" service with reviews: geohash or quadtree indexes, text plus location queries, read-heavy scaling and correct ratings.

Case study26 minLesson 1 of 8
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

You type "pizza" into an app. It shows twenty places within a couple of kilometres, best first, each with a star rating and a distance. You tap one, read some reviews, and later leave your own. A review site with a map, such as Yelp, is the kind of product this question means.

It looks like plain CRUD until you ask "near me". Then you need a spatial index, a query that mixes location with text, a read path built for hundreds of reads per write, and an average rating that stays right when two people review at once. The steps follow A Framework for Any Design. Try each "Your turn" box before reading on.

Clarify the problem

The first five minutes
Interviewer

Design a service that finds businesses near a user, with reviews.

You

So users search by location and category, maybe with keywords, open a business page, and write a review with a star rating. Can one user review a business more than once?

Interviewer

One review per user per business. They can edit it.

You

How big? How many businesses, users and reviews?

Interviewer

50 million businesses worldwide, 100 million daily users who search about 5 times a day, and 2 million new reviews a day.

You

Then reads dwarf writes, and the search must come back in about 200 ms. A new business or a new review can take a few seconds to show up in search, but the rating a user sees must never lose a review. I'll leave out photos, ads and the business owner tools.

  • Functional: search businesses near a point by category and keywords, with filters (open now, price, minimum rating); view a business page with its reviews; add or edit one review per business, with 1 to 5 stars.
  • Non-functional: search in under 200 ms at the 99th percentile; reads highly available; new reviews and businesses searchable within a minute; the average rating counts every review exactly once.
  • Out of scope: photos (see Design Photo Sharing), ads, owner dashboards, fake-review detection.

Estimate the scale

A day is 86,400 seconds. Assume the evening peak is 3 times the average.

  • Searches: 100 million × 5 = 500 million a day ÷ 86,400 ≈ 5,800 a second, about 17,000 at peak.
  • Business page views: say 2 per search, 1 billion a day ≈ 11,600 a second, about 35,000 at peak.
  • Review writes: 2 million a day ÷ 86,400 ≈ 23 a second, about 70 at peak. Reads outnumber writes about (500 million + 1 billion) ÷ 2 million = 750 to 1.
  • Storage: 50 million businesses × 2 KB = 100 GB. Say 1 billion existing reviews × 1 KB = 1 TB, growing 2 million × 1 KB × 365 ≈ 730 GB a year.
Estimate: How big is the search index?

Each business becomes one search document with its name, categories, location, hours, price and rating. About how much memory does the whole index need?

You know
  • 50 million businesses
  • About 500 bytes per search document
  • Reviews are not in the index, only each business's rating

Show the working
  1. 50 × 106 × 500 B = 25 × 109 B = 25 GB
  2. The coordinates alone are 50 million × 24 B (id, latitude, longitude) = 1.2 GB

Estimate 25 GB (anything from 8 GB to 80 GB is a good answer)

25 GB fits in the memory of one server. So you add index machines for throughput, as full copies, not because the data needs splitting.

Estimate: Peak search rate

About how many searches a second arrive at the evening peak?

You know
  • 100 million daily users
  • 5 searches per user per day
  • Peak is 3 times the average
  • A day is 86,400 seconds

Show the working
  1. 100 × 106 × 5 = 500 million searches a day
  2. 500 × 106 ÷ 86,400 ≈ 5,800 a second
  3. 5,800 × 3 ≈ 17,000 a second at peak

Estimate 17 thousand/s (anything from 6 thousand/s to 50 thousand/s is a good answer)

Your turn: Define the API

Write the search, business page and review endpoints. How does the API make "one review per user" natural, and safe when the phone retries?

Compare with a model answer

Search is a GET with a point, a radius, keywords and filters, paged with a cursor. The review is a PUT on a resource keyed by business and user, so writing it twice is the same as writing it once.

Define the API

HTTP API
GET /search?lat=40.7128&lng=-74.0060&radius_m=2000
           &q=pizza&category=restaurants&open_now=true&min_rating=4&cursor=...
  200 { "results": [ { "id": "b_81", "name": "Luigi's", "rating": 4.6,
                       "review_count": 312, "distance_m": 450 }, ... ],
        "next_cursor": "..." }
  400 Bad Request   radius over 50 km, or no location
 
GET /businesses/{id}                   page data, cacheable for 60 s
GET /businesses/{id}/reviews?sort=recent&cursor=...
 
PUT /businesses/{id}/reviews/me        create or replace my review
  body: { "rating": 5, "text": "..." }
  200 { "rating": 5, "business_rating": 4.61, "review_count": 313 }
  400 Bad Request   rating not 1 to 5
  404 Not Found     no such business
DELETE /businesses/{id}/reviews/me

The decision worth arguing is PUT .../reviews/me instead of POST .../reviews. A POST creates a review each time, so a retry after a timeout tries to add a second one. A PUT names the one review this user may have, so a retry replaces it with the same content. "One review per user" becomes the shape of the URL, backed by a primary key.

Your turn: Model the data

Sketch businesses and reviews. Where does the average rating live, and which store answers "places near this point"?

Compare with a model answer

Businesses and reviews in a relational database, reviews keyed by (business, user), with a running sum and count of stars on the business row. A separate search index answers location and text queries, fed by the database's change stream.

Model the data

Source of truth
businesses
  business_id   BIGINT PRIMARY KEY
  name          VARCHAR(200)
  categories    TEXT[]            -- ["restaurants", "pizza"]
  lat, lng      DOUBLE
  hours         JSON              -- weekly opening intervals
  price_level   SMALLINT
  rating_sum    BIGINT            -- total stars of all reviews
  rating_count  INT               -- number of reviews
  -- about 2 KB per row
 
reviews
  business_id   BIGINT
  user_id       BIGINT
  rating        SMALLINT          -- 1 to 5
  text          TEXT
  updated_at    TIMESTAMP
  PRIMARY KEY (business_id, user_id)   -- one review per user, enforced

Access patterns: find businesses near a point that match words and filters (17,000 a second at peak); read one business and a page of its reviews (35,000 a second); write one review and update one business's rating (70 a second).

The writes need a transaction across two rows, the review and the business, so the source of truth is a relational database, partitioned by business_id once it outgrows one machine. A business and its reviews share a partition, so that transaction never crosses machines. The search query is a different shape: geometry plus text plus ranking. That goes to a search index with geo support (see search systems), a copy built from the database. A geospatial extension of the database itself is a fine alternative at small scale; the deep dive compares them.

Your turn: High-level design

Sketch the boxes for a search, a page view and a review write. How does a new review reach search results?

Compare with a model answer

Three small services behind a gateway. Search reads only the search index; page views read through a cache; reviews write to the database in one transaction. A change stream from the database updates the index a few seconds later.

High-level design

Split the services by path, because each has its own store and load. Search never touches the database, which takes only page misses and 70 writes a second.

Search reads the index, pages read through a cache, and reviews write to the database and then delete the cached page. A change stream copies every change into the index.Phone or browser to API gateway (HTTPS); API gateway to Search service; API gateway to Business service; API gateway to Review service; Search service to Search index replicas (geo + text); Business service to Page cache (get page); Business service to Business DB (on miss); Review service to Business DB (one transaction); Review service to Page cache; Business DB to Indexer (change stream), asynchronous; Indexer to Search index replicas (update doc), asynchronous.Phone or browserAPI gatewaySearch serviceBusiness serviceReview serviceSearch indexreplicasPage cacheBusiness DBIndexerHTTPSgeo + textget pageon missone transactionchange streamupdate doc

request and replyasynchronous message

Search reads the index, pages read through a cache, and reviews write to the database and then delete the cached page. A change stream copies every change into the index.
Search, open a page, write a review, lose an index replicaUse the arrows or ← → keys
  1. Search, open a page, write a review, lose an index replica, step 1Phone or browser to API gateway; API gateway to Search service; API gateway to Business service; API gateway to Review service; Search service to Search index replicas; Business service to Page cache; Business service to Business DB; Review service to Business DB; Review service to Page cache; Business DB to Indexer, asynchronous; Indexer to Search index replicas, asynchronous.Phone or browserAPI gatewaySearch serviceBusiness serviceReview serviceSearch indexreplicasPage cacheBusiness DBIndexer
    radius
    2 km

    1Mia searches "pizza", open now, within 2 km. Any stateless search server takes it.

  2. Search, open a page, write a review, lose an index replica, step 2Phone or browser to API gateway; API gateway to Search service; API gateway to Business service; API gateway to Review service; Search service to Search index replicas; Business service to Page cache; Business service to Business DB; Review service to Business DB; Review service to Page cache; Business DB to Indexer, asynchronous; Indexer to Search index replicas, asynchronous.Phone or browserAPI gatewaySearch serviceBusiness serviceReview serviceSearch indexreplicas9 cellsPage cacheBusiness DBIndexer
    radius
    2 km
    cells
    9

    2The search server sends one query to one replica. Inside the index, the geo filter turns her point into map cells: her precision-5 geohash cell and its 8 neighbours, which together cover any 2 km circle around her.

  3. Search, open a page, write a review, lose an index replica, step 3Phone or browser to API gateway; API gateway to Search service; API gateway to Business service; API gateway to Review service; Search service to Search index replicas; Business service to Page cache; Business service to Business DB; Review service to Business DB; Review service to Page cache; Business DB to Indexer, asynchronous; Indexer to Search index replicas, asynchronous.Phone or browserAPI gatewaySearch serviceBusiness serviceReview serviceSearch indexreplicas140 matchPage cacheBusiness DBIndexer
    radius
    2 km
    matches
    140
    returned
    20

    3The same query keeps places in those cells within the exact 2 km, matching "pizza" and open now. 140 match; the index ranks them and returns the top 20.

  4. Search, open a page, write a review, lose an index replica, step 4Phone or browser to API gateway; API gateway to Search service; API gateway to Business service; API gateway to Review service; Search service to Search index replicas; Business service to Page cache; Business service to Business DB; Review service to Business DB; Review service to Page cache; Business DB to Indexer, asynchronous; Indexer to Search index replicas, asynchronous.Phone or browserAPI gatewaySearch serviceBusiness serviceReview serviceSearch indexreplicasPage cacheBusiness DBIndexer
    returned
    20
    db reads
    0

    4Name, rating and distance come straight from the index documents, so search reads no database.

  5. Search, open a page, write a review, lose an index replica, step 5Phone or browser to API gateway; API gateway to Search service; API gateway to Business service; API gateway to Review service; Search service to Search index replicas; Business service to Page cache; Business service to Business DB; Review service to Business DB; Review service to Page cache; Business DB to Indexer, asynchronous; Indexer to Search index replicas, asynchronous.Phone or browserAPI gatewaySearch serviceBusiness serviceReview serviceSearch indexreplicasPage cachehitBusiness DBIndexer
    rating
    4.60 (310)

    5She opens Luigi's. The page is in the cache, so the business service answers in about a millisecond.

  6. Search, open a page, write a review, lose an index replica, step 6Phone or browser to API gateway; API gateway to Search service; API gateway to Business service; API gateway to Review service; Search service to Search index replicas; Business service to Page cache; Business service to Business DB; Review service to Business DB; Review service to Page cache; Business DB to Indexer, asynchronous; Indexer to Search index replicas, asynchronous.Phone or browserAPI gatewaySearch serviceBusiness serviceReview serviceSearch indexreplicasPage cacheBusiness DB+5 stars, +1Indexer
    rating_sum
    1,426 → 1,431
    rating_count
    310 → 311

    6Later she gives it 5 stars. One transaction inserts her review and adds 5 to the sum and 1 to the count on Luigi's row. Both change, or neither does.

  7. Search, open a page, write a review, lose an index replica, step 7Phone or browser to API gateway; API gateway to Search service; API gateway to Business service; API gateway to Review service; Search service to Search index replicas; Business service to Page cache; Business service to Business DB; Review service to Business DB; Review service to Page cache; Business DB to Indexer, asynchronous; Indexer to Search index replicas, asynchronous.Phone or browserAPI gatewaySearch serviceBusiness serviceReview serviceSearch indexreplicasPage cacheBusiness DBdelta 0Indexer
    rating_sum
    1,431
    rating_count
    311

    7Her phone timed out and retries the same PUT. The row for (Luigi's, Mia) exists, so it is an edit from 5 to 5: the sum changes by 0 and the count stays.

  8. Search, open a page, write a review, lose an index replica, step 8Phone or browser to API gateway; API gateway to Search service; API gateway to Business service; API gateway to Review service; Search service to Search index replicas; Business service to Page cache; Business service to Business DB; Review service to Business DB; Review service to Page cache; Business DB to Indexer, asynchronous; Indexer to Search index replicas, asynchronous.Phone or browserAPI gatewaySearch serviceBusiness serviceReview serviceSearch indexreplicasrating 4.60Page cachekey deletedBusiness DBIndexer
    rating
    1,431 ÷ 311 = 4.60

    8After the commit the review service deletes Luigi's cached page, so the next view reads 4.60. Seconds later the indexer, reading the change stream, rewrites Luigi's search document too.

  9. Search, open a page, write a review, lose an index replica, step 9Phone or browser to API gateway; API gateway to Search service; API gateway to Business service; API gateway to Review service; Search service to Search index replicas; Business service to Page cache; Business service to Business DB; Review service to Business DB; Review service to Page cache; Business DB to Indexer, asynchronous; Indexer to Search index replicas, asynchronous.Phone or browserAPI gatewaySearch serviceBusiness serviceReview serviceSearch indexreplicas1 replica downPage cacheBusiness DBIndexer
    peak searches/s
    17,000

    9An index replica dies. Each holds the whole 25 GB index, so the search service retries on another, and the rest absorb its share of 17,000 a second.

1 / 9

Deep dives

Interviewers push on the geospatial index, the 750-to-1 read load, the rating under concurrent writes, and queries that mix words with location.

The geospatial index

An ordinary index on latitude finds a thin band around the whole planet, and the database then checks every business in it. You need an index that groups points by area. Design Ride Sharing introduces geohash cells and the neighbour-cell problem; here the points are businesses, which almost never move, and that changes the choice.

Ways to index 50 million places
OptionGood atBad atPick it when
Geohash cellsA cell is a string prefix, so any store with a sorted index can query it. Simple to shard and update.Fixed cells: a downtown cell holds thousands of places, a rural one none, so the search radius must adapt.You must build it yourself on a key-value or relational store.
Quadtree in memorySplits a square into four whenever it holds more than, say, 100 places, so cells follow density. A k-nearest search walks the tree.Your own server code to build, shard and keep in sync with edits. Rebuilt or patched as places change.Points rarely move and you want "nearest 20" more than "within 2 km".
Geo support in a search engine or database Usual defaultA geo-point field in the search index, or a spatial extension of the database, handles cells, neighbours and exact distance for you, in the same query as text and filters.Less control over how cells work; the database extension shares the machine with your writes.Almost always. A search engine here, because the query also needs text and ranking.

Know the hand-built versions anyway, because interviewers ask how they work. With geohash, pick the precision from the radius. A 3 × 3 block of cells covers any circle whose radius is no bigger than one cell's shorter side, so the cell must be at least as large as the radius. A precision-6 cell is about 1.2 km × 0.6 km, too small for 2 km. A precision-5 cell is about 4.9 km square at the equator, so 9 of them cover 2 km. The east-west width shrinks with latitude: at 50° north a precision-5 cell is only about 3.1 km wide. Still enough for 2 km, but compute from the real cell size.

A quadtree fits places well. With at most 100 places per leaf, 50 million places need at least 500,000 leaves, and the tree plus 1.2 GB of coordinates fits in one server's memory. Build it offline, ship it to every search server, and apply the day's new places as a small overlay until the next rebuild. The strong answer: "I'd use the engine's geo field, and here is how it works inside."

Serving 750 reads for every write

The index is 25 GB, so every search machine holds a complete copy. Do not shard it for size; add replicas for throughput. If one replica handles about 1,000 of these queries a second (measure yours), the 17,000 peak needs 17, so run about 20 for headroom. A query then touches one machine, with no scatter-gather.

Business pages go through a cache. All 50 million pages are 100 GB; caching the most viewed 10% takes about 10 GB. The review service deletes a page's key after each commit, and a 60-second expiry bounds the damage if a delete is lost. Cache search results for 60 seconds, keyed by the user's precision-6 cell, the words and the filters, so everyone in one 1.2 km × 0.6 km cell asking for "pizza" shares an entry; recompute the 20 distances for the exact point.

Keeping the average rating correct

Luigi's has 10 reviews totalling 40 stars, an average of 4.0. Ana gives 5 stars and Ben gives 1 at the same moment. The correct result is 46 stars over 12 reviews, about 3.83. If each request reads the row, computes the new values in the app and writes them back, both read (40, 10). Ana writes (45, 11); Ben writes (41, 11). The final row says 11 reviews averaging 3.73, and Ana's review is lost from the total. This is the lost-update race.

Ways to keep the rating right
OptionGood atBad atPick it when
Recompute from all reviewsAlways exact: AVG(rating) over the business's reviews after each write.A business with 50,000 reviews reads 50,000 rows per write, and concurrent recomputes still need ordering.Small data, or a nightly job that repairs drift.
Atomic increment in the review transaction Usual defaultSET rating_sum = rating_sum + 5, rating_count = rating_count + 1 in the same transaction as the review insert. The database serializes the two updates on the row.Every review locks its business row briefly. Fine at 70 writes a second spread over millions of businesses.The usual answer.
Events, aggregated laterNo lock on the business row; a worker adds up review events.The total lags, and a redelivered event counts twice unless the worker is idempotent per review.One business gets thousands of reviews a second, which does not happen here.
Create or replace a review, rating kept exact
BEGIN;
-- 1. lock the business row: reviews of this business now take turns
SELECT 1 FROM businesses WHERE business_id = 81 FOR UPDATE;
-- 2. my old review, if any
SELECT rating FROM reviews WHERE business_id = 81 AND user_id = 7;  -- 3, or none
INSERT INTO reviews (business_id, user_id, rating, text, updated_at)
     VALUES (81, 7, 5, '...', now())
ON CONFLICT (business_id, user_id)
     DO UPDATE SET rating = 5, text = '...', updated_at = now();
-- 3. new review: +5 stars, +1 review.  edit from 3 to 5: +2 stars, +0.
UPDATE businesses SET rating_sum = rating_sum + 2, rating_count = rating_count + 0
 WHERE business_id = 81;
COMMIT;

Why it is safe: every review of business 81 first locks its row, so those transactions take turns, and the old-review read in step 2 sees what the previous one committed (at read committed, as in PostgreSQL, each statement reads fresh data). The primary key rules out a second review by one user even if two requests race. The lock lasts a few milliseconds, nothing at 70 writes a second. Store the sum and count, never the average: an average cannot be updated by a delta.

Mixing words, filters and location

"Wood-fired pizza, open now, 4 stars or more, within 2 km" has three parts. Running them as separate steps fails either way: location first fetches thousands of downtown places to keep 20; text first finds every pizza place on the planet.

Put everything in one search index query: the geo filter and the structured filters cut the candidates inside the index, the words match against the inverted index, and the engine scores only what is left. A simple score multiplies text relevance by a distance decay and a rating factor, so a 4.8-star place at 1.5 km can beat a 4.1-star place at 300 m. "Open now" is a filter on opening intervals stored as minutes of the week in the business's local time; the search server converts "now" into the local time of the search area, so the filter compares one number.

Bottlenecks and failures

At 10x, 170,000 searches a second at peak, you need about 200 index replicas at 1,000 queries each. Split them into regional clusters near their users, each holding only its region's index. Writes at 700 a second still fit a partitioned relational database. A dense city at dinner sends the same queries from the same cells, which the 60-second result cache absorbs.

When this diesWhat happensWhat you do
A search index replicaIts queries fail or time out.Retry on another replica; each holds the full index.
The indexer or change streamNew reviews and places stop reaching search.Search keeps working. The stream keeps its position, so the indexer resumes where it stopped.
The page cache35,000 page reads a second fall toward the database.Read replicas sized for it, and a short in-process cache per server.
A database primaryReviews for its partition fail until a replica takes over.Synchronous replica and automatic failover. Search and pages are unaffected.

What a strong answer sounds like

LevelWhat the answer does
JuniorStores latitude and longitude in a table and filters with a distance formula, then accepts a spatial index when the interviewer asks about 50 million rows.
MidUses geohash or a quadtree, remembers the neighbour cells, separates the search index from the database, and caches pages.
SeniorNotices the index fits in memory and replicates instead of sharding, combines geo, filters and text in one query, keeps ratings exact with a sum and count in the review transaction, and says which data may lag.
Going deep on the indexStrong answer
Interviewer

How would you shard the geo index?

You

At first I wouldn't. The index is about 25 GB, so every search node holds a full copy and a query touches one machine. I need about 20 replicas for 17,000 searches a second. If it grew past one machine's memory, I'd shard by region, because a search almost never crosses a continent, and accept that a big city's shard needs more replicas.

Interviewer

And if you had to build the spatial part yourself?

You

A quadtree with about 100 places per leaf, built offline and shipped to every node, because places barely move. For 2 km I'd collect the leaves that touch the circle, then filter by exact distance.

Follow-up questions

Fewer than 20 results within 2 km. What then?

Widen the radius in steps, 2 km, then 5, then 10, until you have enough or hit a limit. A quadtree does this by walking to neighbouring leaves.

The user pans the map. How do you search a rectangle?

Send the map's corners instead of a point and radius; geo fields filter by a box directly. Cap the box size, and show clusters when zoomed out.

Should a place with one 5-star review rank above one with 300 reviews at 4.7?

No. Rank by an adjusted rating that pulls small samples toward the site-wide average, such as (sum + 10 × site average) ÷ (count + 10). Show the real average on the page.

Search shows Luigi's at 4.60 but the page shows 4.61. Is that a bug?

No. Search reads a copy that lags by seconds; the page reads the database through a cache that is cleared on every review. The requirements allow search to lag, and the totals behind both numbers are exact.

A review is removed as fake. What changes?

Hide it in the same kind of transaction: subtract its stars from the sum and 1 from the count. A nightly job recomputes sums from the reviews to catch drift from bugs.

Self-review: proximity search0 of 14

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure