SysDesignPrep.com
System design interview question

Design Yelp

Find the best places near you, filtered by what is open now, from 200 M businesses, in under 200 ms, and keep ratings honest as reviews arrive.

Last updated 2026-09-30. Difficulty: medium. Patterns: geospatial-index, search, ranking, change-data-capture, aggregation. Reported at Yelp, Google, Uber, Meta, Amazon.

Walk through a strong candidate's answer, turn by turn.

The interviewer asks, the candidate answers and draws, and you press Next. Pause to answer yourself at the key decisions, and ask the AI Mentor anything along the way.

Functional requirements

  • Search nearby. Given a location and a radius or map viewport, find businesses matching a query ("ramen") and filters (open now, price, rating), ranked.
  • Business page. Details, hours, photos and reviews, paginated.
  • Write a review. A star rating and text. The business’s average rating and count update within minutes.
  • Business owners edit their listing. Hours, address, categories. Changes show in search within a few minutes.
  • Add a new business. Submitted by owners or users, moderated, then searchable.
  • Out of scope. Reservations and ordering, the ads marketplace, the recommendation feed, and map tile rendering (use a provider).

Non-functional requirements

  • Search latency (p99 < 200 ms). This is the requirement that drives the hardest trade-off: geo filtering, text matching, attribute filters and ranking all happen inside that budget, so the index must answer "near here" without scanning.
  • Scale (100 M DAU · ~6 k searches/s avg). Reads dominate: thousands of searches for every listing change or review.
  • Freshness (listing edits searchable in < 5 min). Eventual consistency is fine; a closed restaurant showing as open for an hour is not.
  • Availability (99.9 %). Search degrades gracefully: drop personalisation or exact counts before failing.
  • Rating integrity. Fake and paid reviews are a constant attack. Ratings must resist manipulation, and changes must be explainable.
  • Global coverage. Dense cities (thousands of cafés per square kilometre) and empty countryside (none in 20 km) must both return useful results.

Back-of-envelope estimates

  • Searches per second: ~6 k avg · ~18 k peak. 100 M DAU × 5 searches a day = 500 M/day ÷ 86 400 ≈ 5.8 k/s; × 3 at lunch and dinner peaks ≈ 18 k/s.
  • Business records: ~400 GB. 200 M businesses × ~2 KB (name, address, hours, categories, attributes) = 400 GB. Comfortable for a sharded relational database.
  • Pure geo index size: ~3 GB. 200 M × (8-byte cell id + 8-byte business id) = 3.2 GB. The location index alone fits in the memory of one server, so it can be replicated rather than sharded.
  • Search index with text and filters: ~1 TB. Adding names, categories, attributes and review snippets for text matching and filtering grows each document to ~5 KB: 200 M × 5 KB = 1 TB. That does not fit one node, so the full search index is sharded, by region.
  • Review writes per second: ~25. 2 M reviews a day ÷ 86 400 ≈ 23/s. Trivial for storage; the work is spam detection and keeping the rating aggregate right.
  • Review storage: ~2 TB. 2 B reviews × ~1 KB = 2 TB, plus photos in object storage. Partitioned by business, because reviews are always read by business.
  • Results within 1 km: from 0 to 10 k+. Midtown Manhattan has 10 k+ businesses within 1 km; a rural town may have 0 within 10 km. A fixed radius gives either a firehose or nothing, so the search must adapt its area to density.

