EasyProblem 3 of 36 in the system design series
Design Yelp: Local Business Search with Geo Queries and Reviews
Prefer watching? This article is the written companion to the video above, from the Shubham Aher YouTube channel.
A local discovery app answers a question people ask many times a week: what is good near me right now? The user types "ramen" or taps a category, sees a ranked list of nearby places with star ratings, opens one to read reviews, and perhaps writes a review of their own later. Behind that flow sit tens of millions of business listings and a steady trickle of new opinions.
Interviewers like this problem because it hides two well-known challenges behind a friendly product. The first is spatial: a normal database index is good at sorting one dimension, but "near me" is a two-dimensional question that must be answered across millions of rows in well under a second. The second is aggregation: an average rating must stay accurate while many people review the same place concurrently.
Neither challenge needs exotic infrastructure, which makes the question a good test of judgement. Strong answers pick a sensible spatial index, keep ratings correct with ordinary transactions, and then protect the read path, since browsing vastly outnumbers writing. The walkthrough below designs a Yelp-like service in that order.
This is an interview practice design for a Yelp-like service, built from first principles. It is not a description of how Yelp is actually built.
Functional Requirements
Three capabilities form the core. Users search for businesses around a location, by name or by category such as pizza, and narrow results with filters like a minimum star rating. Users open a business page showing its details, photos, average rating and recent reviews. Users write a review consisting of one to five stars plus free text, and each person may review a given business only once.
Table reservations, food delivery and advertising are distinct products with their own data and workflows, so they are excluded. Leaving them out keeps the conversation on the two problems that matter here: finding nearby places quickly and keeping ratings trustworthy.
Search nearby
Find businesses by location, name or category.
near mefilters
View a business
Details, photos, average rating and reviews.
Leave a review
One to five stars plus text.
one per user
- Out of scope today: reservations, delivery and ads
Non-Functional Requirements
Search latency is the headline requirement: assume results must come back in under 500 ms, because people searching on a phone at a street corner abandon slow apps immediately. The workload is strongly read-dominated, with searches and page views far outnumbering new reviews, so read capacity is where most investment goes.
Availability matters most for search. If the review pipeline is slow or partly down, people should still be able to find a restaurant; the two paths must fail independently. That separation is affordable because the product tolerates eventual consistency. A review that takes a few seconds to appear in search rankings harms nobody, and accepting that lag lets the write path stay simple and decoupled from the read path.
Low-latency search
Nearby results appear almost instantly.
< 500 ms
Read-heavy scale
Far more searches and page views than reviews.
reads ≫ writes
High availability
Search keeps working even if reviews are slow.
99.99% uptime
Eventual consistency
A new review may take a few seconds to show up.
seconds of lag OK
Scale Estimation
Assume about 20 million businesses, each described by roughly 2 KB of data, for a total near 40 GB. That is small enough to hold entirely in memory across a modest cluster. Assume 50 million daily active users running about five searches each, giving around 250 million searches per day. That averages to roughly 3K searches per second, with peaks around 10K.
Writes are tiny by comparison. If the platform receives around 1 million reviews per day, that is only about 12 per second. These figures reshape the problem: storage is trivial and write throughput barely registers, so the real engineering effort belongs to answering location queries quickly and serving the hot read path efficiently.
20million
Businesses
≈ 2 KB each → 40 GB
50million
Daily active users
≈ 5 searches each / day
3K avg
Searches per second
250M / day · ≈ 10K at peak
1million
New reviews per day
≈ 12 per second
Data fits in memory → the hard part is the location query
Core Entities & API
The domain needs a business, a review, a user, and a location stored as a latitude and longitude pair. Keeping location as plain coordinates on the business record leaves us free to choose any spatial index later.
Search is GET /search with lat, lng, a text query q and a category, returning a page of businesses plus a next_cursor for pagination. Cursor-based paging avoids the cost and inconsistency of large offsets when results shift. Opening a listing calls GET /businesses/{id}, which returns details, the current rating and the newest reviews. Writing a review is POST /businesses/{id}/reviews with a stars value from 1 to 5 and the review text, returning a review_id. The user identity comes from the auth token, not the body, so nobody can post as someone else.
Core entities
BusinessReviewUserLocation (lat, lng)
API
- GET /search?lat&lng&q&category→ { businesses[], next_cursor }
- GET /businesses/{id}→ { details, rating, reviews[] }
- POST /businesses/{id}/reviews { stars: 1–5, text }→ { review_id }
High-Level Design
An API gateway sits in front of everything, handling authentication, rate limiting and routing. Search traffic goes to a search service that queries a dedicated index such as Elasticsearch, asking for places near the user's coordinates that also match the text and filters. A dedicated index is the right tool because it combines geo filtering, full-text relevance and attribute filters in a single query.
Business pages and review submissions go to a separate business service backed by Postgres, which acts as the authoritative record. Listings change rarely but are viewed constantly, so a Redis cache holds popular listings ready to serve.
The search index is kept current through change data capture: every committed change in Postgres is streamed into the index within a few seconds, without the business service having to write to two systems. Because search and reviews travel separate paths, a spike in review submissions cannot degrade search, which matches the availability goal set earlier.
Two separate paths → a burst of reviews never slows search
Deep Dive 1 — Geo Search
The simplest attempt stores latitude and longitude as columns and filters with two ranges. A B-tree can use only one of those ranges efficiently, so the database ends up scanning a wide strip of rows and discarding most of them. At 20 million businesses that is too slow for a 500 ms budget.
A geohash avoids that problem by dividing the map into a hierarchy of grid cells, each labelled with a short string. Places in the same small cell share a longer prefix, so a proximity search turns into a prefix match that an ordinary index handles well. A quadtree is the main alternative: it subdivides only where places are dense, giving fine cells downtown and coarse ones in rural areas. It adapts nicely, but it is a custom in-memory structure that is awkward to update and replicate.
Geohash wins on simplicity, with one wrinkle. A user standing near a cell edge may be a few metres from places in the adjacent cell, so the query covers the user's cell plus the eight surrounding it, then computes exact distances to filter and rank. In practice you rarely implement this by hand; Elasticsearch geo queries, Redis GEO commands and PostGIS all provide it, and Elasticsearch adds text and filters in the same request.
Lat / lng ranges
- + Easy to build
- − Two range filters scan a lot
- − Slow at 20M rows
Geohash cellsChosen
- + Nearby = shared prefix
- + Works with normal indexes
- − Must check neighbour cells
Quadtree
- + Adapts to dense cities
- − Custom in-memory tree
- − Hard to keep updated
- Search the user's cell + 8 neighbours, then filter by exact distance
- Built in: Elasticsearch geo queries · Redis GEO · PostGIS
Deep Dive 2 — Rating Aggregation
Correct ratings begin with correct input. A unique constraint on the pair of user_id and business_id in the reviews table stops one person from stuffing the ballot. Next, the review insert and the rating update happen in one database transaction: the business row keeps a running sum of stars and a count of reviews, and both are changed with atomic increments. Two reviews landing at the same instant therefore cannot overwrite each other's update.
With those two stored numbers, displaying a rating is a single division rather than an aggregate over thousands of review rows. Change data capture then carries the new values into the search index, so sorting by rating reflects the review within seconds.
Raw averages still mislead. A café with a single five-star review would outrank a bistro averaging 4.6 across 2,000 reviews. A Bayesian average fixes this by blending each business's ratings with a global prior, so sparse samples are pulled toward the platform average until enough reviews accumulate to trust them.
- 1One per userUnique key on (user_id, business_id)
- 2One transactionInsert review + sum += stars, count += 1
- 3Cheap readsAverage = sum ÷ count; never re-scan reviews
- 4Sync to searchCDC pushes the new rating to the index
- 5Rank fairlyBayesian average: 1 review ≠ 2,000 reviews
Deep Dive 3 — Scaling Reads
Search scales by sharding the index geographically. Since nearly all queries are local, a typical request touches one shard, and replicas add read throughput where a city is busy. Popular searches are also cached, keyed by the geohash prefix combined with the query and filters, for example sushi with four stars or more in a given cell, using a short TTL of about a minute so results stay reasonably fresh.
Business pages live in Redis and are invalidated whenever the listing or its rating changes. Photos are stored in object storage such as S3 and delivered from a CDN near the user, keeping heavy bytes off the application servers. Finally, the system degrades gracefully: if the review path struggles, search continues serving results with ratings that may lag slightly, which is a far better outcome than an empty results page.
Shard the index by region
Most searches are local, so a query usually hits one shard; add replicas for reads.
Cache popular searches
Key = geohash prefix + query + filters, with a short TTL of about a minute.
Cache business pages
Redis keeps hot pages; delete the entry when the business or its rating changes.
Photos via CDN
Images live in object storage and are served from the edge.
Degrade gracefully
If the review path struggles, search keeps working with slightly older ratings.
Trade-offs
Choosing geohash over a quadtree buys simplicity and off-the-shelf tool support, paid for with the extra neighbour-cell lookups. Storing a running sum and count makes averages instantly available, though each review now contends on one shared row; at roughly a dozen reviews per second that contention is negligible.
Eventual consistency between Postgres and the search index keeps writes fast and the two paths independent, at the price of new reviews taking a few seconds to influence search results. Caching search responses removes a large share of index load, but a cached page can be up to a minute old. The overall recipe is short: a geo-aware index for discovery, ratings precomputed at write time, and aggressive caching wherever reads are hot.
Geohash vs quadtree
Simple and built into common tools — but we must check neighbour cells.
Stored sum and count
Instant averages — but every review updates one shared row.
Eventual consistency for search
Fast, decoupled writes — new reviews take seconds to appear in search.
Caching search results
Much less load — but results can be up to a minute old.
Geo index for search. Precomputed ratings. Cache the hot reads.
Key takeaways
- All business data fits in memory; the real challenge is answering location queries quickly.
- Geohash turns proximity into a prefix lookup; always include the eight neighbouring cells.
- Keep a running sum and count updated atomically so averages are correct and cheap to read.
- Bayesian averages stop a single five-star review from beating thousands of solid ones.
- Change data capture keeps the search index in step with Postgres within seconds.
- Separate search from reviews so the read path survives problems on the write side.
Interview questions
How does Yelp find nearby restaurants so quickly?
A Yelp-like design indexes every business location with a spatial scheme such as geohash, which maps nearby coordinates to strings with shared prefixes. A search looks up the user's cell and its eight neighbours, then filters candidates by exact distance and ranks them. Search engines like Elasticsearch, Redis GEO and PostGIS provide this, often combined with text and attribute filters.
What is the difference between geohash and quadtree for location search?
A geohash divides the world into a fixed grid of cells encoded as strings, so it works with ordinary database indexes and is widely supported. A quadtree recursively splits regions into four only where data is dense, adapting well to busy cities, but it is a custom in-memory structure that is harder to update, persist and replicate across servers.
How do you calculate average ratings without race conditions?
Store a running star sum and review count on the business row, and update them with atomic increments inside the same transaction that inserts the review. Concurrent reviews then cannot overwrite each other's changes. The displayed average is simply sum divided by count, so reading it never requires scanning the reviews table.
Why use a Bayesian average for business ratings?
A plain average treats one review the same as thousands, so a new place with a single five-star rating could outrank an established favourite. A Bayesian average mixes each business's ratings with a prior based on the overall platform average, weighted by review count. Places with few reviews stay near the typical rating until more evidence arrives.
How do you prevent a user from reviewing the same business twice?
Add a unique constraint across the user ID and business ID columns in the reviews table. Any second insert for the same pair fails at the database level, even if two requests race each other. The API can catch that error and offer to edit the existing review instead, which keeps the rating fair without complex application logic.
Keep practising
- Learn the building blocks in the free system design course.
- Rehearse answers with system design interview questions.
- See which open jobs fit your skills: upload your resume.