☰ Contents & jump-to
Common Problems · Scaling Reads

Designing Yelp

A walkthrough of building a local-business search & review service — from requirements to a fast, geospatial search engine — following the Hello Interview delivery framework.

Primary source: Hello Interview write-up (Evan King) Plus: 59-min video walkthrough Level: easy · asked at Meta, Amazon, Google, OpenAI
◆ The single idea to hold on to

Yelp is overwhelmingly read-heavy — people search and browse constantly, but rarely write a review (as much as a 1000:1 read:write ratio). The whole design flows from this: make search fast, keep writes simple, and resist adding infrastructure the scale doesn't justify. Demonstrating that restraint is what separates a staff-level answer from an over-engineered one.

1Understanding the Problem

▨ What is Yelp?

An online platform where users search for and review local businesses, restaurants, and services — e.g. "find all Italian restaurants near me."

Functional requirements — the features

These are the "users should be able to…" statements. The instructor limits scope to the three that matter, and pins down exactly what each means before moving on.

  1. Search for businesses by name, location (lat/long), and category.
  2. View a business and its reviews.
  3. Leave a review on a business — a mandatory 1–5 star rating plus optional text.

Out of scope (below the line): admins adding/editing businesses, viewing businesses on a map, and personalized recommendations.

✎ Interview tip

If the interviewer hands you the functional requirements, take them — but still ask a couple of clarifying questions. Real Yelp lets you search by menu items and much more; agree on what's out of scope so you don't drown. Here, search is limited to location, name, and category.

Scale — ask before you design

Before non-functional requirements, the instructor pins down scale by asking the interviewer. The agreed "Yelp-like scale" numbers are worth memorizing because every later trade-off leans on them:

▣ Numbers to know

10M businesses · 100M daily active users. Each business averages ~100 reviews at ~1 KB each ⇒ roughly 1 TB total. (Businesses alone: 10M × 1 KB = 10 GB.) These are close to real Yelp's ~7–8M claimed businesses.

Non-functional requirements — the qualities

These are the "-ilities" that make the system challenging. The trick is to name the unique ones, not generic ones — "everything should be low latency" is not interesting; identifying which operation is the latency risk is.

Availability over consistency (CAP)

Partition tolerance is a given, so the real choice is consistency vs. availability. The question to ask yourself: does every read need the very latest write? For Yelp, no.

✦ Instructor insight — why eventual consistency is fine

Imagine someone in Japan posts a review; you load the business a second later and don't see it yet — no harm. Or a business updates its description and you briefly see the old one — no harm. We'd rather show slightly stale data than an error or nothing. So we prioritize availability; eventual consistency is tolerable for businesses and reviews.

Low-latency search (< 500 ms)

Search is the operation that is naturally high-latency if not optimized, so this is the requirement that actually shapes the design. Target: < 500 ms.

◇ Latency yardsticks

~200 ms feels instant to humans; ~1 s starts to feel slow. 500 ms is a comfortable target for a search round-trip.

Scalability

The system must handle the 100M DAU / 10M businesses above. (Below the line: GDPR/data protection, fault tolerance, spam/abuse protection.)

The constraint

▣ Constraint the interviewer adds

Each user can leave only one review per business. Rationale: stop a single user (or a competitor) from spamming hundreds of 1-star reviews to tank a rating. Handled in the deep dives, alongside the non-functional requirements.

2Core Entities & the API

Core entities — the nouns