Components

  • User app: Sends the location (GPS or map viewport), query and filters; renders results on a map whose tiles come from a map provider, and photos from the CDN.
  • Owner portal: Where business owners edit hours, address, categories and photos, and reply to reviews.
  • API gateway: Authentication, rate limiting (scrapers love directory data) and routing to search, business and review services.
  • Search service: Turns location and query into an index query (geo cells to cover, text, filters), adapts the radius to density, ranks candidates, and hydrates the top results.
  • Result cache (Redis · key = cell + normalised query): Caches results per (geo cell, normalised query, filters) for a few minutes. Popular searches in busy areas ("coffee" downtown at 8 a.m.) hit it constantly.
  • Search index (Elasticsearch · geo + text · sharded by region): One document per business with location, name, categories, attributes, hours and rating. Sharded by geographic region, replicated for read throughput. Answers geo, text and filter conditions in one query.
  • Business service: The source of truth for listings: create, edit, moderate and read business details. Serves business pages and hydrates search results.
  • Business DB (Postgres · sharded by region): Listings, hours (with time zone), categories and owners. About 400 GB. Writes are rare; every write flows to the index through change data capture.
  • Change stream (CDC → Kafka): Captures every committed change to listings and rating aggregates from the database log, so the index can never miss an update the database accepted.
  • Indexer: Consumes the change stream, builds the search document (computing the geo cell, denormalising the rating), and upserts it. Can also rebuild the whole index from a snapshot plus the stream.
  • Review service: Accepts reviews, runs spam and fraud checks, serves review pages by business, and records each published review as an event for the rating aggregator.
  • Review store (partitioned by business_id): Reviews keyed by business and sorted by time and by usefulness, about 2 TB. Always read by business, so partitioning by business keeps a page of reviews on one partition.
  • Rating aggregator: Maintains each business’s review count and adjusted average from published reviews, and writes it back to the business record, from where it reaches the index.
  • Map tiles (map provider): Base map tiles drawn under the result pins, from a provider such as Mapbox or Google Maps. Not built here.
  • Photos + CDN: Business and review photos in object storage with CDN delivery and immutable resized variants.

User flows

  1. Search: ramen near me, open now. The read path that runs thousands of times a second. The geo index narrows 200 M businesses to a few hundred candidates without scanning, then text, filters and ranking finish the job.
    1. The app sends the location, query and filters.
    2. The search service normalises the query and checks the result cache for this area. The location is snapped to a geo cell (about 600 m wide at this level) so that nearby users share cache entries. "Ramen", "ramen restaurant" and "RAMEN" normalise to one key. Open-now results are cached for at most a minute.
    3. On a miss, it queries the search index for documents in the covering cells that match the text and filters. A geo-distance filter (or a set of covering cells) cuts 200 M businesses to the few thousand nearby; text and attribute filters cut further. "Open now" is evaluated from structured hours in the business’s own time zone. If fewer than ~20 results come back, the radius doubles and the query repeats.
    4. Candidates are ranked by relevance, distance, rating and popularity, then the top page is hydrated. Ranking blends text match, distance decay, a confidence-adjusted rating and click-through history. Hydration fetches fresh details (current hours, photo) for 20 ids from the business service’s cache, so a stale index field is corrected before display.
    5. The app draws pins on map tiles and loads thumbnails from the CDN. Tiles and photos come from their own CDNs in parallel with the result JSON, so the API response stays small.
  2. Open a business page. A point read of the listing and a page of reviews, both partitioned so each is one lookup.
    1. The app requests the business details.
    2. It requests the first page of reviews, sorted by usefulness or recency. Reviews are partitioned by business id and kept in two sort orders, so either page is a single range read. Hidden (filtered) reviews are excluded here but remain visible to their authors.
    3. Photos load from the CDN.
  3. Manhattan at lunch versus an empty county. The scale-breaking case is uneven density: the same radius returns ten thousand results downtown and none in the countryside, and the downtown cells are the hottest keys in the cache.
    1. A lunchtime search in Midtown matches thousands of businesses within a kilometre. Returning or even ranking 10 k candidates blows the latency budget. The index query caps candidates per shard (say 500) using a cheap pre-score (rating and distance), and the ranker only sees those.
    2. The same query is repeated by thousands of nearby users; the cell-keyed cache absorbs it. Snapping to cells is what makes this work: users a block apart share an entry. Downtown cells at noon have hit rates above 90 %. Hot keys are replicated across cache nodes.
    3. A rural search finds nothing within the default radius, so the service widens it step by step. Doubling from 1.5 km to 3, 6, 12 km until enough results are found. Most rural searches finish within two or three expansions. Results show the distance prominently so "12 km away" is not a surprise.
    4. Index shards for dense metros get more replicas than rural ones. Shards are by region, so New York’s shard takes far more queries than Montana’s. Replica counts follow query load per shard, not data size.
  4. An owner changes their hours. The database is the source of truth and the index follows it through change data capture, so search catches up within seconds and can never silently miss an update.
    1. The owner saves new opening hours.
    2. The committed change appears on the change stream. CDC reads the database’s write-ahead log, so only committed changes are emitted, in commit order, and none can be lost between "write the database" and "update the index". That is the bug dual writes from application code produce.
    3. The indexer rebuilds the business’s document and upserts it. The document is recomputed from the full current row, not patched, so an out-of-order or replayed event cannot leave it half-updated. The row version is used as the external document version, so an older event never overwrites a newer document.
    4. Cached results for affected cells expire within their short TTL. Explicitly invalidating every cached query that might include this business is not worth it; the cache TTL (a few minutes) is inside the freshness target. Hydration at query time reads fresh hours anyway, so the result card is correct even if ranking used the old ones.
  5. Write a review and update the rating. The write is small and rare; what matters is filtering fraud and keeping the aggregate right without locking the business row on every review.
    1. A user submits a review.
    2. The review service runs spam and fraud checks and stores the review. Signals include account age and history, whether the device or network has posted many reviews for the same business, text similarity to other reviews, and whether the user was ever near the place. Suspect reviews are stored but marked "not recommended" and excluded from the rating.
    3. A published review is sent to the rating aggregator. The aggregator keeps (sum of stars, count) per business, updated incrementally, and recomputes from the review store periodically to correct any drift from replays or reclassified reviews.
    4. The aggregator writes the new count and adjusted rating to the business record. One write per business per short batch window, not per review. The adjusted rating pulls businesses with few reviews toward the regional average, so one five-star review does not put a new place at the top.
    5. The change reaches the search index through the normal change stream. Rating is just another field on the business, so it uses the same CDC path as an hours change. No special pipeline to keep consistent.

