17. Yelp / Nearby Places Search Intermediate → Advanced + Geospatial Search
A. Realistic Interview Prompt
On the surface, this looks like the exact same problem Ride-Sharing (P5) already solved: "find things near this point" is, again, a query no plain B-tree index answers efficiently, and a geospatial index is, again, the tool that fixes it. The load-bearing difference — easy to miss, and exactly what separates a candidate who's memorized "use a geohash" from one who actually understands the trade-off — is what's being indexed doesn't move. A restaurant's coordinates change essentially never; a driver's coordinates change every few seconds. That single fact reopens every design decision P5 already made and lands in a genuinely different place: the index here can be precomputed and cached aggressively rather than updated continuously, because there's nothing continuously changing underneath it. The second, equally load-bearing idea is that "nearby" is only half the query — a real search also has to intersect proximity with attribute filters (cuisine, price, open-now) and produce a ranked list, not just an unordered set of nearby dots.
B. Difficulty
Intermediate to state, Advanced to reason about correctly. A candidate who's already internalized Ride-Sharing's geospatial-index lesson can restate "use a geohash or S2 grid" almost immediately — that part is comparatively easy relative to P5. What actually separates a strong answer here is recognizing that the consequences of that same tool are completely different once the underlying data is static: caching strategies, update frequency, and staleness tolerance all get to be far more aggressive than they were allowed to be for a moving fleet of drivers, and a candidate who reflexively imports P5's real-time-update machinery wholesale (treating this as "Uber but with buildings") is missing the actual point of the problem.
C. What This Problem Tests
- Whether a candidate can correctly re-derive the choice of a specialized geospatial index (geohash/S2/quadtree, Prerequisites N) without simply repeating Ride-Sharing's answer verbatim, including reasoning about why it's the right tool independent of update frequency
- The sharper, higher-signal judgment of recognizing that static points fundamentally change the geospatial trade-off versus a moving-object system — that precomputation and heavy caching become viable exactly because the data underneath rarely changes, an explicit, direct contrast against Ride-Sharing's real-time driver index (P5-M, P5-N)
- Comfort combining two independent filtering dimensions — a spatial candidate set and an attribute/metadata filter — and reasoning about where that intersection should actually happen for it to stay cheap
- Designing a multi-signal ranking function (distance, rating, popularity, sponsorship) rather than treating "nearest first" as if it were the entire ranking problem
- Judgment about how fresh a search index actually needs to be for a domain that tolerates far more staleness than most of this document's other systems — recognizing that "eventually" can genuinely mean hours here, not seconds, without it being a design flaw
D. Requirements
Functional
- A user can search for businesses near a given location (or their current location), optionally with a text query (e.g. "coffee")
- A user can filter search results by category/cuisine, price tier, and whether a business is open at the current moment
- Search results are ranked by relevance, not returned as an unordered or purely distance-sorted list
- A user can view a business's detail page (hours, address, photos, aggregate rating) and its reviews
- A user can submit a review (rating + text) for a business
Non-Functional
- Low search latency — a search request should return ranked results in well under a second; this is a read-heavy, latency-sensitive consumer product surface
- Very high read:write ratio — searches vastly outnumber business updates and even review submissions; the design should be optimized overwhelmingly for fast reads
- High availability for search — a business search returning nothing, or timing out, is a direct, visible product failure; a review taking a few extra seconds to appear is not
- Tolerant of staleness — a business's hours, or a newly-opened competitor, showing up in search results a few hours late causes negligible real harm, a materially looser bar than most of this document's other read paths
- Scales to a large, mostly-static point set — tens of millions of businesses, a dataset whose size is large but whose churn rate (opens/closes/moves per day) is tiny relative to its total size
Out of scope: the reservation/waitlist system some listings offer (a separate booking sub-system entirely), payments for any paid placement itself (the mechanics of sponsored ranking are in scope at a conceptual level, P, but not an ad-auction engine), photo/image processing pipelines beyond storing and serving uploaded photos.
E. Clarifying Questions
| # | Question | Why It Matters | Design Decision Affected |
|---|---|---|---|
| 1 | Do businesses ever change location, or is a listed address effectively permanent once created? | Confirms the entire index can be built around near-static points rather than continuously-moving ones | Deep Dive 1 & 2 (M, N) |
| 2 | How fresh does a search result need to be after a business updates its hours, or a brand-new business is added? | Determines whether the index is rebuilt in near-real-time or on a batch/async schedule | Deep Dive 7 (S) |
| 3 | Should ranking blend in anything beyond distance and rating — sponsored placements, personalization, popularity trends? | Determines how many independent signals the ranking function actually needs to combine | Deep Dive 4 (P) |
| 4 | Does a new review need to affect a business's displayed aggregate rating instantly, or is a short delay acceptable? | Determines whether rating recomputation is synchronous per-review or an async, batched job | Deep Dive 6 (R) |
| 5 | Are search filters (cuisine, price, open-now) combined with free-text search, or is this purely structured/faceted search? | Affects whether a general-purpose search engine (Elasticsearch-style) or a simpler structured index is sufficient | Data Model (K), Deep Dive 3 (O) |
F. Assumptions
- Confirmed: search-nearby-with-filters, ranked results, business detail pages, and review submission as the core functional loop.
- Assumption: business locations are effectively static — a relocation is a rare, manual, low-frequency event (a business owner editing an address), not a continuous stream (E's clarifying question 1) — this is the single assumption the entire design is built around, and the one that most sharply distinguishes this problem from Ride-Sharing (P5).
- Assumption: the search index can tolerate staleness on the order of minutes to low hours for business metadata changes, and does not need to be near-real-time the way Uber's driver-location index does (E's clarifying question 2) — this licenses batch/async reindexing as the primary freshness mechanism (S) rather than a continuous-update pipeline.
- Assumption: ranking blends distance, rating, popularity, and sponsored placement (E's clarifying question 3) — the more interesting, more commonly-discussed version of this problem, rather than a pure nearest-first sort.
- Assumption: an aggregate rating is allowed to lag a freshly-submitted review by a short, bounded delay, recomputed asynchronously rather than on every single write (E's clarifying question 4).
- Assumption: free-text search combined with structured filters is in scope (E's clarifying question 5) — this is what makes a general-purpose search engine (rather than a bespoke geospatial-only index) the right foundation, per K.
G. Capacity Estimation
| Metric | Value / Calculation | Why It Matters |
|---|---|---|
| Total indexed businesses | ~50M (illustrative, in the range Yelp itself has publicly cited for its total listings) | The size of the static point set the geospatial index is built over — large in absolute terms, but the key insight is this number changes very slowly (below) |
| Business churn rate (opens/closes/edits per day) | Illustratively ~0.1-0.5% of listings per day ≈ 50,000-250,000 changes/day ≈ well under 3/sec average | The actual write rate the geospatial and metadata index has to absorb is tiny relative to the read volume — the opposite ratio from Ride-Sharing's ~1.25M location-updates/sec (P5-G) |
| Searches/day, platform-wide | Illustratively ~500M searches/day → 500×10⁶ / 86,400 ≈ ~5,800/sec average, several times that at peak (evenings, weekends) | The dominant load in this system by orders of magnitude — this is a read-heavy problem, and every design decision downstream optimizes for this number, not the write rate |
| Read:write ratio (searches : business/review writes) | Roughly 5,800 : ~3 for business metadata alone, and still overwhelmingly read-dominated even including review writes (below) — on the order of 1,000:1 or higher | Confirms this is a caching and read-scaling problem first, not an ingestion-throughput problem the way Ad Click Aggregation (P16) or Ride-Sharing (P5) are |
| Reviews submitted/day, platform-wide | Illustratively ~2M/day → ~23/sec average, spiking higher around meal times/weekends | Small relative to search volume, but bursty and write-heavy enough to need its own async aggregation path (R) rather than a synchronous recompute per review |
| Geospatial cell size (illustrative, urban) | A geohash/S2 cell edge length on the order of ~150-600m in dense urban areas, coarser in sparse/rural areas | Fine enough that a handful of cells around a search point captures the realistic "nearby" radius without needing to scan cells far outside it |
| Geospatial + metadata index footprint | 50M businesses × ~1-2KB of indexed fields (location, category tags, price tier, hours, precomputed score inputs) ≈ ~50-100GB total | Comfortably fits distributed across a search-engine cluster's shards, and — critically, per N — is small and stable enough to be heavily replicated and cached rather than needing to absorb continuous high-throughput writes |
H. Core Entities
| Entity | Purpose | Key Fields |
|---|---|---|
Business | A single point-of-interest listing — the static entity this entire design is built around | business_id, name, location (lat/lng), geocell (precomputed geohash/S2 id, M), category/cuisine_tags, price_tier, hours, avg_rating, review_count, updated_at |
Review | A single user's rating and text for a business | review_id, business_id, user_id, rating (1-5), text, created_at |
RatingAggregate | The precomputed, denormalized aggregate rating and count backing Business.avg_rating — recomputed async, not on every review (R) | business_id, avg_rating, review_count, last_recomputed_at |
SearchIndexEntry | The denormalized, search-engine-facing document for one business — what a query actually matches against (K, O) | business_id, geocell, location, filterable attributes (category, price, hours), ranking inputs (rating, popularity, sponsorship_boost) |
SearchQuery (ephemeral, not persisted as a first-class entity) | One user's search request — a location, optional text, and a set of filters | location, radius, query_text, filters (category/price/open_now) |
I. API Design
GET /v1/search?lat=&lng=&radius=&q=coffee&category=cafe&price=1,2&open_now=true
-> [{ business_id, name, distance, rating, price_tier, relevance_score }] (O, P)
GET /v1/businesses/{id} -> { name, location, hours, category, avg_rating, review_count, photos }
GET /v1/businesses/{id}/reviews -> [{ review_id, user_id, rating, text, created_at }]
POST /v1/businesses/{id}/reviews { user_id, rating, text } -> { review_id } (R)
POST /v1/businesses { name, location, category, hours, ... } -> { business_id } (owner/admin-facing, K)
PATCH /v1/businesses/{id} { hours?, category?, ... } -> 200 (S)
Why GET /v1/search is the one endpoint the entire rest of this design exists to serve fast: every other endpoint here is either low-volume (a single business detail lookup, a review submission) or entirely off the read-hot-path (a business edit). /search alone carries the ~5,800/sec average, multi-thousand-per-second-peak load from G, and every deep dive in this problem — the geospatial index (M), the geo/metadata intersection (O), ranking (P), and caching (Q) — is, underneath, a refinement of how to answer this one query cheaply.
J. Basic / Naive High-Level Design (V1)
The simplest thing that satisfies the functional requirement in isolation: store every business as a row in a relational table with plain lat/lng columns, and on a search request, compute the Haversine (great-circle) distance from the query point to every single row in the table, filter to those within the requested radius, then filter the survivors by category/price/hours in application code, and sort by distance.
plain relational, no spatial index")] DB --> API API -- "filter dist < radius, then filter category/price in app code" --> API API -- "sort by distance" --> U
V1 — Haversine distance computed against every row in the table, for every single search. Correct on a small dataset; catastrophic the moment the business table has real scale.
Why this breaks immediately, not eventually — and why it's a worse version of the exact mistake Ride-Sharing's V1 made: computing a distance function against every one of 50M rows, for every one of ~5,800 searches/sec, means the database does tens of millions of trigonometric distance calculations per search and needs to do that tens of thousands of times a second — there is no plain relational index that helps here at all, because the query isn't a range lookup on an indexed column, it's a full-table scan with a computed function applied to every row before any filtering happens. This is the identical structural flaw named in Ride-Sharing's own V1 (P5-J) — a 2D proximity query doesn't map onto a 1D B-tree index — but arguably worse here, because V1's Haversine calculation is strictly more expensive per row than a simple range comparison would be, and this system's read volume (thousands of searches/sec, sustained, forever) is far less forgiving of an O(n) full scan than an occasional trip request.
K. Data Model & Store Choice
The search-facing index is a dedicated search engine, not the relational source of truth: denormalized SearchIndexEntry documents (H) — location, geocell, category/price/hours filters, and precomputed ranking inputs — live in a search engine purpose-built for exactly this combined geo-plus-attribute-plus-text query shape (Elasticsearch/OpenSearch, using native geo_point fields and a geohash_grid aggregation or bucket structure, AD). This is the direct structural fix for V1's flaw (J): the search engine's own geospatial and inverted indexes make "nearby and matching these filters" a fast index lookup instead of a full scan.
The system of record for a Business is a plain relational or document store — owner-editable fields (name, hours, category, address) live in a durable primary store (Prerequisites B), exactly the same "one durable system of record, one denormalized read-optimized projection" split News Feed uses for precomputed feeds (P3) and Search Autocomplete uses for its trie (P13) — updates land in the primary store first, then propagate into the search index on the schedule Deep Dive S describes, deliberately not synchronously.
Reviews live in their own store, sharded/indexed by business_id: a wide-column or relational store keyed by business_id (so "all reviews for this business" is a single-partition range scan), separate from the RatingAggregate (H) that's actually read on every search result and business page — the aggregate is a small, precomputed number; the reviews themselves are a much larger, less frequently fully-read collection (R).
Photos, if in scope, follow the standard metadata/blob split (Prerequisites I) — a pointer in the Business record, actual bytes in object storage — the same pattern reused across nearly every problem in this document that has user-uploaded media.
L. End-to-End Flow — A User Searches for "Coffee Near Me" With Filters
The geospatial filter and the metadata filters are evaluated together, inside the same search-engine query (O) — not as two separate round trips that get merged in application code.
M. Deep Dive 1 — Geospatial Indexing for Static Points
Requirement this addresses: directly fixes V1's fatal flaw (J) — an efficient "what's near this point" query is the foundation everything else in this problem is built on, exactly as it was for Ride-Sharing (P5-M).
| Approach | How It Works | Why It Works Well Here Specifically |
|---|---|---|
| Geohash grid | Encodes each business's lat/lng into a sortable string prefix; nearby businesses tend to share prefixes (Prerequisites N) | Simple to compute once per business, at write time; because a business's geohash essentially never changes, it can be stored directly as an indexed field rather than recomputed on read |
| Quadtree | Recursively subdivides space into quadrants, denser subdivision where points are denser | Naturally adapts cell density to where businesses actually cluster (dense in city centers, sparse in rural areas) — a nice fit for a real-world business distribution, which is far from uniform |
| Google S2 / geo grid tiles | Projects the sphere onto a cube, subdivides into a hierarchy of cells with Hilbert-curve ordering for locality (AD) | Handles the whole globe correctly (poles, date-line edge cases) — a real consideration for a platform indexing businesses worldwide, not just one country |
Why, unlike Ride-Sharing (P5-M), the specific choice among these three matters far less here: P5 picked H3 specifically for its uniform-hexagon-neighbor property, because Ride-Sharing's matching flow repeatedly does live "expand my search ring" operations against a constantly-changing driver population, where boundary distortion is a real, recurring correctness concern. Here, the geospatial structure is computed once per business, essentially never recomputed, and then read from many times — a geohash grid's minor boundary-prefix quirks are a far smaller cost than they'd be in a system re-running neighbor expansion continuously against live data. A geohash grid is the simple, defensible, entirely sufficient choice for this problem; naming quadtree/S2 as equally valid alternatives (and being able to say why the choice matters less here than in P5) is the stronger answer than insisting on one "correct" structure.
How the index is actually built: at write time (a business is created or its address is edited, K), a geocell id is computed once and stored as an indexed field alongside the business's other attributes in the search engine (K). A search request computes the query point's own geocell plus its immediate neighboring cells (widening the ring if too few results are found, the same neighbor-expansion idea P5 uses, P5-M) and looks those cells up directly — a small, bounded set of index lookups, never a scan of the full business table.
N. Deep Dive 2 — Why Static Points Change the Geospatial Trade-off vs. Uber
Requirement this addresses: this is the single most important reasoning move in the whole problem — the deep dive that separates "I remembered geohashing from the Uber problem" from actually understanding why it's being used differently here.
The contrast, stated directly: Ride-Sharing's geospatial index (P5-M) has to absorb ~1.25M location writes/sec (P5-G) — every driver, moving, every few seconds, forever — which is why P5 lands on an in-memory, sharded, overwrite-based index (P5-K) that's fundamentally optimized for sustaining continuous high-throughput mutation. This system's equivalent index absorbs, per G, on the order of a handful of business-location writes per second, platform-wide — a rounding error next to Uber's number, several orders of magnitude smaller, because a restaurant simply doesn't move.
What that difference unlocks, concretely:
- Precomputation becomes viable, not just possible. The full geospatial index — every business's geocell, every neighbor-cell relationship — can be built once, offline or in a scheduled batch job, and simply served from then on, rather than being continuously reconstructed as data changes underneath it. Ride-Sharing's index, by contrast, is never "done" being built — it's in a permanent, ongoing state of being overwritten.
- Aggressive caching becomes viable, not just possible. Because the underlying data is stable, the same (geocell, filter) query result can be cached for minutes to hours without risking staleness that matters — the direct subject of Q. Caching Uber's driver-search results for even a few seconds would return stale, likely-already-moved-on drivers; caching this system's "coffee shops near this city block" result for an hour returns results that are, for all practical purposes, still exactly correct.
- Index rebuild frequency can be dramatically lower. Where Ride-Sharing's index effectively rebuilds itself continuously by definition, this system's index only needs to be refreshed on the cadence business changes actually occur — batch reindexing on the order of minutes to hours is entirely sufficient (S), a cadence that would be a correctness bug, not just slow, for a live driver-matching system.
Why this is a genuinely different trade-off, not just "the same solution, less work": both systems reach for a geohash/S2-style grid structure (M) — on the surface, the identical tool. But the engineering problem each system is actually solving with that tool is different: P5's hard problem is sustaining massive continuous write throughput into the index while keeping reads fast; this system's hard problem is almost entirely about read scale and caching, because the write side is, by comparison, nearly idle. A candidate who states this contrast explicitly — not just "I'd use a geohash here too" — is demonstrating the actual skill this problem is testing (C).
O. Deep Dive 3 — Combining the Geospatial Filter with Metadata Filters
Requirement this addresses: the category/price/open-now filtering functional requirement (D) — "nearby" alone is an incomplete query; a real search intersects proximity with a set of independent attribute constraints.
Where this intersection should happen, and why it matters: the naive-but-tempting approach is two separate steps — run a geospatial query to get all nearby businesses, then filter that candidate list by category/price/hours in the application layer. This works, but it's needlessly wasteful: it forces the geospatial query to return every nearby business regardless of whether most of them will immediately be discarded by the metadata filter (a search for "open-now coffee shops" still has to fetch every nearby dry cleaner and hardware store first, just to throw them away a moment later).
The better approach — push both filters into the same search-engine query, evaluated together: a modern search engine's query planner (Elasticsearch's bool query combining a geo_distance or geo_bounding_box clause with term/range filter clauses on category, price, and hours, AD) can intersect a geospatial candidate set with attribute filters natively, using the same underlying bitset/inverted-index machinery it already uses for text search — the intersection happens once, inside the engine, over whichever candidate set is smaller, rather than as two round trips glued together in application code.
Why "open now" specifically is a slightly different kind of filter than the others: category and price are static attributes of the business record itself and index cleanly as plain filterable fields. "Open now" depends on the current wall-clock time relative to the query, not a stored boolean — so it's evaluated either as a range query against a structured hours field (open-interval-per-day-of-week) at query time, or, for a cheaper approximation, as a precomputed, periodically-refreshed is_open_now flag recalculated on a short interval (minutes) rather than evaluated fresh on every single search — a small, deliberate staleness trade that's entirely acceptable given this system's overall tolerance for it (N, S).
P. Deep Dive 4 — Ranking Results
Requirement this addresses: the ranked-results functional requirement (D) — "nearest first" is not, by itself, a good ranking function, and a candidate who stops there is leaving real signal on the table.
Why pure distance is an incomplete ranking signal: the absolute closest coffee shop to a search point might have two reviews and a 2.5-star average, while a slightly farther one has thousands of reviews and a 4.7 average — a user is very likely better served by the second result even though it's not literally nearest, the identical "closest by one metric isn't actually best" lesson Ride-Sharing's ETA-refinement deep dive draws for driver ranking (P5-O), applied here to relevance instead of route time.
A blended relevance score, combining several independent signals: a representative formula — deliberately illustrative, not a "the one correct formula" claim — combines: an inverse-distance term (closer scores higher, but with diminishing sensitivity beyond a certain radius, so two businesses a block apart aren't wildly differentiated by distance alone); a Bayesian-adjusted rating rather than a raw average (so a business with one 5-star review doesn't outrank one with ten thousand reviews averaging 4.6 — the adjustment pulls a low-review-count business's score toward the platform-wide average until it has enough reviews to be trusted on its own, the standard fix for the "one great review" problem); a popularity/engagement term (review volume, recent search-click-through rate); and an optional sponsored-placement boost for paid listings, capped and clearly distinguishable from organic ranking in the actual product surface.
Why this blended score is computed largely offline, and only lightly adjusted at query time: most of a business's ranking inputs (its Bayesian rating, its popularity term) change slowly and can be recomputed on a batch schedule (S) and stored directly on the SearchIndexEntry (H, K) — only the distance term is inherently query-time (it depends on where the searcher actually is), so the query-time ranking work is "combine a precomputed static score with a cheap, freshly-computed distance term," not "recompute every signal from scratch for every search" — the same "precompute the expensive part, do only the cheap part live" instinct generalized in Patterns Playbook C.
Q. Deep Dive 5 — Caching Popular Areas and Queries
Requirement this addresses: the low-search-latency and high-availability NFRs (D) — and the deep dive where N's central insight (static points tolerate aggressive caching) actually gets cashed in as a concrete design decision.
Why a small number of geocells absorb a hugely disproportionate share of traffic: search volume tracks population density and foot traffic, not land area — a handful of dense urban geocells (downtown cores, popular neighborhoods) receive a wildly disproportionate fraction of all searches, the same "hot key" shape Ad Click Aggregation names for a viral ad (P16-R) and Rate Limiter names for an abusive client (P2-Q) — except here, unlike either of those, the hot keys are known in advance and change slowly (a city's busy downtown stays busy downtown for years), which makes them exceptionally easy to cache well rather than something that needs active hot-key detection.
The cache key: a (geocell, filter signature) pair, not the raw query string: results for "coffee near this exact latitude/longitude" and "coffee near a point 20 meters away, same geocell" should hit the identical cached entry — the cache key is normalized to the geocell (or a small ring of cells) plus a canonicalized signature of the applied filters (category, price tier, open-now bucketed to a coarse time window), not the raw, high-cardinality lat/lng float pair a naive cache-key choice would produce, which would fragment the cache into near-useless, almost-never-repeated keys.
Why a generous TTL is the correct choice here, unlike almost anywhere else in this document: a cache-aside read-through layer (Prerequisites D) in front of the search engine, with a TTL on the order of minutes for the raw geo+metadata result set — long enough to absorb the overwhelming majority of repeat traffic against a popular downtown geocell, short enough that a business's occasional update still propagates within a bound the product can live with (S). This is a direct, practical consequence of N: caching a moving driver's location for even a few seconds would be visibly wrong; caching "the coffee shops in this three-block radius" for several minutes is, for all practical purposes, indistinguishable from a live query.
Why this still needs a real cache tier, not just relying on the search engine's own internal caching: a search engine's query cache typically caches at the shard/segment level and still re-executes the query's scoring/ranking logic on a hit in many configurations; a dedicated, application-level result cache in front of it (Redis or an equivalent) can skip the entire query-and-rank pipeline on a hit and return a fully-formed, already-ranked page directly — meaningfully cheaper for the hottest cells that receive a disproportionate share of the ~5,800/sec average search load (G).
R. Deep Dive 6 — Handling Reviews at Scale
Requirement this addresses: the review-submission functional requirement (D) — a genuinely write-heavy path in an otherwise overwhelmingly read-dominated system, and the one place this design has to think about write throughput at all.
Why recomputing a business's aggregate rating synchronously, on every single review, is the wrong default: a naive implementation recalculates avg_rating by re-scanning every review for a business and recomputing the mean (and, if using P's Bayesian adjustment, the full adjusted score) on every single new review submission — for a popular business with tens of thousands of reviews, that's an increasingly expensive full scan triggered by every single write, and it's on the critical path of the review-submission request itself, directly slowing down the one interaction the user is actively waiting on.
The fix — write the raw review immediately, recompute the aggregate asynchronously: a review submission does two things, sequenced differently: the raw Review record itself (K) is written durably and immediately (the user's review exists and is visible on the business's review list right away) — but the RatingAggregate (H) and the ranking-relevant SearchIndexEntry fields derived from it are updated by a background job, either incrementally (maintaining a running sum/count rather than a full rescan — a new review just adjusts the running total, an O(1) update instead of an O(n) rescan) or on a short batch interval, the same "keep the expensive part off the hot path" instinct Patterns Playbook C names generally, applied here to rating recomputation instead of a notification send.
Why a short, bounded lag between "review submitted" and "aggregate rating updated" is an acceptable, deliberate trade, not a bug: a business's displayed star rating shifting a few seconds to a couple of minutes after a new review lands causes no meaningful product harm — nobody is making a time-critical decision off the exact millisecond a rating updates, which is a materially looser bar than, say, Payment System's ledger correctness (P15-V) or even Ad Click Aggregation's billing-grade eventual-exact count (P16-V). This short delay is explicitly named in the Consistency Model (V) as one of several places this design leans on its overall staleness tolerance.
S. Deep Dive 7 — Search Index Freshness
Requirement this addresses: the staleness-tolerance NFR (D) — how fresh does a nearby-search result actually need to be when a business closes, changes its hours, or a brand-new business opens, and what does the system tolerate in between?
The honest answer this problem calls for: far staler than almost anything else in this document, and that's correct, not a compromise. A business that closed yesterday still showing up in search results for a few hours is a minor, recoverable annoyance (a user might arrive and find it closed) — meaningfully lower-stakes than Ride-Sharing showing a driver's location a few seconds late (P5-V), and dramatically lower-stakes than a financial system briefly disagreeing about a balance. This is the same "how fresh does this actually need to be" question Web Crawler's own freshness deep dive asks about recrawl scheduling (P6-R) — and the two problems land on a similar answer for a similar reason: the underlying data changes slowly, so an index that's minutes-to-hours behind reality is a perfectly acceptable, deliberately chosen trade, not a bug to be engineered away.
Batch reindexing as the primary mechanism, not continuous per-write propagation: business creates/edits land in the primary store (K) immediately, but propagation into the search-engine index (K, O) happens on a scheduled batch cadence — every few minutes for high-priority fields (a business marking itself permanently closed) and up to an hour or more for lower-urgency ranking inputs (recomputed popularity scores, R's aggregate rating rollups). This mirrors Web Crawler's own priority-based, adaptive recrawl scheduling (P6-R) — not every field needs the same freshness bar, and treating them uniformly would either over-spend reindexing capacity on rarely-changing fields or under-serve the handful of changes that genuinely matter faster (a business closing down).
Why near-real-time, per-write index updates are available as an escalation, but not the default: a search engine can absolutely support near-immediate document updates if a specific field genuinely needs it — a "permanently closed" flag is the one field in this design worth propagating faster than the default batch cadence, since showing a permanently-closed business in search results is a more visible failure than a slightly-stale price tier. Choosing to make most fields batch-refreshed and only this one field near-real-time is a deliberate, selective decision — the same selective-cost reasoning Ad Click Aggregation applies to salting only genuinely hot ad keys rather than every key uniformly (P16-R).
T. Full Solution at Scale — Final Architecture
system of record, K")] PrimaryDB -- "batch reindex, minutes-to-hours cadence (S)" --> SearchIdx[("Search Engine Index
geo_point + metadata fields, K, M")] PrimaryDB -- "near-real-time propagation
for 'permanently closed' only (S)" --> SearchIdx User(["User"]) -- "GET /search (lat, lng, filters)" --> API["Search Service"] API -- "check first" --> Cache[("Query/Area Cache
(geocell, filter sig), TTL minutes, Q")] Cache -- "miss" --> SearchIdx SearchIdx -- "geo + metadata intersection (O)" --> API API -- "blend precomputed score + live distance (P)" --> API API -- "populate on miss" --> Cache API --> User User -- "POST review" --> ReviewSvc["Review Service"] ReviewSvc -- "write immediately" --> ReviewStore[("Review Store, K")] ReviewSvc -- "enqueue async recompute" --> AggWorker["Rating Aggregation Worker (R)"] AggWorker -- "incremental update" --> RatingAgg[("RatingAggregate, H")] RatingAgg -.-> SearchIdx
Two distinct speeds: the search read path (top) is cached and served from a mostly-static, batch-refreshed index; the review write path (bottom) writes durably and immediately, but only feeds the ranking-relevant aggregate back asynchronously.
U. Design Evolution
| Version | What Changed | Why |
|---|---|---|
| V1 | Haversine distance computed against every row in a plain relational table, on every search (J) | Simplest possible thing that satisfies the functional requirement in isolation |
| V2 | A dedicated geospatial index (geohash/S2 grid) replaces the full-table scan | Closes V1's fatal flaw — "nearby" becomes a bounded set of index lookups, not an O(n) scan (M) |
| V3 | Geospatial and attribute filters (category/price/open-now) are evaluated together, in a single search-engine query | Avoids fetching every nearby business only to discard most of them in application code afterward (O) |
| V4 | A blended relevance score (distance, Bayesian rating, popularity, sponsorship) replaces pure distance-sort | Nearest-first alone is a poor proxy for what a user actually wants (P) |
| V5 | A (geocell, filter signature)-keyed result cache with a minutes-long TTL sits in front of the search engine | A small number of dense geocells absorb disproportionate traffic, and static points make aggressive caching safe (N, Q) |
| V6 | Review writes land immediately; aggregate rating recomputation moves to an async, incremental background job | A synchronous full rescan on every review would slow the one request the user is actually waiting on (R) |
| V7 | Index updates move to a batch/scheduled reindex cadence, with one deliberate near-real-time exception ("permanently closed") | This system's staleness tolerance is far looser than most of this document's other read paths — most fields don't need continuous propagation (S) |
What stays stable throughout: the GET /search contract from I — every refinement underneath changes how a result is found, filtered, ranked, and cached, never the basic shape of what a user's client actually calls.
V. Consistency Model
This design sits at the opposite end of this document's staleness spectrum from Ride-Sharing (P5-V), for the precise reason N spells out: Uber's location data, ETA estimates, and live tracking tolerate seconds of staleness because a driver's position genuinely changes second to second; this system's search index, cached query results, and aggregate ratings tolerate minutes to low hours of staleness because the underlying data — a business's location, hours, and slowly-accumulating review base — simply doesn't change on anything close to that timescale. There is no equivalent, anywhere in this design, to Ride-Sharing's one deliberate strong-consistency island (atomic driver assignment, P5-P) — nothing in this problem is a genuine race for an exclusive resource; every read path here is eventually consistent by default, and the only real design question is how eventual, not whether a narrow strongly-consistent exception is needed at all.
Where the staleness bound actually lives: the cache TTL (Q) bounds how stale a served search result can be; the batch reindex cadence (S) bounds how stale the underlying index itself can be, with one selective near-real-time exception for a business going permanently closed; and the async rating-recompute job (R) bounds how stale a displayed aggregate rating can be relative to the reviews actually written. None of these bounds is zero, and — unlike almost every other problem in this document — none of them needs to be, because nothing downstream of any of them is a correctness-critical decision the way a financial balance or a driver assignment is.
W. Failure Scenarios
- The search engine cluster is temporarily degraded or a shard is unavailable: the result cache (Q) continues serving already-cached popular-area queries directly, and a cache miss for an uncached query degrades to a slower or partial result rather than a hard outage — the same "cache absorbs a backend blip" resilience most of this document's read-heavy systems rely on.
- The async rating-aggregation worker falls behind or crashes: raw reviews remain durably written and visible on the business's review list regardless; the displayed aggregate rating simply lags further behind until the worker catches up — never a lost review, only a temporarily staler number (R).
- A scheduled batch reindex job fails partway through: the previous index snapshot remains fully queryable and correct-as-of-its-own-build-time throughout; the next successful run simply catches up the backlog of changes, the same "the old version stays serveable until the new one is ready" safety property behind any staged/blue-green-style rebuild (S).
- A business is marked permanently closed, but the near-real-time propagation path itself is delayed: the business is still shown in search briefly — an acceptable, bounded-duration degradation given this system's overall staleness tolerance (V), not a correctness failure the way a stale driver location would be for Ride-Sharing.
- A dense downtown geocell's cached result expires and a burst of concurrent requests all miss simultaneously ("thundering herd"): a brief request-coalescing layer (only one of the simultaneous misses actually queries the search engine; the rest wait on and reuse that single result) prevents a cache-expiry moment from turning into a spike of redundant, simultaneous full queries against the hottest cells (Q).
X. Bottlenecks & Scaling Boundary
| Stage | Actual Bottleneck | Fix Applied |
|---|---|---|
| V1's full-table Haversine scan | A 2D proximity query with a computed distance function doesn't map onto any plain relational index at all | Dedicated geospatial index inside a search engine (M) |
| Two-step geo-query-then-app-filter | Fetches and discards many nearby businesses that fail the metadata filter, wasted work on every search | Combined geo + metadata query evaluated together, inside the search engine (O) |
| Uncached search engine as the only read path | A small number of dense urban geocells receive disproportionate traffic, hammering the search engine repeatedly for near-identical queries | A (geocell, filter signature)-keyed result cache with a generous TTL, viable specifically because points are static (N, Q) |
| Synchronous full-rescan rating recomputation | Cost per review grows with a business's total review count, directly slowing the review-submission request | Async, incremental aggregate rating recomputation off the hot path (R) |
Y. Design Trade-Offs
| Decision | Option A | Option B | Trade-off |
|---|---|---|---|
| Geospatial structure | Geohash grid (chosen) | Quadtree or Google S2 | A geohash grid is simplest and sufficient given the geospatial choice matters far less here than in a live-tracking system (N); a quadtree adapts better to non-uniform density, S2 handles global edge cases more cleanly — either is a defensible alternative |
| Index update strategy | Near-real-time, per-write propagation (Ride-Sharing's approach) | Scheduled batch reindexing, chosen | Near-real-time propagation is unnecessary machinery for data that changes as slowly as this; batch reindexing is far simpler and cheaper, at the cost of a bounded staleness window this system can comfortably tolerate (S) |
| Where geo and metadata filters intersect | Two separate queries, merged in application code | A single combined search-engine query, chosen | Two separate queries are conceptually simpler but waste work fetching businesses the metadata filter will immediately discard; a combined query is more dependent on the search engine's own query-planning capability, in exchange for real efficiency (O) |
| Rating aggregation | Synchronous full rescan on every review | Async, incremental recomputation, chosen | Synchronous rescan is always instantly consistent but grows more expensive, and slower for the user, as a business accumulates reviews; async incremental updates keep review submission fast, at the cost of a short, bounded lag before a new review affects the displayed rating (R) |
| Cache TTL for search results | Very short (seconds), matching most of this document's other caches | Minutes-long, chosen | A short TTL is the safer default almost everywhere else in this document, but is needlessly conservative here — static points make a much longer TTL just as correct in practice, while dramatically increasing cache hit rate for the hottest, most-searched geocells (N, Q) |
Z. Common Bad Designs
- Computing Haversine distance against every row in a plain SQL table, with no spatial index at all. Works on a small demo dataset, is the wrong tool for this query shape from the very first realistic dataset size — the defining mistake on this problem, and a more expensive version of Ride-Sharing's own V1 mistake (J).
- Importing Ride-Sharing's real-time, continuously-overwritten index architecture wholesale, without asking whether this data actually moves. Solves a write-throughput problem this system doesn't have, while missing the caching and precomputation opportunities that only exist because the data is static (N) — the single most diagnostic mistake for this specific problem, since it shows the candidate pattern-matched the tool without re-deriving the trade-off.
- Filtering by category/price/open-now entirely in application code, after fetching every geospatially-nearby business. Wastes work fetching and discarding businesses the metadata filter was always going to reject, instead of intersecting both filters inside the search engine itself (O).
- Ranking purely by distance, with no rating, popularity, or Bayesian adjustment at all. Confidently surfaces the nearest business even when it's a one-review outlier a user would clearly prefer to skip past for a slightly-farther, well-reviewed option (P).
- Recomputing a business's full aggregate rating from a raw scan of all its reviews synchronously, inside the review-submission request itself. Gets progressively slower as a popular business accumulates reviews, directly penalizing the exact users engaging most with the platform (R).
AA. Interviewer Follow-Ups
| Follow-Up | Design Change | Components Changed | Components Unchanged |
|---|---|---|---|
| "What if a business does occasionally relocate — a food truck, a pop-up, a mobile vendor? | That specific business's location updates get routed through the near-real-time propagation path already reserved for "permanently closed" (S), rather than the default batch cadence — the design already has the right escalation hook, it's just applied to a second, narrower category of business | Which businesses use the fast propagation path (S) | The geospatial index structure itself, caching strategy, ranking |
| "How would you support personalized ranking — boosting businesses a specific user has liked before?" | Add a per-user personalization term to the blended score (P), computed at query time from a lightweight user-preference profile, layered on top of the existing precomputed static score rather than replacing it | Ranking function (P), an added user-preference lookup | Geospatial index, metadata filtering, caching strategy (though the cache key now needs to account for personalization, a real added wrinkle worth naming) |
| "A single celebrity restaurant review goes viral and that one business page gets a massive traffic spike." | The business-detail-page read path (not the search path) needs its own hot-key cache entry with a short, dedicated TTL — a much narrower, cheaper version of Ad Click Aggregation's hot-ad partition-salting problem (P16-R), since this is a single cached document under read pressure, not a write-partitioning problem | Business detail page caching | Search ranking, geospatial index, review-write path |
| "How would you detect and filter fake/incentivized reviews?" | Layer heuristic fraud/authenticity scoring onto the review-ingestion path (velocity from one account, review-text similarity across businesses, reviewer account age) — conceptually the same heuristic-signal-combination approach Ad Click Aggregation applies to bot clicks (P16-S), applied here to review authenticity instead of click fraud | A new review-scoring stage before a review counts toward the aggregate | Search, ranking, geospatial index |
AB. Interviewer Evaluation Rubric
- Weak candidate: proposes computing distance against every row in a SQL table with no spatial index; ranks purely by distance with no mention of rating or relevance at all.
- Mid-level: correctly reaches for a geospatial index (geohash or similar) by name, but treats it identically to how they'd describe Ride-Sharing's driver index, without noticing or being able to explain why the two systems' trade-offs actually differ.
- Senior: proactively distinguishes this problem's static-point geospatial index from a moving-object index, and explains concretely what that unlocks (precomputation, longer cache TTLs, batch reindexing); designs a blended ranking function beyond pure distance; correctly separates the review write path from the aggregate-rating read path.
- Staff+: states the static-vs-moving contrast against Ride-Sharing unprompted, as the organizing idea of the whole design, not a footnote; reasons precisely about which specific fields need near-real-time propagation versus which can tolerate a batch cadence, rather than picking one uniform freshness policy for everything; treats caching, ranking, and freshness as three expressions of the same underlying insight rather than three unrelated features.
AC. Interview Simulation
AD. Online Solution Comparison
| Source | Approach | Strength | Weakness | Worth Learning |
|---|---|---|---|---|
| Hello Interview — "Design Yelp" | Geospatial-index-first framing combined with an explicit discussion of denormalized search documents and read-heavy caching, closely mirroring this section's own progression | The clearest treatment of combining a spatial index with faceted/attribute search specifically for a static-POI domain | Lighter on explicitly contrasting the static-point case against a moving-object system like Uber's | The read-heavy, cache-first framing of the whole problem, adapted directly into Q of this section |
| Elastic — "Geo queries" (official Elasticsearch documentation) | Documents native geo_point/geo_shape field types and combined geo_distance/geo_bounding_box queries alongside ordinary term and range filters within a single bool query | Direct, first-party confirmation that intersecting a geospatial filter with attribute filters inside one search-engine query (this section's O) is exactly how a real, production search engine implements it — not an interview simplification | Documents the query API thoroughly but not the internal scoring/index-structure implementation in depth | The concrete mechanism (a combined bool query) this design's Deep Dive 3 (O) is built around |
| Google — S2 Geometry Library (official site) | A hierarchical, sphere-projected geospatial cell decomposition with Hilbert-curve locality, used internally at Google and several other large-scale mapping/geo products | A real, production-grade alternative to a geohash grid, handling whole-globe edge cases (poles, the antimeridian) that a naive geohash implementation can mishandle | A lower-level geometry library, not a search engine — needs to be paired with an actual index/query layer, unlike Elasticsearch's built-in geo support | Confirms quadtree/S2-style hierarchical cell decomposition (M) is a real, battle-tested alternative to geohash, not a hypothetical |
| Uber — H3 (GitHub, official) | Uber's own open-sourced hexagonal geospatial grid, built specifically for its real-time driver-matching problem (P5-M) | Useful here specifically as a contrast, not a recommendation — reading H3's own design goals (uniform-neighbor distance for live, continuous ring-expansion queries) makes clear it's solving a problem this system doesn't actually have | Purpose-built for a moving-object matching workload; adopting it here would import real complexity with no corresponding benefit for a static-point search problem | The clearest concrete evidence, from Uber's own real infrastructure, for exactly the static-vs-moving distinction this section's Deep Dive 2 (N) is built around |
AE. Interview Cheat Sheet
| Problem | Yelp / Nearby Places Search |
| Difficulty | Intermediate → Advanced + Geospatial Search — the geospatial tool is familiar from Ride-Sharing, but correctly reasoning about the static-point trade-off is the real, higher-signal test |
| Core requirements | Search nearby businesses with category/price/open-now filters, ranked by relevance; view business details and reviews; submit reviews — ~50M businesses, ~5,800 searches/sec average, only a handful of business-metadata writes/sec |
| Central decision | A precomputed, heavily-cached geospatial index (geohash/S2) over largely static points, combined with a separate metadata/attribute index, intersected in a single search-engine query and ranked by a blended relevance score |
| The Uber contrast (the load-bearing insight) | Static points mean the index can be precomputed and cached aggressively, with batch (not continuous) updates — the opposite trade-off from Ride-Sharing's real-time, continuously-overwritten driver index (P5-N, this section's N) |
| Filtering | Geo and metadata filters (cuisine, price, open-now) intersected together inside one search-engine query, not fetched-then-filtered in application code (O) |
| Ranking | Blended score: distance, Bayesian-adjusted rating, popularity, capped sponsored boost — mostly precomputed offline, only the distance term is genuinely query-time (P) |
| Caching | (Geocell, filter signature)-keyed result cache with a minutes-long TTL — viable specifically because the underlying points rarely move (Q) |
| Reviews | Written immediately and durably; aggregate rating recomputed asynchronously/incrementally, never via a synchronous full rescan on every review (R) |
| Freshness | Batch reindexing on a minutes-to-hours cadence is the default; one deliberate near-real-time exception for "permanently closed" listings (S) |
| Common mistakes | Full-table Haversine scans; importing Uber's real-time index architecture wholesale without asking whether the data moves; filtering metadata in application code after a geo-only query; pure distance-sort ranking; synchronous full-rescan rating recomputation |
| What NOT to over-engineer | A full ad-auction engine for sponsored placement, or a real-time driver-style index for business locations that essentially never change |