CMD Guide
HomeSystem DesignSystem Design Problems

Designing Yelp or Nearby Friends

A proximity search is fast because you bucket every place into a spatial cell once, so that answering "what's near me?" reads only the handful of cells the query circle touches instead of scanning all 500 million rows and computing 500 million distances. Every design below is the same single move — shrink the candidate set before you measure distance — done with progressively smarter cells.

What we are building

A read-heavy store of places (restaurants, theaters, shops) that answers: given a point (lat, lng) and a radius, return the nearby places, optionally filtered by category and sorted by distance or rating. Users also add/edit places and leave reviews.

Scale & schema

Assume 500M places and 100K queries/sec, growing ~20%/yr. A Place row is LocationID (8B) + Name (256B) + Lat (8B) + Lng (8B) + Description (512B) + Category (1B) ≈ 793 bytes. Reviews and photos live in separate tables keyed by LocationID. The full row store is large and lives on disk; the interesting problem is the spatial index that turns a location into a short candidate list — that is what we design next.

Step 1 — the SQL range query, and why it stalls

Mechanism: store lat and lng in two columns, put a B-tree index on each, and select the bounding box:

SELECT * FROM Places
WHERE Latitude  BETWEEN X-D AND X+D
  AND Longitude BETWEEN Y-D AND Y+D;

Why it fails at 500M rows. The two indexes are independent. The latitude index returns every place in a thin horizontal band that wraps the whole planet — potentially tens of millions of rows; the longitude index returns a vertical band, equally huge. The engine must then intersect two enormous ID lists to keep only the places in the small box where the bands cross. You have paid to read two planet-spanning strips to answer a question about one neighborhood. There is no single index the box maps onto, so latency scales with the band size, not the answer size.

Why the naive box is also incorrect: a fixed degree delta D is not a fixed distance. One degree of longitude is ~69 miles at the equator but ~49 miles at 45°N and shrinks to 0 at the poles. A square degree box over-selects near the equator and under-selects near the poles, and its corners are farther than its edges. Treat lat/lng as Euclidean only to grab candidates; always apply a real spherical distance (haversine) as the final filter.

Step 2 — static grids, and why uniform cells break

Mechanism: chop the map into fixed cells and stamp each place with the GridID it falls in (indexed). Because cells are a fixed size, computing a point's cell and its 8 neighbors is pure arithmetic — no search. Size the cell to the query radius so a nearby search only ever touches the containing cell plus its 8 neighbors:

SELECT * FROM Places
WHERE GridID IN (g, g1, g2, ..., g8)
  AND Latitude  BETWEEN X-D AND X+D
  AND Longitude BETWEEN Y-D AND Y+D;

Keep the index in RAM as a hash map: GridID → list<LocationID>.

Worked memory (with the arithmetic fixed)

QuantityCalculationResult
Cell size (radius = 10 mi)10 mi × 10 mi100 sq mi
Number of grids~200M sq mi ÷ 100 sq mi~2M grids
Grid keys4 B × 2M8 MB
Location IDs8 B × 500M4 GB
Total index8 MB + 4 GB≈ 4 GB

Correction: the commonly-quoted "20 million grids" forgets to square the cell side (it divides by 10 sq mi, not 10×10). A 10-mile cell is 100 sq mi, so it is ~2 million grids. The final ≈4 GB is unchanged because it is dominated by the 8 B × 500M location IDs, not by the tiny grid-key term.

Why uniform cells fail: real places are wildly non-uniform. A 10 mi cell over the Pacific holds a handful of coastal spots; the same-sized cell over downtown San Francisco holds tens of thousands. Every query that lands downtown scans a monster list, so your worst-case latency is set by your densest cell — exactly where traffic is heaviest.

Step 3 — the dynamic quadtree, and why it wins

Mechanism: make the cell size follow the data. Cap a cell at 500 places; when a cell overflows, split it into 4 equal children and redistribute. Dense downtown ends up as a deep pile of tiny cells; the open ocean stays one giant cell. A cell that can no longer split is a leaf and holds the actual place list; internal nodes are just 4 child pointers. That four-way tree is a quadtree, and it guarantees every leaf holds ≤500 places regardless of density — so worst-case scan cost is bounded everywhere.

Worked memory

QuantityCalculationResult
Cached per place (ID + lat + lng)8 + 8 + 824 B
Location data24 B × 500M12 GB
Leaf nodes500M ÷ 5001M leaves
Internal nodes (~⅓ of leaves)1M ÷ 3~333K
Internal-node pointers333K × 4 ptrs × 8 B≈ 10 MB
Total quadtree12 GB + 10 MB≈ 12.01 GB

The tree skeleton (10 MB) is a rounding error next to the 12 GB of location data — and 12 GB fits in RAM on a single modern server. That is the counter-intuitive punchline: the size of a planet-scale spatial index is small. The hard problems are fan-out, rebuild, and skew, not capacity.