Deep dives

Indexing location

How do you find businesses near a point without scanning all 200 million?

A query like "within 1.5 km of this point" cannot use an ordinary index on latitude and longitude separately: a B-tree on latitude returns a band around the whole planet, and intersecting it with a longitude band still scans millions of rows. The data needs an index that understands two dimensions.

Businesses barely move, which makes this much easier than tracking drivers (see Design Uber). The index is built once and updated rarely, so the choice is about query shape and density, not write throughput. And the pure location index is only about 3 GB, so it fits in memory.

  • A search engine with a native geo field (Elasticsearch / OpenSearch), sharded by region chosen
  • Geohash prefixes in a key-value store or SQL index situational: a pure "what is near me" service with no text search
  • In-memory quadtree or S2 cell index situational: a dedicated location service feeding a ranker, at very high query rates
  • PostGIS with an R-tree (GiST) index situational: smaller catalogs, or as the source of truth beside a search index

The answer: Store each business in a search index with a geo_point field, its text fields, attributes and pre-computed hours buckets, sharded by large geographic region and replicated by query load. A search is a single bool query: a geo-distance (or viewport) filter, text match, attribute filters, and a cap on candidates per shard. The radius expands when results are sparse. The relational database remains the source of truth and feeds the index through CDC. The key point to make: the geo part is small enough to be cheap; what costs is combining it with text and ranking, which is why one engine that does all three wins.

Explain geohash, and why you need neighbouring cells.

A geohash interleaves the bits of latitude and longitude and encodes them in base 32, so a longer shared prefix means a smaller shared square. Searching only the user’s own cell misses places just across the boundary, a few metres away. So you query the cell plus its eight neighbours, then filter by true distance.

Why shard by region rather than by business id?

A query is almost always local. Sharding by region sends it to one or two shards; sharding by id would send every query to every shard. Region shards are uneven in size and load, which is handled with per-shard replica counts.

A user drags the map along the boundary between two shards. What happens?

The viewport overlaps both regions, so the query goes to both shards and results are merged. The routing layer knows each shard’s bounding area. This is the uncommon case; most viewports sit inside one region.

How would you serve 10x the search traffic?

Searches are reads, so add replicas to the busiest region shards and grow the result cache; neither the index size nor the write rate changes. If one metro shard gets too hot even with replicas, split that region into smaller shards. Ranking cost grows with traffic too, so keep the per-shard candidate cap tight.

Filters like "open now"

"Open now" seems simple. Why is it one of the trickiest filters?

Hours are stored per business in its own time zone, with split shifts, overnight hours (open until 2 a.m. means the next day), holidays and temporary closures. "Open now" depends on the current time in each business’s zone, which a static index field cannot hold.

Evaluating hours in code for every candidate is too slow when there are thousands of candidates, and pushing raw hours into the index does not let the engine filter on them.

  • Pre-compute weekly time buckets in UTC per business and filter on the current bucket; verify at hydration chosen
  • Evaluate hours in the search service after retrieval situational: as the final verification on the top results only
  • Store a live "is_open" flag updated by a job every minute rejected

