Medium

Design a Proximity Service (Yelp / Nearby)

A location-based service that returns businesses near a user, sorted by distance and relevance. The core challenge is indexing 2D geospatial data so radius queries stay fast at hundreds of millions of businesses, while keeping business data reasonably fresh.

1Requirements

Functional

  • Return all businesses within a user-specified radius (0.5 km to 20 km) of a lat/lng point
  • Support pagination and sorting by distance, rating, or a combined relevance score
  • Business owners can add, update, and delete their business listings
  • Return business detail pages (hours, photos, reviews summary) by business id
  • Filter results by category (restaurants, gas stations, etc.)

Non-functional

  • Low latency: p99 under 200 ms for nearby search
  • Read-heavy workload: roughly 1000:1 read to write ratio, optimize for reads
  • High availability: search should degrade gracefully rather than fail
  • Eventual consistency is acceptable: a business update can take up to a minute to appear in search
  • Handle uneven density: Manhattan has thousands of businesses per km2, rural areas nearly none

2Back-of-envelope estimation

Businesses200 million
Daily active users100 million
Search QPS~5,800 average, ~12,000 peak
Business write QPS~10
Index storage~20 GB
Full business data~2 TB

3API design

GET /v1/search/nearby?lat={lat}&lng={lng}&radius={m}&category={c}&page={p}

Core search. Returns a page of business summaries (id, name, distance, rating) sorted by relevance. Radius is clamped server-side to supported precision tiers.

GET /v1/businesses/{id}

Full business detail. Served from the business service with a CDN/cache layer since detail pages are hot and change rarely.

POST /v1/businesses

Create a listing. Writes to the business DB, then asynchronously updates the geo index (directly or via a nightly rebuild plus incremental log).

PUT /v1/businesses/{id}

Update a listing. Same async index propagation as create.

DELETE /v1/businesses/{id}

Tombstone the listing so the index can filter it out before the next rebuild.

4High-level design

Split the system into two services: a stateless Location-Based Search (LBS) service that answers nearby queries, and a Business service that owns CRUD on business data. They scale independently because the workloads are wildly different: LBS is read-hot and latency sensitive, the business service is a boring CRUD API in front of a relational database with replicas.

The heart of the LBS is a geospatial index. The three mainstream options are geohash, quadtree, and Uber's H3. Geohash encodes lat/lng into an interleaved base32 string where a shared prefix implies spatial proximity, so a radius query becomes a prefix match over the target cell plus its 8 neighbors. A quadtree recursively splits the map until each leaf holds under ~100 businesses, adapting naturally to density but living in memory and needing rebuilds. H3 uses hexagons, which have the nice property that all neighbors are equidistant, making ring-based expansion cleaner. For an interview, geohash on top of a plain database index is the simplest defensible answer.

Query flow: the client sends lat/lng and radius. The LBS picks a geohash precision matching the radius (precision 5 is about 4.9 x 4.9 km, precision 6 about 1.2 x 0.6 km), computes the center cell and its 8 neighbors, fetches candidate business ids from the index for those 9 prefixes, computes exact haversine distance to filter false positives from cell corners, then ranks and hydrates the top N from the business service or a cache.

Writes take the slow path. Business updates land in the business DB immediately, and the geo index is updated asynchronously: either incrementally (insert/delete the geohash row) or via a periodic rebuild for the in-memory quadtree variant. Because the index rows are tiny, the whole index replicates cheaply across many read replicas, and a fresh replica can rebuild from the DB in minutes.

5Data model

business

business_id (PK), name, address, city, country, latitude, longitude, category, rating, hours_json, created_at, updated_at

Source of truth, relational DB with read replicas

geo_index

geohash (char 6), business_id, PRIMARY KEY (geohash, business_id)

One row per business. Compound key makes prefix scans and dedup trivial; no need for a JSON list per cell, which would create update contention

business_category

category, business_id, PRIMARY KEY (category, business_id)

Optional inverted list for category filtering before distance ranking

6Deep dives

Geohash vs quadtree vs H3

