Design nearby places
"Coffee near me" sounds like a simple filter, but a database index sorts things in one dimension and the map has two. The whole design turns on one idea: turn 2-D positions into 1-D keys (cells) that an ordinary index can range-scan, and choose cells that stay small where places are dense and large where they're sparse. It follows the interview framework phase by phase.
1. The prompt
"Design Yelp, or 'nearby places': users search for businesses near a location, optionally filtered by category and rating, sorted by distance or relevance. Owners add and update listings."
Unlike ride hailing (D13), places barely move: the data changes slowly and is read constantly. The interview is about the spatial index and about serving 50,000 searches a second from something small enough to copy everywhere.
2. Frame the problem
How many places, and how many searches?
About 200M places; 50,000 searches a second at peak.
The index will fit in memory; reads dominate.
What radius?
From about 500 m in a city to 25 km in the countryside.
Cell size must adapt to density and radius.
Filters?
Category, rating, open now; free-text search for names.
A search engine combines text, filters and geo well.
How fresh must edits be?
A new or changed listing can take a few minutes to appear.
The index can be rebuilt or updated in batches.
Do places move?
No (food trucks aside).
Static locations: no high-rate updates, unlike D13.
Ranking?
Distance and rating to start; personalisation later.
Filter by geo first, then rank a candidate set.
3. What it must do
- A user can search for places within a radius of a point, with filters.
- A user can view a place's details and reviews.
- Owners can add or edit places; changes appear within minutes.
Not now:
- Routing: a separate system.
- Moving objects: that's D13.
- Review moderation.
4. How well it must do it
| Target | Why this number |
|---|---|
| Fast: results under ~200 ms speed | Search is interactive, often on mobile. |
| Read-heavy scale: 50,000 searches a second load shape | Edits are ~1 a second; reads are 50,000 times more. |
| Available: stale by minutes is fine; errors aren't consistency or availability | A café's listing being a few minutes old harms nobody. |
| Correct distances: results really are within the radius, nearest first consistency | "Near me" that isn't near breaks trust. |
5. Numbers that change the design
| Quantity | Working | → so we… |
|---|---|---|
| Index size | 200M places × (id 8 B + lat/lng 16 B + cell key 8 B) | ~6.4 GB → so the geo index fits in memory on one machine; replicate it for reads |
| Place details | 200M × ~2 KB | ~400 GB → so a sharded store, cached for popular places |
| Search load | 50,000 a second | A few dozen read replicas or search nodes; caching popular cells helps |
| Edits | ~100,000 new or changed places a day | ~1 a second → so index updates can be batched |
6. The nouns
- Place.
- Geo index entry: a cell key and a place.
- Review.
- Owner.
places place_id, name, lat, lng, category, rating, hours, owner_id, updated_at (source of truth, sharded by place_id) geo index cell_key (geohash / quadtree leaf / S2 cell) → [place_id, lat, lng, category, rating] reviews (place_id, review_id) → author, stars, text
7. The contract
GET /v1/search?lat=…&lng=…&radius=2000&category=coffee&cursor=… → {places: [...], next_cursor}
GET /v1/places/{id} → details
POST /v1/places → create · PATCH /v1/places/{id} → edit (owner from the session)
Data flow: (1) owners write to the places database → (2) a change stream (or a rebuild every few minutes) updates the geo index replicas → (3) a search finds cells overlapping the circle → (4) exact distance check, filter, rank → (5) hydrate details.
8. First working version
A places table with lat/lng columns and indexes, and a search service that queries it.
8.1 Searching
Query a bounding box around the point on the indexed columns, then compute exact distances and sort.
8.2 Viewing a place
Read the place and its first page of reviews by id.
8.3 Editing
Owners update their row; the next search sees it.
Parked for later (section 9 fixes each)
- An index on latitude scans a band around the whole planet.
- Two close places can sit on either side of a cell boundary.
- Where the index lives, and how it scales to 50,000 searches a second.
- Dense downtown cells versus empty countryside cells.
- Ranking, filters and caching popular areas.
9. Make it hold up
9.1 Why a normal index isn't enough, and what is fast
WHERE lat BETWEEN a AND b AND lng BETWEEN c AND d with an index on lat finds every place in a thin band of latitude around the whole planet, then filters by longitude. A composite index on (lat, lng) has the same problem: it is sorted by latitude first. You need a key where nearby on the map means nearby in the index.
| Approach | Why | |
|---|---|---|
| ✗ Bad | Index on latitude and longitude columns | One-dimensional sort order; a radius query scans a huge band. |
| ~ Good | Geohash: interleave the bits of latitude and longitude into one string; a shared prefix means a shared cell (6 characters ≈ 1.2 km × 0.6 km) | A plain B-tree can range-scan a prefix. But cells are fixed-size everywhere: a downtown cell holds thousands of places, a rural one none. |
| ✓ Great | Adaptive cells: a quadtree that splits busy cells, or a database's spatial index (PostGIS's R-tree via GiST, a search engine's geo field); or hierarchical cells (Google S2, Uber H3) used at a level matched to the radius | Cells stay small where data is dense, so a query looks at few non-matching points. Try it in section 13. |
9.2 Edges: searching neighbours too correct distances
Two cafés across the street can be in different cells with different geohash prefixes. A query must cover every cell that overlaps the search circle (typically the centre cell and its 8 neighbours at a suitable precision), then compute real distances to filter and sort. The cells are a coarse filter; the distance check gives the exact answer.
9.3 Where the index lives read-heavy scale
- Simplest good answer: PostgreSQL with PostGIS (or Elasticsearch or OpenSearch with a geo field), with read replicas. Category and rating filters combine naturally with the geo filter, and search engines add text relevance.
- Custom in-memory service: because the index is only a few GB, each search server can hold a full copy (a quadtree or sorted cell keys) built from the database and refreshed every few minutes from a change stream. Reads never touch the database until the details lookup.
- Sharding: sharding the geo index by region makes hot cities hot shards; with an index this small, replication beats sharding. Shard the place-details store by place id.
9.4 Dense cities and empty countryside fast
A fixed grid is a bad fit for both: in Manhattan one cell holds thousands of places and every query examines most of them; in the countryside a 2 km search needs dozens of empty cells. A quadtree splits a cell only when it holds more than, say, 100 places, so cell size follows density. With S2 or H3, choose the cell level from the radius: small radius, fine cells; large radius, coarse cells.
9.5 Filtering, ranking and caching fast · read-heavy scale
Filter first by geo and category, then rank the candidate set (distance, rating, open now, personalisation) and paginate with a cursor. Round the query point to a cell and cache results per (cell, radius, category) for popular areas; city centres repeat constantly. Place details come from a cache keyed by place id.
9.6 Keeping the index fresh available
Edits go to the places database, the source of truth. A change stream (change data capture) feeds index updates to every replica within minutes; alternatively, rebuild the whole index every few minutes and swap it atomically. Either way, searches never wait on writes.
9.7 The whole design after the deep dives
A search, and a new café appearing, through the final design.
10. Interviewer follow-ups
Search along a route ("coffee on my way").
Cover the route with a corridor of cells (the cells within a buffer of the path's segments) and rank by detour time from the routing service.
Traffic grows 10×.
More replicas of the in-memory index (it's small), more cache, and CDN caching for anonymous popular searches. The source database barely changes.
"Open now" in every time zone.
Store opening hours with the place's time zone; evaluate at query time in that zone. Precompute an "open now" bitmap every few minutes if the check is too slow.
Food trucks that move.
Treat them like D13 drivers: a small, separately updated in-memory index merged into results.
Personalised ranking.
Geo and filters produce candidates; a ranking model reorders them using the user's history, within a time budget, with distance-and-rating as the fallback.
Why not shard by region?
Popular cities would become hot shards while rural ones sit idle. With a 6 GB index, copying it everywhere is simpler and balances naturally.
Spam listings.
Verification for owners, duplicate detection by name and location, and review-based trust signals before a listing ranks well.
Variation: searching places to stay by date
"Design the search for a holiday-rental site, like Airbnb" adds dates and guests to the map search. A listing matches only if it's in the area, fits the group, and is free every night of the stay, and the price depends on those nights.
- Availability as a calendar per listing. One bit per night for the next year is 46 bytes a listing; 10 million listings fit in under half a gigabyte, so the whole calendar can sit in memory next to the search.
- Search in stages. The location index finds candidates in the map area; the calendar check removes listings that aren't free; pricing totals the stay; then rank and page. Each stage shrinks the list for the next.
- Search may be slightly stale. A booking updates the calendar within seconds, so a listing booked a moment ago can still appear. The booking step re-checks (Design D15), and the page says the dates just went.
- Cache the stable part. Dates multiply the possible queries, so caching whole results rarely hits. Cache the area's candidate list instead, and run the calendar and price stages on every search.
11. When things break
| Failure | What users see | How we detect it | What limits the damage |
|---|---|---|---|
| Index replica down | Nothing | Health checks | Other replicas serve |
| Change stream stalls | New edits appear late | Index age alert | Searches keep working on the last index |
| Places DB slow | Slow details pages | Read latency | Details cache; search results don't need the DB |
| Hot area (festival) | Nothing, ideally | Request rate per cell | Cell-result cache; replicas |
12. Wrap-up and the clock
Trade-offs we made:
- Cells that turn 2-D into 1-D keys.
- Adaptive cells (quadtree or S2/H3 levels) over a fixed grid.
- A small index replicated everywhere instead of sharded.
- Minutes of freshness via change streams.
- Caching per cell.
What we'd watch:
- Search p99.
- Places examined per result.
- Index age.
- Cache hit rate per cell.
- Replica load.
At 10×:
- More replicas.
- Edge caching.
- Per-region indexes if data outgrows memory.
A 45-minute interview
| 0–4 | Frame the problem (counts, radius, freshness) |
| 4–9 | Must do, how well, numbers (6 GB index) |
| 9–12 | Nouns, contract, data flow |
| 12–18 | First working version, parked list |
| 18–40 | Deep dives: indexes (9.1), edges (9.2), where it lives (9.3), density (9.4) |
| 40–45 | Follow-ups and wrap-up |
A 60-minute interview
| 0–5 | Frame the problem |
| 5–11 | Must do, how well, numbers |
| 11–15 | Nouns, contract, data flow |
| 15–23 | First working version |
| 23–50 | Deep dives 9.1–9.6 and the final picture |
| 50–60 | Follow-ups: routes, open now, moving places; wrap-up |
| Level | What a strong answer shows |
|---|---|
| Mid-level | Why lat/lng columns don't index well, geohash or a spatial index, a places DB with read replicas. |
| Senior | Neighbour cells for edges, geohash vs quadtree vs PostGIS/search engine with trade-offs, the in-memory estimate, caching by cell, filter-then-rank. |
| Staff+ | Density-aware cells (S2/H3 levels), replication over sharding for a small hot index, the freshness pipeline from the source of truth, ranking and personalisation, multi-region serving. |
Common mistakes:
- Two-column range queries.
- Forgetting neighbour cells.
- One cell size for everywhere.
- Sharding a small index by region.
- Skipping the exact distance check.
13. Try it, check yourself, read more
400 places: most in a dense downtown, the rest spread out. Tap the map to move the search circle. Orange cells are the ones the index must read; orange points are examined but outside the circle; teal points are the answers.
Why doesn't an index on (lat, lng) answer radius queries well?
Why does a geohash query also check neighbouring cells?
What does a quadtree do better than a fixed grid?
The geo index is ~6 GB. Shard it or replicate it?
| Resource | Why |
|---|---|
| Spatial indexing (PostGIS workshop) | How a spatial index uses bounding boxes, and how to query it. |
| S2 cell hierarchy (S2 Geometry) | Hierarchical cells on a sphere, mapped to 64-bit keys. |
| Design Yelp (Hello Interview) | Another walkthrough. |