diagram
diagram

The three operations, made concrete

Build. Start with one node covering the whole world; insert every place; whenever a leaf exceeds 500, split it into NW/NE/SW/SE and re-insert. Find the leaf for a point is a top-down walk: at each internal node compare the point to the node's midpoint and step into the one child quadrant that contains it — O(depth), no scanning. Find neighbors is a range query: recurse from the root but prune any child whose bounding box cannot intersect the query circle.

CAPACITY = 500

class Node:
    def __init__(self, bounds):      # bounds = (min_lat, min_lng, max_lat, max_lng)
        self.bounds   = bounds
        self.places   = []           # populated only on leaves
        self.children = None         # 4 Nodes once split; None => leaf

    def is_leaf(self):
        return self.children is None

def child_for(node, lat, lng):       # which quadrant holds the point?
    min_lat, min_lng, max_lat, max_lng = node.bounds
    mid_lat = (min_lat + max_lat) / 2
    mid_lng = (min_lng + max_lng) / 2
    north = lat >= mid_lat
    east  = lng >= mid_lng
    if north and not east: return node.children[0]   # NW
    if north and east:     return node.children[1]   # NE
    if not north and not east: return node.children[2]  # SW
    return node.children[3]                              # SE

def find_leaf(node, lat, lng):       # top-down descent, O(depth)
    while not node.is_leaf():
        node = child_for(node, lat, lng)
    return node

def split(node):
    lo_lat, lo_lng, hi_lat, hi_lng = node.bounds
    mid_lat, mid_lng = (lo_lat + hi_lat) / 2, (lo_lng + hi_lng) / 2
    node.children = [
        Node((mid_lat, lo_lng, hi_lat, mid_lng)),    # NW
        Node((mid_lat, mid_lng, hi_lat, hi_lng)),    # NE
        Node((lo_lat, lo_lng, mid_lat, mid_lng)),    # SW
        Node((lo_lat, mid_lng, mid_lat, hi_lng)),    # SE
    ]
    moving, node.places = node.places, []
    for p in moving:
        insert(node, p)              # push down into the new children

def insert(node, p):                 # p has .lat, .lng
    if node.is_leaf():
        node.places.append(p)
        if len(node.places) > CAPACITY:
            split(node)
    else:
        insert(child_for(node, p.lat, p.lng), p)

def search(node, center, radius, out):   # center = (lat, lng)
    if not circle_intersects_box(center, radius, node.bounds):
        return                        # prune: this whole subtree is too far
    if node.is_leaf():
        for p in node.places:
            if haversine(center, (p.lat, p.lng)) <= radius:
                out.append(p)
    else:
        for c in node.children:
            search(c, center, radius, out)

Why the single-cell shortcut is wrong. A tempting version just calls find_leaf and returns that one cell's places. But a place sitting a few meters across the leaf boundary is inside your radius yet lives in the neighbor leaf — so a single-cell answer silently drops results near every edge. You must visit all leaves the circle overlaps. search() above does this correctly by pruning on box-intersection. When you instead want k-nearest with a growing radius, chain the leaves (see below) and walk outward until you have k or hit the max radius.

diagram
diagram

Traced example: nearby restaurants in San Francisco

Query point (37.77, −122.42), radius 0.5 mi, category restaurant, want 20 results. The descent compares the point to each node's midpoint and steps into exactly one child (all midpoints below are the true arithmetic centers of each cell):

LevelCell bounds (lat × lng)MidpointPoint vs midDescend to
0 (root)[−90, 90] × [−180, 180](0, 0)N, WNW
1[0, 90] × [−180, 0](45, −90)S, WSW
2[0, 45] × [−180, −90](22.5, −135)N, ENE
3[22.5, 45] × [−135, −90](33.75, −112.5)N, WNW
4[33.75, 45] × [−135, −112.5](39.375, −123.75)S, ESE
5–13…9 more splits — SF is dense, so this region subdivided deeply…leaf

Now the range phase, walking outward via the sibling chain until we hit 20. One mechanism note: a plain doubly-linked leaf chain orders leaves by list position, not compass direction — so "north" and "east" neighbors are located either by re-descending from the shared parent with a point nudged just across the boundary, or by chaining leaves in Z-order/Hilbert order so spatial neighbors are usually (not always) chain-adjacent:

#ActionIn cellPass radius + categoryRunning total
1Scan the leaf we landed in; haversine ≤ 0.5 mi & restaurant4861212
212 < 20 → walk to north sibling leaf301517
317 < 20 → walk to east sibling leaf228421 ✓
421 ≥ 20, and these 3 leaves are every leaf the 0.5 mi circle overlaps → safe to stop, sort by distance, return top 20 (stopping at count ≥ k before covering the circle would risk missing a closer place in an unscanned leaf)done