The answer: When a listing changes (and when daylight saving shifts), the indexer converts the business’s weekly hours into a set of 15-minute bucket ids in UTC ("mon_1230") and stores them as a multi-value field. A query for "open now" filters on the bucket containing the current time, which the engine does as cheaply as any category filter. Special hours and temporary closures are checked exactly at hydration for the top results, using the business service’s fresh data, so a holiday closure never shows as open.

A bar is open from 8 p.m. to 2 a.m. How do buckets handle it?

Converting to weekly UTC buckets removes the problem: Friday 8 p.m. to Saturday 2 a.m. is just a range of buckets that crosses a day boundary. The query asks for the current bucket and does not care which local day it belongs to.

How would you support "open at 7 p.m. tomorrow"?

The same field works: compute the UTC bucket for that local time in the user’s zone and filter on it. Because the buckets are absolute weekly UTC slots, any future time within the week is just a different term.

How do you know the open-now filter is right?

Sample businesses continuously and compare the bucket answer against an exact evaluation of their hours for the current time, per time zone. Also track user reports of "closed when shown open" by category. A spike after a daylight saving change means the bucket recompute was missed somewhere.

What happens to a business with no hours listed?

It cannot match an open-now filter, so it disappears from those results, which hurts small businesses with incomplete listings. Show them in a lower section labelled "hours unknown", and nudge owners to add hours, rather than silently treating unknown as open or closed.

Ranking nearby results

You have 500 candidate ramen places. Which 20 go on the first page?

Distance alone puts a mediocre place next door above an excellent one 300 m away. Raw average rating puts a place with one five-star review above one with 4.6 stars from 2,000 reviews. Relevance alone ignores both.

The ranking must blend several signals, be cheap enough to run on hundreds of candidates in a few milliseconds, and be robust to manipulation.

  • Cheap pre-score in the index, then a learned ranker on the top few hundred chosen
  • Sort by distance rejected
  • Sort by average rating rejected

The answer: Inside the index, a function score combines text relevance, a distance decay whose scale matches the search radius, and a confidence-adjusted rating (a Bayesian average that pulls few-review businesses toward the local mean), and each shard returns its top few hundred. The search service then applies a lightweight learned model using those features plus click-through and conversion history, freshness and personal signals when available. Sponsored placements are kept separate and labelled. Offline, ranking changes are evaluated against held-out sessions before an A/B test.

What is a Bayesian average and why use it?

It is the average of a business’s reviews blended with a prior (the local average) weighted as if it were a fixed number of reviews: (C × m + sum of stars) ÷ (C + n). With few reviews the result stays near the local mean; with many it converges to the true average. It stops a single five-star review from beating a well-established 4.6.

How would you measure whether a ranking change is better?

Online: click-through on the first results, calls and direction requests (strong signals of intent), and how often users refine their search (a sign the results missed). Offline: replay logged sessions and check whether the businesses users eventually chose rank higher. Ship behind an A/B test.

How do you stop a business gaming the ranking with fake clicks?

Click signals are aggregated per business with bot filtering, capped in how much they can move the score, and weighted toward stronger actions (calls, directions) that are harder to fake at scale. Sudden jumps in engagement for one business are flagged the same way review bursts are.

Should results be personalised?

Lightly. Past categories and price levels a user prefers make good tie-breakers, but heavy personalisation makes results hard to explain and debug, and logged-out users must still get good results. Keep personal signals as features in the second-stage ranker, never as filters.

Keeping the index in sync

The database and the search index are two systems. How do you make sure the index never misses an update?

The tempting design is for the business service to write the database and then update the index. If the process crashes between the two, or the index call fails and the retry is lost, the index is silently wrong until someone notices: a moved restaurant shows at its old address indefinitely.

The database already has an ordered, durable log of every committed change: its write-ahead log. Reading changes from there makes the index a follower of the database rather than a second write the application must remember.

  • Change data capture from the database log into Kafka; idempotent, versioned upserts of whole documents chosen
  • Dual writes from the application rejected
  • Transactional outbox table polled by a relay situational: databases or hosting where reading the write-ahead log is not allowed

