17. Yelp / Nearby Places Search Intermediate → Advanced + Geospatial Search

A. Realistic Interview Prompt

"Design a local business search and discovery system like Yelp: a user searches for nearby places — restaurants, cafes, shops — optionally filtered by category, price range, or whether they're open right now, and sees results ranked by relevance. Users can also view a business's detail page and leave a review."

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

D. Requirements

Functional

  1. A user can search for businesses near a given location (or their current location), optionally with a text query (e.g. "coffee")
  2. A user can filter search results by category/cuisine, price tier, and whether a business is open at the current moment
  3. Search results are ranked by relevance, not returned as an unordered or purely distance-sorted list
  4. A user can view a business's detail page (hours, address, photos, aggregate rating) and its reviews
  5. A user can submit a review (rating + text) for a business

Non-Functional

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

#QuestionWhy It MattersDesign Decision Affected
1Do 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 onesDeep Dive 1 & 2 (M, N)
2How 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 scheduleDeep Dive 7 (S)
3Should ranking blend in anything beyond distance and rating — sponsored placements, personalization, popularity trends?Determines how many independent signals the ranking function actually needs to combineDeep Dive 4 (P)
4Does 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 jobDeep Dive 6 (R)
5Are 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 sufficientData Model (K), Deep Dive 3 (O)

F. Assumptions

G. Capacity Estimation

MetricValue / CalculationWhy 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 averageThe 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-wideIllustratively ~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 higherConfirms 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-wideIllustratively ~2M/day → ~23/sec average, spiking higher around meal times/weekendsSmall 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 areasFine 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 footprint50M businesses × ~1-2KB of indexed fields (location, category tags, price tier, hours, precomputed score inputs) ≈ ~50-100GB totalComfortably 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

EntityPurposeKey Fields
BusinessA single point-of-interest listing — the static entity this entire design is built aroundbusiness_id, name, location (lat/lng), geocell (precomputed geohash/S2 id, M), category/cuisine_tags, price_tier, hours, avg_rating, review_count, updated_at
ReviewA single user's rating and text for a businessreview_id, business_id, user_id, rating (1-5), text, created_at
RatingAggregateThe 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
SearchIndexEntryThe 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 filterslocation, 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.

