← System design

Search & Discovery

Design Yelp / Google Places

Someone standing on a street corner types "coffee" and expects a ranked list of places within walking distance before they have finished looking up. The data is modest, 60 million places fit in a couple of gigabytes of coordinates, but the query is two-dimensional, the ranking mixes distance with ratings and hours, and every review changes what the next person sees.
Core: geohash cells + in-memory geo index60M places, 25M daily usersRead-heavy, about 500 reads per writeSearch p99 under 150 ms

01

What the service must do

02

What the service must do

Functional
  • 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.
Non-Functional
  • 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.
Questions that change the design

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

03

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.

QuantityWorkingResult
Placesassumption, worldwide60M
Searches per day25M x 5125M
Average search rate125M / 86,400 sabout 1,450 / s
Peak search rate3x average at meal timesabout 4,300 / s
Place page views per day25M x 8200M, about 2,300 / s average
Reviews written per dayassumption600K, about 7 / s
Read to write ratio(125M + 200M) / 600Kabout 540 to 1
Place records60M x 2 KB (text, hours, attributes)about 120 GB
Geo index entryid 8 B + lat 4 B + lng 4 B + cell 8 B + rank fieldsabout 32 B
Whole geo index60M x 32 Babout 2 GB, fits in RAM on every node
Reviews stored1.2B reviews x 700 Babout 840 GB
Photo uploads per day1.4M x 3 MB original plus 3 resized copiesabout 5 TB a day into object storage
The number that frees you

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.

Where the bytes really are

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

04

API design

GET/v1/places/search?lat=12.9716&lng=77.5946&radius_m=2000&q=ramen&open_now=true&limit=20Nearby search
// Either lat/lng/radius_m or a viewport bbox=minLng,minLat,maxLng,maxLat { "results": [ { "place_id": "pl_8Hq2", "name": "Kintaro Noodle Bar", "distance_m": 640, "rating": 4.4, "review_count": 812, "price": 2, "open_until": "22:30" } ], "next_cursor": "eyJvIjoyMH0" // opaque, encodes offset plus query hash }
GET/v1/places/{place_id}Place details
{ "place_id": "pl_8Hq2", "location": { "lat": 12.9751, "lng": 77.6003 }, "timezone": "Asia/Kolkata", "hours": { "mon": [["12:00", "15:00"], ["18:30", "22:30"]] }, "rating": 4.4, "top_reviews": [ /* 3 reviews, then paginate */ ], "photos": ["https://img.example-cdn.net/pl_8Hq2/a1_640.webp"] }
POST/v1/places/{place_id}/reviewsWrite a review
// Idempotency-Key header lets a flaky mobile network retry safely { "stars": 5, "text": "Tonkotsu was rich without being heavy. Go before 7.", "photo_ids": ["up_33f"] } // 201 with the review. One review per user per place; a second POST edits it.
POST/v1/uploadsGet a signed photo upload URL
{ "content_type": "image/jpeg", "bytes": 2840112 } // 200 OK, URL valid for 10 minutes, size and type locked into the signature { "upload_id": "up_33f", "put_url": "https://uploads.example.net/...signed" }
Why cursors instead of page numbers

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

05

Architecture overview

Read path for search, write path for reviewsDiagram
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.

Search service

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.

Place service

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.

Review service

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.

Rating aggregator

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

StoreKeyHoldsWhy there
Postgres placesplace_idName, address, lat and lng, zone, hours, attributes, owner, rating count and star sumRelational edits, owner workflows, the source of truth for the index
Review storeplace_id, then created_at descendingStars, text, photo ids, helpful votes, moderation statePaging a place's reviews is one range read on one partition
User reviews indexuser_idPointers to the user's reviewsProfile pages and the one-review-per-place check
Geo index snapshotgeohash cellPacked entries for every place in the cellLoaded into search nodes at boot, patched by deltas
Redisplace card id, or cell plus query hashRendered cards and top-N results for hot cellsAbsorbs 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

06

Deep dive: finding what is nearby

The core question

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.

LengthCell size near the equatorGood for
4about 39 km x 20 kmA whole metro area at low zoom
5about 4.9 km x 4.9 kmSearches of 2 to 10 km
6about 1.2 km x 0.6 kmWalking distance, 200 m to 2 km
7about 150 m x 150 mDense downtown blocks, hotspot splitting

The neighbour query

Turning a radius search into cell lookupsDiagram
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.

Where the latency budget goes

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.

Do not skip the corners

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

07

Decisions and their costs

Chosen
Geohash cells in an in-memory index

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 operate
Alternative
Database spatial index (PostGIS GiST or an R-tree)

Exact 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 point
Chosen
Search engine for text plus geo, when keyword search matters

Elasticsearch 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".

Adopt when text relevance is required
Alternative
Quadtree or S2 cells

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 explain
Chosen
Cache place cards and popular cell results

Place 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 compute
Rejected
Compute every search live with no caching

Simple and always fresh, but meal-time peaks hit the search fleet with the same few thousand queries over and over.

Wastes the hot set
Chosen
Running count and star sum per place

Each 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 recoverable
Rejected
Recompute the average from all reviews on each read

Correct by definition, but a place with 20,000 reviews makes every page view scan them.

Too slow on popular places

07

Failure modes

08

Failure modes

🔥
One place goes viral

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.
🏙️
A dense cell overloads search

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.
🧮
Rating counts drift

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.
🕐
"Open now" shows closed places

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.
📸
Upload URLs are abused

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.
🌍
A region goes down

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

09

How to handle the interview

1

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

2

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.

3

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.

4

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.

5

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.

6

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.

7

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

11

Growing the design in stages

Order matters

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.

Stage 1: one city, a few thousand users
Monolith on Postgres with PostGIS

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.

Stage 2: a country, about a million users
Caching and a CDN

Place cards move to Redis, photos to object storage behind a CDN, and read replicas take search traffic off the primary.

Stage 3: tens of millions of users
Dedicated search service with an in-memory geohash index

Search leaves Postgres. Review events flow through Kafka to an aggregator and an index builder. The PostGIS query on the hot path is retired.

Stage 4: global
Multi-region reads, a text search engine and better ranking

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.