Geohash is a static grid: pick a precision, and every cell at that precision has the same size. Its killer feature is that it turns 2D proximity into 1D string prefix matching, so a vanilla B-tree index on a geohash column supports radius queries with no special database extensions. Its two weaknesses are boundary effects (two points meters apart can sit in different cells, even with non-matching prefixes across major grid lines) and fixed granularity (a dense downtown cell can hold 10,000 businesses). The boundary problem is solved by always querying the center cell plus 8 neighbors; the density problem by capping and paginating within a cell or dropping to a finer precision in hot areas.

A quadtree adapts to density: recursively subdivide any node holding more than ~100 businesses. Searches walk down to the leaf containing the query point and expand to sibling leaves until enough candidates are found. The tree for 200M businesses is only a few GB and fits in memory, giving very fast lookups, but it is an in-memory structure you must build (minutes at startup), rebuild or patch on updates, and warm on every new server, which complicates deploys and autoscaling.

H3 tiles the earth with hexagons at 16 resolutions. Hexagons have uniform neighbor distance (squares have diagonal neighbors ~41 percent farther), which makes k-ring expansion for radius search more accurate and is why ride-sharing companies use it for supply/demand smoothing. In an interview: lead with geohash for simplicity, mention quadtree when asked about density adaptation, mention H3 when the domain involves movement and ring-based aggregation.

Choosing precision and handling the radius query

Map the requested radius to the smallest geohash precision whose cell fully covers it: 20 km maps to precision 4 (~39 x 19.5 km), 5 km to precision 5 (~4.9 x 4.9 km), 1 km to precision 6 (~1.2 x 0.6 km). Then fetch all rows whose geohash starts with the center cell prefix or any of its 8 neighbors. Using LIKE 'prefix%' on the indexed column (or storing exactly at query precision) keeps this a handful of index range scans.

Candidates from 9 cells form a superset of the true radius, so compute exact haversine distance per candidate and discard anything outside the radius. This filter step is cheap: even a dense query returns a few thousand candidates, and a few thousand haversine evaluations cost well under a millisecond.

Ranking rarely stops at raw distance. A practical relevance score blends distance decay, rating, review count, and open-now status, for example score = w1 * exp(-distance/1km) + w2 * rating_normalized + w3 * log(1 + review_count). Keep ranking in the LBS layer so you can iterate without touching the index.

Keeping the index fresh under business updates

Writes are ~10 QPS against ~12,000 read QPS, so never let writes contend with reads on the hot path. The clean pattern: business service commits to its DB, emits an event (or the LBS tails a change log), and an indexer applies the delta to geo_index rows. A moved business is a delete of the old (geohash, id) row plus an insert of the new one.

If you use in-memory quadtrees instead, incremental tree surgery across a replica fleet is fiddly, so most designs accept staleness: rebuild the tree nightly from a DB snapshot and roll replicas gradually so the fleet never rebuilds at once. A one-minute to one-day staleness window is explicitly acceptable per requirements; the business detail page (served from the DB) is always fresh, so users rarely notice index lag.

Deletes need care: a tombstoned business may linger in the index until the next delta or rebuild, so the hydration step should drop ids that no longer resolve in the business service rather than render ghosts.

7Rapid implementation: build the MVP

Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.

Stack

Postgres (Neon free tier) with earthdistance + cube extensions, Next.js API routes on Vercel free tier, ngeohash npm package

  1. 01Create a Neon Postgres database and run CREATE EXTENSION cube; CREATE EXTENSION earthdistance;
  2. 02Create the businesses table with latitude, longitude, and a geohash char(6) column
  3. 03Seed 100k rows from the free OpenStreetMap POI extract for one city (osmnx or a CSV dump)
  4. 04Add a B-tree index on geohash and a GIST index on ll_to_earth(latitude, longitude) so you can benchmark both strategies
  5. 05Install ngeohash and write a /api/nearby route: encode the query point at precision 6, compute the 8 neighbors, query WHERE geohash IN the 9 cells
  6. 06Filter candidates by exact haversine distance in TypeScript and sort by a score of distance decay plus rating
  7. 07Add a Redis (Upstash free tier) cache keyed by center-cell geohash plus radius with a 60 second TTL
  8. 08Deploy to Vercel and load-test the endpoint with autocannon to confirm sub-100 ms p99 on the cached path

