Design Yelp
Run this for someone else. You hold the answers; they do not. Read the prompt, keep the clock, and use the probes below when an answer is thin. Do not show them this page.
Open with this
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. Take a couple of minutes on requirements, then we will do some numbers, then the design. I will interrupt to keep us moving.
The clock
- 4 min: functional requirements and scope
- 4 min: non-functional requirements, with numbers
- 5 min: back-of-envelope estimates
- 16 min: high-level design and one or two flows
- 16 min: deep dives and the close
Move them on out loud when a section overruns. The commonest failure is spending twenty minutes on requirements and never reaching a deep dive, and preventing that is your job as much as theirs.
Requirements · 8 min
Listen for: a scoped set of capabilities, an explicit out-of-scope list, and numeric targets rather than adjectives. Prompt with “what are you not building?” if they never scope, and “what number would make that requirement real?” if they say “fast” or “highly available”.
Functional (6)
- 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 (6)
- 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.
Estimates · 5 min
Ask for two or three numbers, not all of them. What matters is whether they state assumptions, round sensibly, and say what the number implies. Push once with “where did that come from?”
The numbers (7)
- 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.
High-level design · 16 min
Let them draw. Interrupt only to ask what backs a component or what a box actually does. Then pick one flow below and ask them to walk it end to end.
Components (15)
- 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.
Flows to ask them to walk (5)
- 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.
- The app sends the location, query and filters.
- The search service normalises the query and checks the result cache for this area.
- On a miss, it queries the search index for documents in the covering cells that match the text and filters.
- Candidates are ranked by relevance, distance, rating and popularity, then the top page is hydrated.
- The app draws pins on map tiles and loads thumbnails from the CDN.
- Open a business page: A point read of the listing and a page of reviews, both partitioned so each is one lookup.
- The app requests the business details.
- It requests the first page of reviews, sorted by usefulness or recency.
- Photos load from the CDN.
- 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.
- A lunchtime search in Midtown matches thousands of businesses within a kilometre.
- The same query is repeated by thousands of nearby users; the cell-keyed cache absorbs it.
- A rural search finds nothing within the default radius, so the service widens it step by step.
- Index shards for dense metros get more replicas than rural ones.
- 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.
- The owner saves new opening hours.
- The committed change appears on the change stream.
- The indexer rebuilds the business’s document and upserts it.
- Cached results for affected cells expire within their short TTL.
- 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.
- A user submits a review.
- The review service runs spam and fraud checks and stores the review.
- A published review is sent to the rating aggregator.
- The aggregator writes the new count and adjusted rating to the business record.
- The change reaches the search index through the normal change stream.
Deep dives · 16 min
Pick two. Ask the headline question, let them answer, then use the follow-ups. The follow-ups are where the level gets decided, so leave time for at least three of them.
Indexing location
Ask: How do you find businesses near a point without scanning all 200 million?
Good answers name: A search engine with a native geo field (Elasticsearch / OpenSearch), sharded by region, Geohash prefixes in a key-value store or SQL index, In-memory quadtree or S2 cell index, PostGIS with an R-tree (GiST) index.
Our pick: 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"
Ask: "Open now" seems simple. Why is it one of the trickiest filters?
Good answers name: Pre-compute weekly time buckets in UTC per business and filter on the current bucket; verify at hydration, Evaluate hours in the search service after retrieval, Store a live "is_open" flag updated by a job every minute.
Our pick: 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
Ask: You have 500 candidate ramen places. Which 20 go on the first page?
Good answers name: Cheap pre-score in the index, then a learned ranker on the top few hundred, Sort by distance, Sort by average rating.
Our pick: 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
Ask: The database and the search index are two systems. How do you make sure the index never misses an update?
Good answers name: Change data capture from the database log into Kafka; idempotent, versioned upserts of whole documents, Dual writes from the application, Transactional outbox table polled by a relay.
Our pick: 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
Ask: How do you keep ratings accurate when businesses pay for fake reviews?
Good answers name: Classify each review on arrival, exclude suspect ones from the aggregate, use a confidence-adjusted rating, recompute periodically, Simple running average of all reviews, Manual moderation of every review.
Our pick: 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.
Close · 5 min
Ask what breaks first at ten times the load, and what they would build next. Then give them your read: one thing that was strong, one thing that was missing, one thing to practise. Be specific; “good job” helps nobody.