← System Design Simulator

Proximity Service (Yelp-style 'Nearby') System Design Interview Question

By Rahul Kumar · Senior Software Engineer · Updated · 8 components · 3 operations ·Source: Alex Xu, System Design Interview Vol 2, Chapter 1

Problem: Design a proximity / 'find businesses near me' service like Yelp, Google Maps nearby, or DoorDash restaurant search.

Overview

A proximity service answers the deceptively simple question, 'what is near me right now?'. Yelp, Google Maps nearby, DoorDash restaurant search, Uber pickup lists, and Tinder swipe stacks all share the same core primitive: given a latitude, longitude, and radius, return the top N points of interest ranked by distance and relevance in under one hundred milliseconds. The difficulty is not the math, it is the skew. Times Square, central Tokyo, and a few hundred other cells on Earth absorb almost half the global query volume, while vast oceans, deserts, and suburbs receive almost nothing. A good design treats the world as a key-value store keyed by geohash cells, caches the hot cells aggressively, and falls back to a PostGIS GiST index for the long tail. This SEO supplement expands on the widget with runnable Java sketches for geohash encoding, neighbour-cell expansion, and the REST endpoint so you can lift the ideas straight into an interview answer or a side project.

Proximity Service (Yelp-style 'Nearby') — Interactive Simulator

Runs fully client-side in your browser; no sign-up. Or open full screen →

Launch the interactive walkthrough for Proximity Service (Yelp-style 'Nearby') — animated architecture diagram, step-by-step flow with real payloads, component swap, and a discrete-event stress simulator.

Summary

A read-heavy geospatial lookup service that must answer 'all restaurants within R meters of (lat, lng)' in under ~100ms p99. The dominant design choice is a pre-computed geohash (or quadtree) index: every business is tagged with its geohash cell, and a radius query becomes 'fetch businesses in this cell + 8 neighbor cells'. A Redis layer caches hot cells (Times Square, downtown SF) which absorb the vast majority of queries; PostGIS on Postgres is the authoritative store with GiST indexes for exact radius filtering. The main tradeoff is precision vs cacheability: coarse geohash cells (6 chars ≈ 1.2 km) cache beautifully but include far-away businesses that need post-filtering; fine cells (8 chars ≈ 38 m) need 8+ cell fetches per query. Sized for 500M DAU with ~5B nearby-search QPS/year.

Requirements

Functional

Non-functional

Capacity Assumptions

Back-of-Envelope Estimates

High-level architecture

The hot path is a three-tier pipeline: a stateless Proximity API fleet behind an L7 load balancer, a Redis cluster keyed by geohash cell, and a sharded PostGIS cluster for authoritative data. When a /nearby request arrives the API computes an eight-character geohash of the query point (roughly 38 m cells), then trims characters until the cell width exceeds the requested radius; for a 500 m radius that lands on a six-character prefix covering about 1.2 km. The API fetches that prefix plus its eight neighbour cells from Redis in a single MGET. On a cache hit it post-filters each candidate by true haversine distance, sorts, and returns. On a miss it queries PostGIS with ST_DWithin against the same cells, writes the result back to Redis with a 60 s TTL, and returns. Writes go straight to PostGIS and emit a Kafka event that the cache invalidator consumes, evicting the affected cells within one second. A separate admin pipeline replicates partner feeds into PostGIS through a bulk COPY job every fifteen minutes. The service is deployed per region; each region holds a full PostGIS replica and its own Redis, so a cross-region outage only drops writes for its geographic slice. Observability is routed through the cache hit ratio, p99 latency per radius bucket, and PostGIS GiST index scan counts, all fanned into a Grafana board that operators watch during zoom-heavy events like Super Bowl Sunday.

Architecture Components (8)

Operations Walked Through (3)

Implementation

Geohash encoder (8-character precision)
public final class GeoHash {
    private static final String BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";

    public static String encode(double lat, double lon, int precision) {
        double[] latRange = {-90.0, 90.0};
        double[] lonRange = {-180.0, 180.0};
        StringBuilder hash = new StringBuilder(precision);
        boolean evenBit = true;
        int bit = 0;
        int ch = 0;
        while (hash.length() < precision) {
            double[] range = evenBit ? lonRange : latRange;
            double value = evenBit ? lon : lat;
            double mid = (range[0] + range[1]) / 2.0;
            if (value >= mid) {
                ch = (ch << 1) | 1;
                range[0] = mid;
            } else {
                ch = ch << 1;
                range[1] = mid;
            }
            evenBit = !evenBit;
            if (++bit == 5) {
                hash.append(BASE32.charAt(ch));
                bit = 0;
                ch = 0;
            }
        }
        return hash.toString();
    }
}
NearbyService with neighbour-cell expansion
public class NearbyService {
    private final JedisCluster redis;
    private final BusinessRepository repo;

    public List<Business> findNearby(double lat, double lon, int radiusMeters) {
        int precision = precisionForRadius(radiusMeters);
        String center = GeoHash.encode(lat, lon, precision);
        List<String> cells = Neighbours.around(center);
        List<Business> candidates = new ArrayList<>();
        for (String cell : cells) {
            String cached = redis.get("cell:" + cell);
            if (cached != null) {
                candidates.addAll(Json.readList(cached, Business.class));
            } else {
                List<Business> fromDb = repo.byCellPrefix(cell);
                redis.setex("cell:" + cell, 60, Json.write(fromDb));
                candidates.addAll(fromDb);
            }
        }
        return candidates.stream()
            .filter(b -> haversine(lat, lon, b.lat(), b.lon()) <= radiusMeters)
            .sorted(Comparator.comparingDouble(b -> haversine(lat, lon, b.lat(), b.lon())))
            .limit(20)
            .toList();
    }

    private int precisionForRadius(int r) {
        if (r <= 150) return 7;
        if (r <= 1200) return 6;
        return 5;
    }
}
REST endpoint with radius validation
@RestController
@RequestMapping("/nearby")
public class NearbyController {
    private final NearbyService service;

    @GetMapping
    public ResponseEntity<NearbyResponse> nearby(
            @RequestParam double lat,
            @RequestParam double lon,
            @RequestParam(defaultValue = "500") int radius,
            @RequestParam(required = false) String category) {
        if (radius < 1 || radius > 50_000) {
            return ResponseEntity.badRequest().build();
        }
        List<Business> results = service.findNearby(lat, lon, radius);
        if (category != null) {
            results = results.stream().filter(b -> b.category().equals(category)).toList();
        }
        return ResponseEntity.ok(new NearbyResponse(results, System.currentTimeMillis()));
    }
}

Key design decisions & trade-offs

Interview follow-ups

Related