Geohash neighbor search (query handler)

typescript
import geohash from "ngeohash";
import { sql } from "./db";

const PRECISION_FOR_RADIUS = [
  { maxMeters: 1000, precision: 6 },
  { maxMeters: 5000, precision: 5 },
  { maxMeters: 20000, precision: 4 },
];

export async function nearby(lat: number, lng: number, radiusM: number) {
  const { precision } = PRECISION_FOR_RADIUS.find(
    (t) => radiusM <= t.maxMeters
  ) ?? { precision: 4 };

  const center = geohash.encode(lat, lng, precision);
  const cells = [center, ...geohash.neighbors(center)];

  // geohash column is stored at precision 6; prefix match covers coarser cells
  const rows = await sql(
    "SELECT id, name, rating, latitude, longitude FROM businesses " +
      "WHERE " + cells.map((_, i) => "geohash LIKE $" + (i + 1)).join(" OR "),
    cells.map((c) => c + "%")
  );

  return rows
    .map((r) => ({ ...r, distanceM: haversine(lat, lng, r.latitude, r.longitude) }))
    .filter((r) => r.distanceM <= radiusM)
    .sort((a, b) => a.distanceM - b.distanceM)
    .slice(0, 20);
}

Haversine distance

typescript
export function haversine(lat1: number, lng1: number, lat2: number, lng2: number): number {
  const R = 6371000; // earth radius in meters
  const toRad = (d: number) => (d * Math.PI) / 180;
  const dLat = toRad(lat2 - lat1);
  const dLng = toRad(lng2 - lng1);
  const a =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
  return 2 * R * Math.asin(Math.sqrt(a));
}

Schema and the earthdistance alternative

sql
CREATE TABLE businesses (
  id         BIGSERIAL PRIMARY KEY,
  name       TEXT NOT NULL,
  category   TEXT,
  rating     REAL DEFAULT 0,
  latitude   DOUBLE PRECISION NOT NULL,
  longitude  DOUBLE PRECISION NOT NULL,
  geohash    CHAR(6) NOT NULL
);

CREATE INDEX idx_businesses_geohash ON businesses (geohash);
CREATE INDEX idx_businesses_earth
  ON businesses USING GIST (ll_to_earth(latitude, longitude));

-- Alternative radius query without geohash, using earthdistance directly:
SELECT id, name,
       earth_distance(ll_to_earth(latitude, longitude), ll_to_earth(40.7484, -73.9857)) AS meters
FROM businesses
WHERE earth_box(ll_to_earth(40.7484, -73.9857), 1000) @> ll_to_earth(latitude, longitude)
ORDER BY meters
LIMIT 20;

Bottlenecks & failure modes

  • Dense cells: a single precision-5 cell in Manhattan can hold tens of thousands of businesses; mitigate with finer precision in hot regions, per-cell result caps, and category pre-filtering
  • Hot geographic keys: everyone in a stadium queries the same 9 cells; cache (cell, radius, category) result lists in Redis with a short TTL
  • Cross-cell boundary queries always cost 9 index lookups; batch them into one range-scan query rather than 9 round trips
  • Hydrating full business records for ranking can dominate latency; store denormalized rank fields (rating, review_count) alongside the index or in a cache
  • Index replica warm-up after deploys (quadtree variant) causes cold-start latency spikes; use blue-green rollout with pre-warmed snapshots

Key takeaways

  • Turn a 2D problem into a 1D one: geohash prefix matching lets an ordinary B-tree answer radius queries
  • Always query the center cell plus 8 neighbors, then filter by exact haversine distance to fix boundary false negatives
  • Separate the tiny hot geo index (GBs, replicate everywhere) from the large cold business data (TBs, cache in front)
  • Exploit the 1000:1 read/write skew: async index updates, aggressive read replication, eventual consistency by design
  • Know the trade triangle: geohash is simplest, quadtree adapts to density, H3 gives uniform neighbor geometry

Brush up on the underlying topics