Total places actually distance-checked: 486 + 301 + 228 ≈ 1,015 — versus 500,000,000 for a full scan. The haversine step in row 1 is what corrects the earlier lat/lng-box distortion: the cells give candidates, real spherical distance gives the answer.

Scaling out: partitioning, rebuild, and serving

12 GB fits one box today, but 20%/yr growth and 100K QPS force partitioning across many quadtree servers. Two schemes:

Rebuild after failure. A quadtree server is a rebuildable cache, not the source of truth — the row store is. If both a primary and its replica die, brute-force rebuild means scanning the entire places table and re-hashing every LocationID: slow, and the shard serves nothing meanwhile. Fix with a QuadTree Index server — a reverse map server# → HashSet<place (LocationID, lat, lng)> — so a fresh server asks for exactly its places and rebuilds in seconds. Replicate that index too. Reads scale via primary/secondary replicas (secondaries lag a few ms — fine for near-static places). Hot places sit behind an LRU cache (Memcached). Ranking by popularity is precomputed and refreshed once or twice daily off-peak — updating the tree on every review would thrash search throughput for a signal that need not be real-time.

Pitfalls

When to reach for a quadtree — and when not

Reach for it when your data is point-like, highly non-uniform in density (real geography always is), mostly static, small enough to hold in RAM, and you control the code and want cell resolution to adapt automatically to density. That is exactly Yelp.

Decision signals vs named alternatives

AlternativeMechanismYou gainIt costs
Static gridFixed cells; O(1) cell mathDead simple; trivially shardableDense cells blow up latency — no density adaptation
Geohash / Redis GEOInterleave lat/lng bits into a base-32 string; prefix = cell; GEOSEARCHNo tree to build/maintain; off-the-shelf store; prefix = shard keyFixed precision per query; fiddly neighbor cells; cell size varies with latitude
Google S2 / Uber H3Hierarchical cells on the sphere (S2 = Hilbert curve on cube faces; H3 = hexagons)No lat/lng distortion; near-uniform cells; clean, equidistant neighborsA library dependency and more concepts to hold
PostGIS R-tree (GiST)Bounding-box tree in the DB; ST_DWithinExact geometry, polygons, transactions, one systemSingle-DB scaling ceiling; slower than an in-RAM index at 100K QPS

Choose the quadtree when data is skewed, static, point-based, and RAM-resident and you want adaptive resolution. Prefer geohash/Redis when you'd rather lean on an existing store and can accept fixed precision and simpler ops. Prefer S2/H3 when objects move on a globe and clean neighbor math matters — which is why ride-sharing (Uber) uses them. Prefer PostGIS when the dataset fits one database and you need rich geometry and transactional correctness more than raw QPS.

Takeaways


Re-authored and deepened for this guide. Sources: Grokking the System Design Interview (Designing Yelp / Nearby Friends — Proximity Server); the QuadTree data structure (Finkel & Bentley, 1974); Google S2 Geometry and Uber H3 hierarchical spatial indexing; Redis GEOADD/GEOSEARCH geohash commands; and PostGIS ST_DWithin / GiST R-tree indexing. Memory figures and the San Francisco descent trace were recomputed and verified; the widely-copied "20 million grids" figure is corrected to ~2 million (a 10-mile cell is 100 sq mi, and the ≈4 GB index is unchanged because it is dominated by the 8 B × 500M location IDs).

Observability: the three signals that tell you the index is misbehaving

Three metrics catch the failure modes above before users feel them. Leaf-size p99: a leaf stuck at the 500 cap that will not split is a degenerate-coordinate hotspot (the food-court-single-point case) — page on it. Rebuild time per shard: if it climbs, the QuadTree Index reverse map is stale or missing and a crash will become a coverage gap. Haversine drop rate: the fraction of gathered candidates the final spherical-distance filter rejects — a high drop rate means the cells are too coarse (you are scanning far more places than you return), so tighten the leaf cap or cell resolution. These are the observable versions of the pitfalls: dense-leaf skew, slow rebuild, and over-coarse candidate gathering.

Drill ladder

  1. L1: Why Euclidean lat/lng distance is wrong.
  2. L2: Boundary miss — query circle overlaps N cells.
  3. L3: Size 500M places × 8 B id ≈ 4 GB ids — total index ~12 GB claim defend.
  4. L4: Region vs hash sharding trade-off for Super Bowl weekend hotspot.
  5. L5: Defend S2/H3 over quadtree for drivers.
🔨 Practice this hands-on — Design Yelp / Proximity Service →
Attempt it from an empty file, break it to feel the failure, then defend it under pushback.
🤖 Don't fully get this? Learn it with Claude

Stuck on Designing Yelp or Nearby Friends? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Designing Yelp or Nearby Friends** (System Design) and want to truly understand it. Explain Designing Yelp or Nearby Friends from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Designing Yelp or Nearby Friends** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Designing Yelp or Nearby Friends** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Designing Yelp or Nearby Friends** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes