Search & Discovery
Design Yelp / Google Places
01
What the service must do
What the service must do
- Search for places near a point or inside a map viewport, with an optional keyword and category such as "thai" or "pharmacy".
- Filter by open now, price level and minimum rating, and sort by relevance, distance or rating.
- Show a place page: name, address, hours, photos, average rating and the most helpful recent reviews.
- Signed-in users write a review with a 1 to 5 star rating and up to ten photos.
- Owners claim a listing and edit hours, phone and description; edits go live within minutes.
- Search p99 of 150 ms at the API, place pages under 100 ms from cache.
- Search and place pages stay up at 99.95%; review writes can degrade first.
- A new review shows on the place page within seconds for its author and within a minute for everyone else.
- Ratings, counts and search ranking can lag by a minute or two. Nobody notices a 4.31 that should be 4.32.
- Survive a regional outage with read-only search served from another region.
Ask whether search is radius around the user or the visible map rectangle, since the map case asks for different shapes at very different zoom levels. Ask how often places move or open: if the answer is "a few thousand changes a day", the geo index can be rebuilt or patched cheaply and does not need to be a transactional database. Finally ask whether text relevance matters, because "sushi near me" mixes a full-text match with a spatial filter, and that decides whether you need a search engine or just a spatial index.
02
Sizing the load
Sizing the load
Assume 25 million daily users, each running about five searches and opening eight place pages a day. Reviews are rare compared with reads. Search traffic peaks around lunch and dinner in each time zone, roughly three times the daily average.
| Quantity | Working | Result |
|---|---|---|
| Places | assumption, worldwide | 60M |
| Searches per day | 25M x 5 | 125M |
| Average search rate | 125M / 86,400 s | about 1,450 / s |
| Peak search rate | 3x average at meal times | about 4,300 / s |
| Place page views per day | 25M x 8 | 200M, about 2,300 / s average |
| Reviews written per day | assumption | 600K, about 7 / s |
| Read to write ratio | (125M + 200M) / 600K | about 540 to 1 |
| Place records | 60M x 2 KB (text, hours, attributes) | about 120 GB |
| Geo index entry | id 8 B + lat 4 B + lng 4 B + cell 8 B + rank fields | about 32 B |
| Whole geo index | 60M x 32 B | about 2 GB, fits in RAM on every node |
| Reviews stored | 1.2B reviews x 700 B | about 840 GB |
| Photo uploads per day | 1.4M x 3 MB original plus 3 resized copies | about 5 TB a day into object storage |
Two gigabytes is the size of the whole spatial index. Every search node can hold a full copy in memory, so you never need to shard by geography to make search fast. Sharding becomes a capacity choice, not a necessity.
Photos dwarf everything else. They go straight from the client to object storage through a signed upload URL and are served from a CDN, so they never pass through the search or place services.
03
API design
API design
Results shift as ratings update and places close. An opaque cursor that pins the query and offset avoids showing the same place twice on pages one and two, and lets you cache page one by query without caching every offset.
04
Architecture overview
Architecture overview
flowchart LR
C["Mobile and web clients"] --> GW["API gateway"]
GW --> SS["Search service"]
GW --> PS["Place service"]
GW --> RS["Review service"]
SS --> GI["Geo index, in memory on every node"]
SS --> RC[("Redis: cell results + place cards")]
PS --> RC
PS --> PG[("Postgres: places")]
RS --> RDB[("Review store, partitioned by place")]
RS -. "review.created" .-> K[["Kafka"]]
K --> AG["Rating aggregator"]
AG --> PG
K --> IB["Index builder"]
IB --> GI
C --> OBJ[("Object store + CDN: photos")]There are three services because there are three very different workloads. Search is a CPU-bound filter over an in-memory structure. Place pages are key lookups that cache almost perfectly. Reviews are the only real writes, and they need durability and moderation more than speed.
The search service does not ask a database where things are. Each node loads a compact geo index at startup: every place's id, coordinates, geohash cell, category bits, price, rating and a packed weekly-hours bitmap. A query turns the user's circle into a handful of cells, gathers candidates from those cells, filters and scores them in memory, and returns the top twenty ids. The gateway then hydrates those ids into place cards from Redis, falling back to Postgres.
Writes reach the index indirectly. A new review lands in the review store and emits an event. The aggregator updates the place's running count and star sum, and the index builder pushes a small delta to every search node. Owner edits to hours or location take the same route. Nothing on the write side blocks a search.
Stateless apart from its index copy. Scales by adding replicas. Each replica holds all 60M entries in about 2 GB, so any node can answer any query and a node failure just shifts load.
Read-through cache over Postgres. Place cards are small and change rarely, so the hit rate sits well above 95%. Edits write Postgres first and then delete the cache key.
Writes to a store partitioned by place_id so a place's reviews sit together for paging. Runs spam and abuse checks asynchronously before a review becomes public.
Consumes review events and keeps count and star sum per place. The displayed average is computed from those two numbers, never by scanning reviews.
Data model
| Store | Key | Holds | Why there |
|---|---|---|---|
Postgres places | place_id | Name, address, lat and lng, zone, hours, attributes, owner, rating count and star sum | Relational edits, owner workflows, the source of truth for the index |
| Review store | place_id, then created_at descending | Stars, text, photo ids, helpful votes, moderation state | Paging a place's reviews is one range read on one partition |
| User reviews index | user_id | Pointers to the user's reviews | Profile pages and the one-review-per-place check |
| Geo index snapshot | geohash cell | Packed entries for every place in the cell | Loaded into search nodes at boot, patched by deltas |
| Redis | place card id, or cell plus query hash | Rendered cards and top-N results for hot cells | Absorbs meal-time peaks |
Search nodes boot from the latest snapshot in object storage, then replay index deltas from Kafka starting at the snapshot's offset. A fresh node is serving within a couple of minutes, and every node converges on the same view without talking to the others.
05
Deep dive: finding what is nearby
Deep dive: finding what is nearby
Given a point and a radius, how do you find the few hundred places inside it without looking at the other 60 million? A B-tree on latitude alone narrows to a band around the planet. You need an index that understands two dimensions.
Geohash in one paragraph
A geohash interleaves the bits of longitude and latitude and writes the result in base32. Each extra character splits a cell into 32 smaller ones, so a shared prefix means a shared area. That turns a 2D lookup into a prefix lookup on a string, which any sorted store or hash map can do. The useful precisions for local search are shown below.
| Length | Cell size near the equator | Good for |
|---|---|---|
| 4 | about 39 km x 20 km | A whole metro area at low zoom |
| 5 | about 4.9 km x 4.9 km | Searches of 2 to 10 km |
| 6 | about 1.2 km x 0.6 km | Walking distance, 200 m to 2 km |
| 7 | about 150 m x 150 m | Dense downtown blocks, hotspot splitting |
The neighbour query
flowchart TD Q["Query: point, radius 2 km, ramen, open now"] --> P["Pick precision so a cell is at least the radius: length 5"] P --> C["Centre cell plus its 8 neighbours"] C --> G["Gather candidates from 9 cell lists"] G --> F["Exact haversine distance, drop outside radius"] F --> M["Category, price and open-now bitmap filters"] M --> S["Score: text match, rating, distance, popularity"] S --> T["Top 20 ids"] T --> H["Hydrate place cards from cache"]
A user standing near a cell edge is closer to places in the next cell than to most of their own. So never search only the centre cell. Choose the precision where a cell is at least as wide as the radius, then read the centre and its eight neighbours. The union of those nine cells is guaranteed to contain the whole circle. After that, compute the real distance for each candidate and drop the corners.
The cost of a query is the number of candidates in nine cells. In a suburb that might be 300 places. In central Tokyo at precision 5 it can be 40,000. Handle density adaptively: if a cell's list is longer than a threshold, store it split one level deeper and read only the sub-cells that intersect the circle. This gives you most of a quadtree's benefit while keeping the simple string keys.
Two edge cases trip people up. Geohash cells shrink in width toward the poles, so precision chosen at the equator over-covers in Oslo; compute cell width from the latitude. And adjacent cells across the 180th meridian or a geohash quadrant boundary can have completely different prefixes, so compute neighbours with a proper neighbour function, not by incrementing the last character.
Open now, correctly
"Open now" is a time zone problem disguised as a filter. Store hours in the place's local time with its IANA zone, then precompute a 672-bit weekly bitmap of 15-minute slots in UTC for the index. At query time, look up one bit. Rebuild the bitmap when hours change and twice a year when daylight saving shifts. Holiday overrides go in a small exceptions table checked only for the top results.
Ratings that do not lie to small places
A place with one 5-star review should not outrank one with 900 reviews averaging 4.7. Rank on a Bayesian average: (C x m + sum_of_stars) / (C + count), where m is the city's mean rating and C is a confidence weight such as 25. Display the plain average to users, but sort on the adjusted one. Because the aggregator keeps count and sum, both values update in constant time per review.
Scoring the candidates
After filtering, a few hundred candidates remain and you need twenty. A simple, explainable score works well to start: a weighted sum of text match, adjusted rating, a distance decay such as 1 / (1 + d / 500 m), and a popularity term from recent check-ins or page views. Learn the weights from click and call-through data later. Keep the scoring inside the search node so it never needs another network hop.
Sponsored results are a separate list merged at fixed slots. Mixing ad bids into the organic score makes both ranking and billing hard to audit.
Out of 150 ms: about 10 ms in the gateway, 15 to 30 ms for the in-memory geo filter and scoring, 10 ms to hydrate cards from Redis, and the rest is network to the client. If the geo step ever takes 80 ms, the cause is almost always one overloaded dense cell, not the database.
Returning everything in the nine cells without the distance check gives results up to about 1.4 times farther than the radius promises. Users notice when "within 1 km" shows a place 1.6 km away.
06
Decisions and their costs
Decisions and their costs
String prefixes are easy to store, shard, cache and debug, and the whole index fits on one box. You must handle neighbours, density splitting and polar distortion yourself.
Fast and simple to operateExact geometry, polygon queries and decent performance for thousands of queries a second. It ties search to one database and makes horizontal read scaling a replica problem. Great for stage one.
Best starting pointElasticsearch or OpenSearch with a geo_point field does keyword relevance and distance filtering in one query. Use it once "thai curry" has to beat "thai massage".
A quadtree adapts to density natively and S2 has better shape properties near the poles. Both are excellent; geohash wins on how easily people can read and reason about the keys.
Equally valid, more to explainPlace cards change a few times a year. Results for a popular cell and query, such as "coffee" in a busy precision 6 cell, can be cached for 60 seconds and serve thousands of users.
Most reads never computeSimple and always fresh, but meal-time peaks hit the search fleet with the same few thousand queries over and over.
Wastes the hot setEach review is an O(1) update and the average is always derivable. Rebuild from the review store nightly to correct drift from any lost event.
Cheap and recoverableCorrect by definition, but a place with 20,000 reviews makes every page view scan them.
Too slow on popular places07
Failure modes
Failure modes
A food video sends a million people to one café's page in an hour. Its cache key expires at the peak and thousands of requests miss together.
Refresh hot keys before they expire, use single-flight on misses, and give place cards a stale-while-revalidate window. A slightly stale rating is fine.Precision 5 in central Manhattan holds tens of thousands of places. Queries there scan far more candidates than anywhere else and p99 jumps for the whole node.
Split cells over a size threshold into sub-cells, pick precision per query from local density, and cap candidates per query with early termination on score.The aggregator crashed after updating Postgres but before committing its Kafka offset, so a replay counted 300 reviews twice.
Make updates idempotent by recording the last applied review event per place, and run a nightly job that recomputes count and sum from the review store and fixes differences.A daylight saving change shifted every hours bitmap in one country by an hour. Users arrived at locked doors and left one-star reviews.
Rebuild bitmaps from local hours whenever zone rules change, test the transition dates in CI, and surface "hours may differ today" when the owner has not confirmed recently.Someone reuses a signed URL to push large non-image files into the bucket.
Sign the content type, a size ceiling and a short expiry into each URL, keep uploads in a quarantine bucket until a worker validates and re-encodes them, and rate-limit URL issuance per account.The primary region loses its database. Review writes fail and place edits stall.
Search nodes in the second region already hold the full index and replicated place data, so they keep serving reads. Queue review writes on the client and return a clear "saved, will post shortly" state.08
How to handle the interview
How to handle the interview
Size the index first. Show that 60 million entries at about 32 bytes is roughly 2 GB. It reframes the problem from "how do I shard a spatial database" to "how do I replicate a small structure".
Explain why one cell is not enough. Draw a point near a cell edge. The nine-cell read plus an exact distance filter is what interviewers look for.
Know at least two spatial indexes. Geohash, quadtree, S2 and R-trees each have a story. Pick one, say why, and name what the others do better.
Separate fast reads from slow truth. Ratings and ranking can lag a minute. Saying so out loud lets you push the write path behind a queue.
Handle density explicitly. Downtown and countryside differ by three orders of magnitude in places per square kilometre. A fixed precision is the usual weak spot.
Treat open now as a time zone problem. Mention local hours, IANA zones and daylight saving. It shows you have been bitten by real data.
Keep photos off your servers. Signed uploads and a CDN take the biggest data volume out of the design in one sentence.
09
Growing the design in stages
Growing the design in stages
Each step below fixes a limit you can measure. Start with the database you already run, and leave it only when its numbers tell you to.
A GiST index on a geography column answers radius queries directly. Reviews and places live in the same database. This handles a launch city with room to spare.
Place cards move to Redis, photos to object storage behind a CDN, and read replicas take search traffic off the primary.
Search leaves Postgres. Review events flow through Kafka to an aggregator and an index builder. The PostGIS query on the hot path is retired.
Each region holds the full index. Keyword relevance moves to a search engine, and ranking adds personal signals such as past visits and saved places.
Watch and read
Try next
Free to read · better with landed
Whiteboard this with landed
landed runs it as a live system design round on the whiteboard and grades your trade-offs.