flowchart LR U(["User searches 'coffee near me'"]) --> API["Search Service"] API -- "SELECT *, haversine(lat,lng,q_lat,q_lng) AS dist FROM businesses" --> DB[("Business Table
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

sequenceDiagram participant U as User participant API as Search Service participant Cache as Query/Area Cache (Q) participant SE as Search Engine (Geo + Metadata Index, K) U->>API: GET /search?lat=...&lng=...&q=coffee&price=1,2&open_now=true API->>Cache: check cache for (geocell, filter signature) alt cache hit Cache-->>API: cached ranked result page else cache miss API->>SE: geo_distance/geo_point filter (nearby cells, M) AND category/price/open_now filters (O) SE-->>API: candidate set matching both geo and metadata filters API->>API: compute blended relevance score (P) over candidates API->>Cache: populate cache for this (geocell, filter signature) end API-->>U: ranked results

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).

ApproachHow It WorksWhy It Works Well Here Specifically
Geohash gridEncodes 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
QuadtreeRecursively subdivides space into quadrants, denser subdivision where points are denserNaturally 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 tilesProjects 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:

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

flowchart TB Owner(["Business owner"]) -- "create/edit listing" --> PrimaryDB[("Primary Business Store
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

VersionWhat ChangedWhy
V1Haversine distance computed against every row in a plain relational table, on every search (J)Simplest possible thing that satisfies the functional requirement in isolation
V2A dedicated geospatial index (geohash/S2 grid) replaces the full-table scanCloses V1's fatal flaw — "nearby" becomes a bounded set of index lookups, not an O(n) scan (M)
V3Geospatial and attribute filters (category/price/open-now) are evaluated together, in a single search-engine queryAvoids fetching every nearby business only to discard most of them in application code afterward (O)
V4A blended relevance score (distance, Bayesian rating, popularity, sponsorship) replaces pure distance-sortNearest-first alone is a poor proxy for what a user actually wants (P)
V5A (geocell, filter signature)-keyed result cache with a minutes-long TTL sits in front of the search engineA small number of dense geocells absorb disproportionate traffic, and static points make aggressive caching safe (N, Q)
V6Review writes land immediately; aggregate rating recomputation moves to an async, incremental background jobA synchronous full rescan on every review would slow the one request the user is actually waiting on (R)
V7Index 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

X. Bottlenecks & Scaling Boundary

StageActual BottleneckFix Applied
V1's full-table Haversine scanA 2D proximity query with a computed distance function doesn't map onto any plain relational index at allDedicated geospatial index inside a search engine (M)
Two-step geo-query-then-app-filterFetches and discards many nearby businesses that fail the metadata filter, wasted work on every searchCombined geo + metadata query evaluated together, inside the search engine (O)
Uncached search engine as the only read pathA small number of dense urban geocells receive disproportionate traffic, hammering the search engine repeatedly for near-identical queriesA (geocell, filter signature)-keyed result cache with a generous TTL, viable specifically because points are static (N, Q)
Synchronous full-rescan rating recomputationCost per review grows with a business's total review count, directly slowing the review-submission requestAsync, incremental aggregate rating recomputation off the hot path (R)

Y. Design Trade-Offs

DecisionOption AOption BTrade-off
Geospatial structureGeohash grid (chosen)Quadtree or Google S2A 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 strategyNear-real-time, per-write propagation (Ride-Sharing's approach)Scheduled batch reindexing, chosenNear-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 intersectTwo separate queries, merged in application codeA single combined search-engine query, chosenTwo 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 aggregationSynchronous full rescan on every reviewAsync, incremental recomputation, chosenSynchronous 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 resultsVery short (seconds), matching most of this document's other cachesMinutes-long, chosenA 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

AA. Interviewer Follow-Ups

Follow-UpDesign ChangeComponents ChangedComponents 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 businessWhich 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 itRanking function (P), an added user-preference lookupGeospatial 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 problemBusiness detail page cachingSearch 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 fraudA new review-scoring stage before a review counts toward the aggregateSearch, ranking, geospatial index

AB. Interviewer Evaluation Rubric

AC. Interview Simulation

Interviewer: Design a system like Yelp — users search for nearby businesses, filter by category or price, and see ranked results.
Candidate: The core query is "find things near this point," which sounds identical to the Uber matching problem — a plain SQL range query on lat/lng won't scale, so I'd want a dedicated geospatial index, something like a geohash grid. But before I go further, I want to flag one thing that changes the design meaningfully from Uber's version of this: businesses don't move. A restaurant's location is essentially permanent once it's listed.
Interviewer: Why does that matter? Isn't it the same indexing problem either way?
Candidate: The index structure itself is similar, but what that structure has to sustain is completely different. Uber's index absorbs something like a million location updates a second, because every driver is moving constantly — that's why it has to be an in-memory, continuously-overwritten structure. Here, business locations change maybe a handful of times a second, platform-wide, out of tens of millions of listings. That means I can build this index as a mostly-static, precomputed structure — rebuild it on a schedule, batch-refresh it every few minutes or even hours — instead of treating it as something that needs continuous real-time updates. And because the underlying data barely changes, I can cache search results aggressively — minutes-long TTLs, keyed by geographic cell and filter combination — in a way that would be actively wrong to do for Uber's live driver positions.
Interviewer: How do you combine "nearby" with filters like cuisine or price?
Candidate: I'd push both into the same query against a search engine like Elasticsearch — a geo-distance clause combined with term filters on category and price, evaluated together, rather than fetching every nearby business first and filtering in application code afterward. That way the engine only does the expensive geospatial work once, and it's already intersected with the filters by the time results come back.
Interviewer: And ranking — do you just sort by distance?
Candidate: No — pure distance is a weak signal on its own. I'd blend distance with a Bayesian-adjusted rating, so a business with one perfect review doesn't outrank one with ten thousand reviews averaging 4.6, plus a popularity term, plus room for a capped, clearly-labeled sponsored boost. Most of those inputs change slowly, so I'd precompute them and store them directly on the search index entry — the only genuinely query-time piece is the distance itself, since that depends on where the searcher actually is right now.
Interviewer: Last one — reviews. What happens when someone submits one?
Candidate: The review itself gets written immediately and durably — it shows up on the business's review list right away. But I wouldn't recompute the business's overall star rating synchronously on every single review; for a popular business with a huge review history, that gets more expensive every time. I'd update the aggregate rating asynchronously — an incremental running total, updated by a background worker — so the review-submission request stays fast, and the displayed rating just lags by a few seconds to a couple of minutes, which for this product is a completely acceptable trade.

AD. Online Solution Comparison

SourceApproachStrengthWeaknessWorth 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 progressionThe clearest treatment of combining a spatial index with faceted/attribute search specifically for a static-POI domainLighter on explicitly contrasting the static-point case against a moving-object system like Uber'sThe 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 queryDirect, 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 simplificationDocuments the query API thoroughly but not the internal scoring/index-structure implementation in depthThe 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 productsA real, production-grade alternative to a geohash grid, handling whole-globe edge cases (poles, the antimeridian) that a naive geohash implementation can mishandleA lower-level geometry library, not a search engine — needs to be paired with an actual index/query layer, unlike Elasticsearch's built-in geo supportConfirms 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 havePurpose-built for a moving-object matching workload; adopting it here would import real complexity with no corresponding benefit for a static-point search problemThe 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

ProblemYelp / Nearby Places Search
DifficultyIntermediate → 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 requirementsSearch 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 decisionA 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)
FilteringGeo and metadata filters (cuisine, price, open-now) intersected together inside one search-engine query, not fetched-then-filtered in application code (O)
RankingBlended 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)
ReviewsWritten immediately and durably; aggregate rating recomputed asynchronously/incrementally, never via a synchronous full rescan on every review (R)
FreshnessBatch reindexing on a minutes-to-hours cadence is the default; one deliberate near-real-time exception for "permanently closed" listings (S)
Common mistakesFull-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-engineerA full ad-auction engine for sponsored placement, or a real-time driver-style index for business locations that essentially never change