“Find good coffee near me.” Behind that one tap sits a system that must filter millions of businesses by location, category and opening hours, rank them by a blend of distance and quality, and do it in well under a second — while serving a review corpus that is read thousands of times for every write. Yelp is a geospatial-search problem wrapped around a read-heavy reviews problem, and the interview rewards you for treating each half correctly.
Understanding the Problem
Functional Requirements
- Search businesses near a location — by category, filtered by price, rating, open-now, etc.
- View a business — details, photos, hours, and its reviews
- Write a review — a star rating (1–5) plus text
- See aggregate ratings — average stars and review count per business
- Business management — owners add/update their listings
Out of scope for the core interview: reservations, messaging, ads.
Non-Functional Requirements
| Requirement | Target |
|---|---|
| Search latency | < 200 ms (p99) — it feels instant |
| Availability | 99.9% — search down = no customers found |
| Read:write ratio | ~1000:1 — people browse far more than they review |
| Consistency | Search & ratings may lag a few seconds (eventual) |
| Scale | ~50M businesses, ~500M reviews, ~10K searches/sec peak |
Estimation. 50M businesses × ~2 KB of metadata ≈ 100 GB — small enough to index fully. Reviews dominate storage: 500M × ~1 KB ≈ 500 GB. The defining characteristic is the read skew: a business is searched and viewed constantly but reviewed rarely, so we optimise relentlessly for reads.
Core Entities and APIs
Data Model
CREATE TABLE businesses (
id UUID PRIMARY KEY,
name VARCHAR(200),
category VARCHAR(50), -- 'restaurant', 'cafe', ...
lat DECIMAL(10,7),
lng DECIMAL(10,7),
geohash VARCHAR(12), -- precomputed cell id, indexed
price_tier SMALLINT, -- 1..4 ($ .. $$$$)
hours JSONB,
-- precomputed aggregates (updated on new review):
rating_sum BIGINT DEFAULT 0, -- sum of all scores
rating_count INTEGER DEFAULT 0,
-- derived, never accumulated (no rounding drift):
rating_avg NUMERIC(3,2) GENERATED ALWAYS AS
(CASE WHEN rating_count > 0
THEN rating_sum::numeric / rating_count END) STORED
);
CREATE INDEX idx_biz_geohash ON businesses (geohash);
CREATE TABLE reviews (
id UUID PRIMARY KEY,
business_id UUID REFERENCES businesses(id),
user_id UUID,
score SMALLINT CHECK (score BETWEEN 1 AND 5),
body TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_review_business ON reviews (business_id, created_at DESC);Note the two denormalised columns on businesses: rating_sum and rating_count, with rating_avg derived from them. They are the whole trick for serving ratings cheaply. We store the sum rather than a running average because a rounded average fed back into its own update accumulates error with every review; integer sums stay exact.
API Design
GET /api/v1/search
query: lat, lng, radius_km, category, price, open_now, min_rating,
sort = (relevance|distance|rating), cursor, limit
response: { results: [{ business_id, name, distance_m, rating_avg,
rating_count, price_tier }], next_cursor }
GET /api/v1/businesses/{id}
response: { ...details, rating_avg, rating_count, hours }
GET /api/v1/businesses/{id}/reviews?cursor=&limit=20
response: { reviews: [...], next_cursor }
POST /api/v1/businesses/{id}/reviews
body: { score: 5, body: "Great flat white" }
response: { review_id }Pagination uses cursors (e.g. the last seen id/score), not OFFSET — deep offsets are slow and drift as data changes.
High-Level Design
A Search Service takes the query, asks the geo index for candidate businesses in the right area, hydrates their details and precomputed ratings from the business store, applies filters, ranks, and returns a page. A separate Review Service handles writing reviews and keeping each business’s rating aggregate up to date. Because search is read-dominated, almost everything on the read path is cache-friendly.
Two reasonable choices for the index:
- Elasticsearch with a geo_point field. Gives geospatial filtering and full-text, category and numeric filters in one query, with built-in relevance scoring. This is the pragmatic production answer for Yelp because a search mixes “near me” with “sushi”, “open now” and ”$$“.
- A geohash/S2 index in Redis or Postgres for pure proximity, with filters applied afterward. Simpler, but you bolt text/filtering on separately.
We’ll use Elasticsearch as the primary search store (fed from the source-of-truth Postgres via change data capture), and keep Postgres as the authoritative record.
Deep Dive: Geospatial Search
The core question: how do you answer “businesses within 3 km of (lat, lng) matching these filters” quickly?
Encode each business’s location into a geohash (or S2 cell): nearby points share a string prefix, so “near me” becomes a prefix/range match. Because a point near a cell boundary has close neighbours in adjacent cells, you query the viewer’s cell plus its 8 neighbours, gather candidates, then compute the true great-circle (haversine) distance and drop anything outside the radius.
def search_nearby(lat, lng, radius_km, filters):
cells = geohash_neighbours(lat, lng, precision_for(radius_km)) # 9 cells
candidates = geo_index.query(cells, filters) # category, price, open_now
out = []
for b in candidates:
d = haversine(lat, lng, b.lat, b.lng)
if d <= radius_km * 1000:
out.append((b, d))
return rank(out, lat, lng)Choosing the geohash precision matters: too coarse and a cell holds too many candidates; too fine and you must scan too many neighbour cells for a large radius. Pick the precision from the requested radius. In Elasticsearch this is a geo_distance filter; the engine handles the cell math, and you add term filters for category/price and a range for open_now. (For the mechanics of geohash, S2 and quadtrees, see the Proximity Search lesson.)
Ranking. “Nearest” is rarely what users want — they want good and near. Blend signals:
score = (w_dist * proximity(distance) + # closer is better
w_rating * (rating_avg / 5.0) + # quality
w_pop * log_popularity(count) + # well-reviewed
w_text * text_relevance) # query matchWeights are tuned by experiment; distance and rating dominate for local search.
Deep Dive: Reviews and Ratings
Reviews are the data that makes Yelp Yelp, and they are overwhelmingly read. The mistake to avoid is computing AVG(score) over a business’s reviews on every page view — a business with 20,000 reviews would do enormous work for a number that barely changes.
Instead, precompute the aggregate and update it when a review is written:
def add_review(business_id, user_id, score, body):
review = reviews.insert(business_id, user_id, score, body)
# atomic running aggregate — no scan of all reviews
businesses.update(business_id, """
rating_sum = rating_sum + :score,
rating_count = rating_count + 1
-- rating_avg is derived: rating_sum / rating_count
""", score=score)
return reviewNow a read is a single row lookup for rating_avg and rating_count — O(1), and trivially cacheable. The update can run synchronously in the same transaction (simplest, strongly consistent) or asynchronously via an event (the rating lags by seconds, which is fine).
Trust and fake reviews. The aggregate is only as good as the reviews behind it. Defend it with: rate limiting per user, a spam/fraud model that filters or down-weights suspicious reviews before they count, requiring an account in good standing, and recomputing the aggregate from the filtered set periodically (a nightly job) so that removing fake reviews corrects the average.
Deep Dive: Serving Reads at Scale
With a 1000:1 read ratio, caching is the design.
- Cache search results for popular (area, filter) combinations for a short TTL (e.g. 30–60 s). A busy downtown block is searched constantly; recomputing every time is waste.
- Cache business detail + aggregate in Redis, invalidated when a review or edit changes it.
- CDN the static assets (photos) and even full business pages for anonymous traffic.
- Read replicas for Postgres absorb the detail lookups; the primary only takes writes.
Guard the hot-key case: a single viral business (a new restaurant everyone is checking) can hammer one cache key — replicate or locally cache it. And protect against a cache stampede on expiry with single-flight rebuilds (see the Scaling Pitfalls lesson).
Key Trade-offs
- Search store: Elasticsearch vs. a geohash index in Postgres/Redis. Elasticsearch answers “sushi, near me, open now, $$” in one query with built-in relevance. The cost is a second datastore to run, a CDC pipeline from Postgres to keep healthy, and search results that lag writes by a few seconds. A geohash column in Postgres (or a Redis geo set) is the simpler choice when searches are mostly pure proximity with one or two filters.
- Spatial cells: fixed-precision geohash + 8 neighbours vs. an adaptive quadtree/S2 covering. Geohash cells are trivial to compute and index, but density is uneven: a downtown cell can hold thousands of candidates while a rural one is empty, and precision has to be picked per radius. An adaptive structure keeps candidate counts even across dense and sparse areas, at the cost of more complex indexing (see the Proximity Search lesson).
- Ratings: precomputed aggregate vs.
AVG(score)on read. Storingrating_sumandrating_count(and derivingrating_avg) makes every read O(1), but turns each review into an update of one business row (a hot spot for a viral restaurant) and the aggregate goes out of date when reviews are later removed or flagged as fake, which is why we need the nightly recompute. Aggregating on read is only defensible for a small catalogue with few reviews per business. - Aggregate update: synchronous vs. asynchronous. Updating in the same transaction as the review insert is simple and strongly consistent, but serialises concurrent reviews on that row. Publishing an event and updating asynchronously absorbs bursts, at the cost of a rating that lags by seconds, which our eventual-consistency requirement already allows.
- Search-result caching: short TTL vs. always fresh. Caching popular (area, filter) results for 30–60 s removes most repeated work in busy areas, but a new listing, a rating change or an “open now” flip can be up to a TTL late. Skip the cache for personalised or rare queries, which rarely hit anyway.
- Pagination: cursors vs.
OFFSET. Cursors stay fast and stable at any depth, but users cannot jump straight to page 7, and a relevance-ranked cursor needs a stable tiebreaker (score plusbusiness_id).OFFSETis acceptable when results are shallow and nobody pages past the first few screens.
Key Takeaways
| Decision | Choice | Why |
|---|---|---|
| Geospatial search | Geohash/S2 cells or Elasticsearch geo_distance |
Cell + neighbours query, then exact-distance filter |
| Search engine | Elasticsearch (geo + text + filters) | One query for “sushi, near me, open, $$” |
| Source of truth | PostgreSQL → ES via CDC | Durable writes; search index stays in sync |
| Ratings | Precompute rating_sum + rating_count; derive the average |
O(1) reads; never aggregate on the fly |
| Read scale | Cache areas + details, replicas, CDN | 1000:1 read skew demands caching everywhere |
| Pagination | Cursor-based | Stable and fast at any depth |
Yelp is two problems in a trench coat: a geospatial search that narrows and ranks, and a read-heavy reviews store that lives or dies on precomputed aggregates and caching. Nail the “cell + neighbours then exact distance” query and the “precompute the rating, never scan on read” insight and you have the design.