Start with a broad list of the primary entities (roughly the tables you'll persist and the data your API exchanges). Don't nail down every column yet — you'll have a clearer picture once you reach the high-level design.

  • Business — the most important entity: name, location, category, average rating.
  • Review — a rating (+ optional text) left by a user on a business.
  • User — searches, views, and (when authenticated) leaves reviews.
✎ Communicate the plan

Tell the interviewer: "I'll list the core entities now and flesh out the actual columns during the high-level design, when I understand them better." Outlining the full data model up front is also fine — do whichever you prefer, just say so.

The API — one endpoint per requirement

Walk the functional requirements one by one; each usually maps to an endpoint (sometimes two).

Search for businesses

// Search for businesses — GET because we're reading resources
GET /businesses?query&location&category&page  -> Business[]
✦ Why query params & pagination

Search filters (query, location, category) are optional, and optional inputs belong in query params, not the path. And any endpoint that can return a huge set — up to 10M businesses here — must paginate (e.g. page 1, 20 results), or you ship an enormous, slow payload over the network.

View a business + its reviews

The simple version returns both at once:

GET /businesses/:businessId  -> Business & Review[]

Acceptable, especially at mid-level. But it's not optimal — reviews may be numerous (needs its own pagination) and slower to load than the core business details. Splitting into two endpoints lets the client load them in parallel and lazy-load reviews under a skeleton loader:

GET /businesses/:businessId          -> Business
GET /businesses/:businessId/reviews?page=  -> Review[]
◇ Path vs. query params

:businessId is a path parameter — required; you can't fetch a business without one. Search filters were query params — optional.

Leave a review

// First POST endpoint — creates a new resource, nested under its business
POST /businesses/:businessId/reviews
{
  rating: number,   // 1–5, required
  text?: string     // optional
}
⚠ Watch out — don't trust a user ID in the body

This is the first authenticated endpoint (search/view stay open so anonymous users can browse). Auth travels in the header via a session token or JWT. You may include a userId in the body — that's not inherently wrong — but you must validate it server-side against the authenticated identity. Never trust the client-supplied ID on its own.

3High-Level Design

Goal of the high-level design: satisfy the functional requirements with the simplest architecture that works. Go endpoint by endpoint; layer depth later in the deep dives.

Clientweb / mobile
→
API Gatewayroutes + auth + SSL
→
Business Servicesearch & view (read-heavy)
Review Servicewrites (write-light)
→
Databasebusinesses + reviews

Two services behind one gateway, sharing a single database. Recreated from the source's high-level diagram.

3.1 · Satisfying each requirement

Search & view. The gateway forwards to the Business Service, which builds a query against the database. Both search and view are read-heavy and closely related, so they live in the same service. A naive search query looks like:

SELECT * FROM businesses
WHERE name LIKE '%coffee%'
  AND lat > ? AND lat < ? AND long > ? AND long < ?
  AND category = ?;
✦ When to split into a separate service

Two criteria: (a) is the functionality closely related (same team)? and (b) do the parts need to scale independently because of very different read/write patterns? Search and view share read patterns → keep together. Reviews are write-heavy and rare → the instructor gives them their own Review Service.

✎ Don't fake ignorance

If you already know search needs Elasticsearch/PostGIS, say so now — "I know this query is inefficient; I'll introduce a search-optimized store in the deep dive" — and move on. The linear build-up is to organize your thinking, not to pretend you don't know the answer. Just don't rat-hole for 20 minutes on search this early.

Leave a review. The Review Service writes to the database. The review model:

Review { id, userId, businessId (FK), rating 1–5, text? }

3.2 · One database, or one per service?

◇ A common question — "shouldn't every microservice own its DB?"

The instructor's answer: a "resounding maybe." Microservice purists insist on separation, but many real systems share a database and it's often the simpler, arguably correct choice.

The justification for sharing here:

  • Tiny data. ~1 TB fits comfortably on a single modern Postgres instance (which can handle 5–100+ TB) — no sharding needed.
  • Tight coupling. Reviews and businesses are frequently joined; one database avoids cross-service joins.

The counter-argument — fault isolation (if the review DB dies, search shouldn't die with it) — is valid, but here it's mitigated with simple replication / read replicas rather than a full split.

▣ Design principle

Bias toward simplicity unless you can articulate a clear benefit to added complexity. Trade-offs like this have no single right answer — what matters is the justification you give.

4Deep Dive · Average Rating in Search Results

Search results show a partial business — name, hours, and crucially the average rating, which is the first thing users look at. Computing that average on the fly for every search would be brutal. The deep dive walks three solutions, from bad to great.

BadCompute on the fly

SELECT b.business_id, b.name, b.location, b.category,
       AVG(r.rating) AS average_rating
FROM businesses b
JOIN reviews r ON b.business_id = r.business_id
GROUP BY b.business_id;

Simple, no extra infra — but it re-runs an expensive JOIN + aggregate on every search, even when no new reviews arrived. A popular business can have hundreds of thousands of ratings to aggregate. At our scale this kills read performance. The fix: do the aggregation on write, not on read.

GoodPrecompute with a cron job

A periodic job (hourly/daily) computes each business's average and writes it to an average_rating column on the businesses table. Now search just reads the column.

⚠ Watch out — staleness

A business with 2 reviews averaging 2.0 gets your fresh 5-star rating — but the average won't move for hours or a day. Confusing. Whether that's acceptable is a product decision to raise with the interviewer.

GreatSynchronous update + concurrency control

Update the average as each review arrives. Add a num_reviews column so the new average is a cheap incremental formula (a few CPU cycles):

▣ Incremental average formula
new_avg = (old_rating × num_reviews + new_rating) / (num_reviews + 1)

Wrap the "insert review + update business" in a single transaction (atomic — all or nothing). But two concurrent reviews can still clobber each other:

▷ The lost-update timeline

Business A: 100 reviews, avg 4.0. User 1 and User 2 both read that state. User 1 (5★) computes 4.01; User 2 (3★) computes 3.99. User 1 writes 4.01, then User 2 overwrites with 3.99. The correct answer accounting for both is 4.00 — User 1's review is lost.

Two fixes, both fine:

  • Pessimistic row lock — SELECT … FOR UPDATE the business row; the second transaction waits its turn. Ordering becomes strictly before/after.
  • Optimistic concurrency control (OCC) — re-check num_reviews before writing; if it changed, someone else wrote, so retry. No lock contention; ideal when concurrency is low (as it is here).
-- pessimistic, in one transaction
BEGIN;
  lock business row (business_id);
  INSERT review;
  UPDATE num_reviews, average;
COMMIT;  -- lock released
✦ Community debate — is OCC even needed in Postgres?

Several commenters argue the lost update can't happen with a single atomic UPDATE … SET avg = (avg*num + rating)/(num+1), num = num+1, because Postgres takes a row-level lock and re-evaluates the row on the second updater (Read Committed). Others counter that a race still exists depending on where the read happens. Evan King's response cuts through it: "in an interview the decision matters less than how you justify it." A common production alternative is an AFTER INSERT trigger or INSERT … ON CONFLICT upsert. Preserve this as an open, legitimately debated point.

⚠ The message-queue trap

Many mid/senior candidates add a queue: reviews → queue → worker recomputes the average. The instructor's pushback: what are you buffering for? Do the math — 10M businesses, ~100k writes/day ≈ 1 write/second; even a wild 1000× peak is ~1000 writes/s, which a single Postgres instance handles easily. The queue is unnecessary complexity. Recognizing this — that write throughput is tiny — is the hallmark of a staff candidate.

TakeawayMove aggregation from read-time to write-time; protect the counter with a lock or OCC; and don't reach for a queue when the write volume is one-per-second.

5Deep Dive · One Review per User per Business

BadApplication-level check

def leave_review(user_id, business_id):
    reviews = db.get_reviews_for_business(business_id)
    if user_id in reviews:
        return "User has already reviewed this business"
    else:
        db.leave_review(user_id, business_id)
⚠ Why the app-layer check is fragile

It only holds if every writer runs the check. As the company grows, another service, a different code path, or a data-engineering backfill (say, after an acquisition) writes reviews without knowing about your if statement — and the constraint silently breaks. It also has a race condition: two simultaneous submissions can both pass the check.

GreatEnforce it in the database

ALTER TABLE reviews
ADD CONSTRAINT unique_user_business UNIQUE (user_id, business_id);

Now a duplicate insert throws an error the app catches and returns to the client. Even the earlier race resolves cleanly: one writer wins, the second hits the unique-constraint error (as long as the data is on one instance — which it is).

▣ Principle — enforce constraints at the persistence layer

Whenever you have a hard data constraint, enforce it as close to the database as possible. This is the "C" (consistency/correctness) in ACID. It guarantees the invariant regardless of which application, path, or engineer does the write.

7Deep Dive · Searching by Location Name

◇ Staff-level bonus question

Only asked of staff candidates (or seniors who flew through everything else). Real Yelp lets you search "Italian in Santa Monica" — a named place, not a radius.

The key realization: a city or neighborhood isn't a circle — it's an irregular polygon (a neighborhood like "The Mission" in SF, not just a zip or city). Everything built so far handles circles/radii, which is now insufficient. You need two pieces:

  1. Location name → polygon. Add a locations table: name, type (city / neighborhood), and boundary (a list of lat/long points). Populate from public datasets like GeoJSON / Geoapify; index the name column.
  2. Polygon → businesses inside it. Both Elasticsearch and PostGIS support this natively — geo_shapes (PostGIS) / geo_points & geo_bounding_box (Elasticsearch).
{ "query": { "geo_bounding_box": { "location": {
    "top_left":     { "lat": 42, "lon": -72 },
    "bottom_right": { "lat": 40, "lon": -74 }
}}}}
▣ Optimization — precompute on write, not on read

Running an expensive polygon lookup on every search for "Santa Monica" is wasteful. Instead, when a business is created, compute which named areas contain it and store them as a list on the business — e.g. ["bay_area", "san_francisco", "mission_district"]. Search then becomes a cheap B-tree / keyword lookup on the location string.

{
  "id": "123",
  "name": "Pizza Place",
  "location_names": ["bay_area", "san_francisco", "mission_district"],
  "category": "restaurant"
}
✦ Why write-time precompute is safe here

Same pattern as the average rating: shift work from read to write. It's justified because locations barely change — a business stays in Santa Monica for years. If its address does change, a cron job recomputes that business's location set. Reads are frequent; recomputation is rare.

8What's Expected at Each Level

LevelWhat the interviewer wants
Mid-levelA working high-level design; reasonable answers on average rating and search optimization. Doesn't need to know DB constraints or index internals, but should problem-solve toward pushing the constraint to the persistence layer and apply the "correct" technologies.
SeniorNails most deep dives (except location-name search). Watched for over-engineering — wants strong justifications. Should know the index types and weigh technology trade-offs.
Staff+Recognizes key insights and derives simple solutions: Postgres extensions instead of Elasticsearch (avoids a new tech + the sync problem); tiny write throughput ⇒ no message queue; small data ⇒ replica/cache, no sharding. Can describe when the complex option would be needed, but argues why simple suffices here.
◆ The through-line across all levels

Seniority is shown less by adding components and more by doing the math, choosing the simple option, and justifying it — knowing that a queue, a separate DB, or Elasticsearch could be right, but explaining why they aren't necessary at this scale.

+A Note on Scaling

Pulling the scaling thread together (it recurs throughout the deep dives): 100M DAU is significant but not extreme. Standard moves apply — horizontally scale the stateless Business/Review services behind load balancers, add read replicas for the read-heavy load. Sharding isn't needed (real Yelp has far more data and would shard; our scoped version fits one instance — and saying so, with the math, is a "staff-y" move).

✎ Cache strategically

Don't naively cache search results — they key on the user's exact lat/long + radius, so hit rates are poor. Instead cache business details and reviews (hit via the view endpoint), keeping only the minimum fields in Elasticsearch for search. That's what actually helps read scalability.

★Active Recall

Reconstruct the answers before expanding. If you can't, revisit the linked section.

Why does Yelp choose availability over consistency? Give a concrete example.

Reads don't need the latest write. If a review or a business-description update takes a few seconds to appear (e.g. a review posted in Japan, or an edited description), no one is harmed. Showing slightly stale data beats showing an error or nothing — so we prioritize availability with eventual consistency. (§1)

A B-tree index on (latitude, longitude) exists. Why is geographic search still slow?

B-trees have no spatial awareness — they treat lat and long as independent 1-D values. Each range query returns a huge strip; you must intersect two enormous strips to isolate the small target box. You need a geospatial index (geohash/quadtree/R-tree) instead. (§6.1)

Name the three filters in Yelp search and the index each requires.

Location → geospatial index (geohash/quadtree/R-tree); Name → inverted index (full-text); Category → B-tree (exact match). Elasticsearch or Postgres+PostGIS+pg_trgm can serve all three. (§6.1–6.3)

You introduced Elasticsearch. What new problem appears, and how do you solve it?

Now two stores must stay consistent, and Elasticsearch shouldn't be the primary DB (weak on ACID/durability). Keep Postgres as source of truth and sync changes into Elasticsearch via Change Data Capture (CDC): DB change events → stream (Kafka) → worker → Elasticsearch. (§6.2)

When would you pick Postgres extensions over Elasticsearch?

When scale is modest (here ~1 TB). PostGIS + pg_trgm give geospatial + full-text in one store, avoiding a new technology, the CDC sync pipeline, and the operational burden. Elasticsearch earns its place mainly at high scale / heavy full-text. (§6.3)

Why is a message queue for updating average ratings usually unnecessary here?

Do the math: ~100k writes/day ≈ 1 write/second; even a 1000× peak is ~1000/s, which a single Postgres instance handles. There's nothing to buffer — the queue adds complexity without benefit. Spotting this signals staff-level judgment. (§4)

Two reviews for the same business arrive at once. What breaks, and what are the two fixes?

Lost update: both read the same num_reviews/avg, compute new averages, and the second write overwrites the first. Fix with (a) a pessimistic row lock (SELECT … FOR UPDATE), or (b) optimistic concurrency control — recheck num_reviews before writing and retry if it changed. (§4)

Why enforce "one review per user per business" in the database, not the app?

An app-layer check only holds if every writer runs it — future services, code paths, or data backfills will bypass it, and it has a race condition. A DB UNIQUE(user_id, business_id) constraint guarantees the invariant no matter who writes, and cleanly resolves the race (one winner, the other errors). (§5)

How do you support searching by a place name like "Santa Monica"? Why precompute?

A place is an irregular polygon, not a radius. Store name→polygon in a locations table (from GeoJSON); use geo_shape/geo_bounding_box to find businesses inside. Then precompute each business's containing areas at creation time (a location_names list) so search is a cheap keyword lookup — justified because locations rarely change. (§7)

What is second-pass filtering and when is it needed?

When hand-rolling geospatial search, the index returns a coarse square. Second-pass filtering discards the corners outside the true circular radius using the Haversine formula. Sequence filters to cut the search space fastest — usually distance first, then name/category. (§6.4)

↺Quick Revision

Must remember

  • Read:write ≈ 1000:1 → optimize reads, keep writes simple.
  • Scale: 10M businesses, 100M DAU, ~1 TB.
  • Search target: < 500 ms.
  • Availability > consistency; eventual is fine.
  • Constraint: 1 review / user / business.

Rules & formulas

  • Incremental avg: (old·n + new)/(n+1).
  • Enforce constraints at the persistence layer (UNIQUE).
  • Aggregate on write, not on read.
  • Bias to simplicity unless complexity is justified.

Three filters → three indexes

  • Location → geospatial (geohash/quadtree/R-tree)
  • Name → inverted (full-text)
  • Category → B-tree (exact)

Common mistakes

  • B-tree on lat/long → strip-intersection, still slow.
  • Elasticsearch as primary DB → durability risk.
  • Message queue for 1 write/sec → needless.
  • App-layer uniqueness check → bypassable + racy.
  • Caching raw search results → poor hit rate.

Solution ladder (bad → great)

  • Avg rating: on-the-fly JOIN → cron precompute → sync update + lock/OCC.
  • Search: full scan → B-tree (fails) → Elasticsearch + CDC / Postgres extensions → hand-rolled quadtree + second-pass filter.
  • One review: app check → DB unique constraint.
  • Place names: polygon lookup per request → precompute containing areas on write.
The one line to leave withAt Yelp's scoped scale, the right architecture is deliberately boring: Postgres (with extensions), read replicas, synchronous rating updates, DB-enforced constraints — and the skill is justifying why the fancy pieces aren't needed.

Study notes compiled from the Hello Interview "Design Yelp" breakdown (primary) and its video walkthrough (instructor commentary). Community-debated points (e.g. whether optimistic locking is strictly necessary in Postgres) are flagged as open, not resolved.