The answer: Debezium-style CDC reads the business database’s log and publishes each committed row change to Kafka keyed by business id, so changes to one business stay in order. The indexer rebuilds the full search document from the current row (not a patch) and writes it with the row version as an external version, so a replayed or out-of-order event can never overwrite newer data. A nightly job compares counts and checksums between the database and the index per region, and a full rebuild from a snapshot plus the stream takes a few hours and runs into a fresh index that is swapped in with an alias.

The indexer is down for an hour. What happens?

Changes accumulate in Kafka, which retains them for days. When the indexer returns it catches up from its last committed offset. Search is up to an hour stale meanwhile; hydration still shows current details for the top results, which limits the damage.

You need to change how documents are built. How do you reindex 200 M businesses without downtime?

Build a new index in parallel from a database snapshot, then replay the change stream from the snapshot’s position until it catches up. Point a read alias at the new index once checks pass, and keep the old one for quick rollback.

The CDC connector itself crashes and loses its position. Now what?

It resumes from the last offset it committed to Kafka, and the database retains enough log (a replication slot) to cover the gap. If the log was already recycled, take a fresh snapshot and rebuild. Monitor replication slot lag, because a stuck slot can also fill the database disk.

Why rebuild the whole document instead of patching the changed field?

Because events can arrive twice or out of order, and a patch applied to the wrong base state produces a document that never existed. Rebuilding from the current row and writing it with the row's version is idempotent: replaying any event, any number of times, converges to the same document.

Ratings people can trust

How do you keep ratings accurate when businesses pay for fake reviews?

Fake reviews are an industry: batches of five-star reviews bought for a business, or one-star attacks on a competitor. If the rating moves with every review instantly and equally, it is trivially gamed.

Detection is never perfect, so the system must make individual fake reviews matter little, catch coordinated patterns, and be able to recompute ratings when it learns more.

  • Classify each review on arrival, exclude suspect ones from the aggregate, use a confidence-adjusted rating, recompute periodically chosen
  • Simple running average of all reviews rejected
  • Manual moderation of every review rejected

The answer: Each review is scored by a fraud model (account age and history, device and network fingerprints, bursts of reviews for one business, text similarity, whether the reviewer was ever nearby). Low-trust reviews are stored and shown as "not currently recommended", excluded from the rating and the default sort. The aggregator maintains sum and count incrementally for speed, and recomputes each business’s rating from the review store nightly, applying the latest classifications. The displayed rating is the Bayesian-adjusted average, so bursts move it slowly. Yelp’s "recommendation software" works along these lines and is public about it.

A business suddenly gets 200 five-star reviews in a day. What happens?

The burst itself is a strong signal: reviews per business per day far above its normal rate trigger closer scoring, and often a public notice on the page. The adjusted rating moves slowly anyway, and if the reviews are later judged fake, the nightly recompute removes their effect completely.

Why recompute from scratch instead of trusting the incremental counter?

Because classifications change after the fact, events get replayed, and bugs happen. Incremental updates are fast but drift; a periodic recompute from the source of truth bounds the drift and makes every rating explainable from the reviews it includes.

A business owner disputes their rating. How do you explain it?

Because the rating is recomputed from the source of truth, you can list exactly which reviews are included, which were excluded as not recommended, and the prior used in the adjusted average. Explainability is a reason to prefer a recomputable aggregate over an opaque running counter.

Should a review's weight decay with age?

Often yes: a restaurant that changed chefs three years ago is a different place. A mild time decay in the aggregate (or showing a recent-rating figure beside the all-time one) keeps ratings current without throwing away history.

The theory behind it

  • Geospatial indexing and proximity search: Geohash, S2 and quadtrees, nearest-neighbour and radius queries, indexing moving objects, map tiles, and the accuracy and hot-cell problems that come with each.
  • Search, indexing and autocomplete: Inverted indexes, analysis and ranking, prefix search with tries, filtering by permissions, index freshness, and when vector search actually belongs in the answer.
  • Caching: Where to cache (browser, CDN, application, database), cache-aside vs write-through vs write-back, eviction policies, invalidation, hot keys, thundering herds and cache stampedes.

Related

Open in your browser to sign in

Google does not allow sign-in inside this app's built-in browser. Open this page in Safari and sign in there. The link opens this same page.

Tap the ⋯ or share button at the top or bottom of the screen, then Open in browser. Or copy the link and paste it into Safari.