Proximity Service System Design Template
Nearby places search: geohash vs quadtree vs S2 vs H3, in-memory replicated geo index, adaptive radius by density, ranking, change stream, failure modes
How to use: Use it when designing or interviewing on "find places near me" for a read-heavy catalogue. Fill the capacity numbers first to decide whether the index fits in memory and can be replicated instead of sharded. The main pitfall is a fixed search radius that returns tens of thousands of candidates downtown and nothing in the countryside.
Preview
System Design: [Proximity service name]
Reference design for a proximity service: "find businesses or places near me", ranked by distance, rating and relevance, for a read-heavy catalogue of [200 million] places. It compares geohash, quadtree, Google S2 and Uber H3 (H3 library 4.x, cell statistics from h3geo.org) as the spatial index, sizes an in-memory index that is replicated rather than sharded, and covers adaptive search radius, ranking, write propagation and the failure behaviour of each component. It follows the structure of the System Design Document template, adds a 45-minute interview path, access-pattern and failure-mode tables and a cited "How real systems do it" box, and is pre-filled with a worked design to adapt. Replace every
[bracketed]value and every row marked Example; values marked Example are assumptions, not benchmarks.
Document control
| Field | Value |
|---|---|
| Document ID | [SDD-PROX-001] |
| System / feature | [Nearby places search] |
| Version | [0.1] |
| Status | [Draft / In review / Approved / Implemented / Superseded] |
| Owner | [@owner] |
| Author(s) | [@author] |
| Reviewers | [@reviewer-1], [@reviewer-2] |
| Last updated | YYYY-MM-DD |
| Classification | [Internal] |
| Difficulty | Intermediate |
| Spatial index and version | [H3 4.x, resolutions 7, 8 and 9] |
| Related | [Place catalogue service], [Search ranking design], [ADR: spatial index choice] |
Approvals
| Role | Name | Decision | Date |
|---|---|---|---|
| Tech lead | [@name] | [Approved / Changes requested] | YYYY-MM-DD |
| Staff / principal engineer | [@name] | [Decision] | YYYY-MM-DD |
| SRE / operations | [@name] | [Decision] | YYYY-MM-DD |
| Privacy | [@name] | [Decision] | YYYY-MM-DD |
| Product (search experience) | [@name] | [Decision] | YYYY-MM-DD |
Revision history
| Version | Date | Author | Change summary |
|---|---|---|---|
| 0.1 | YYYY-MM-DD | [@name] | First draft for design review |
| 0.2 | YYYY-MM-DD | [@name] | Example: Replaced fixed 2 km radius with density-based adaptive radius |
| 0.3 | YYYY-MM-DD | [@name] | Example: Moved index from a geo-enabled database to in-memory replicas |
| 1.0 | YYYY-MM-DD | [@name] | Example: Approved |
Contents
1. Overview
1.1 Problem statement
[Example: The current "near me" search runs a bounding-box query on the place table in a relational database. At the evening peak the query takes over 900 ms in dense city centres, returns up to 40 000 rows that are then sorted in the application, and the primary database is saturated by search traffic that should never touch it. Rural users often get empty results because the radius is fixed.]
1.2 Proposed solution
[Example: A search service answers nearby queries from an in-memory spatial index that maps H3 cells to place IDs at three resolutions. The index holds about 16 GB for 200 million places, so every search node keeps a full copy and nodes scale out by replication. A query picks a resolution and ring count from the local place density, reads the cells, filters by category and opening hours, computes exact distances and ranks the top results. The place catalogue stays the source of truth; changes flow to the index through a change stream and are visible within one minute.]
1.3 Decisions requested from reviewers
| ID | Question | Recommended option | Section |
|---|---|---|---|
| Q-01 | Example: Which spatial index? | H3 cells at resolutions 7, 8 and 9 | 7.2, 10 |
| Q-02 | Example: Shard or replicate the index? | Replicate the full index to every search node | 3.3, 7.5 |
| Q-03 | Example: Fixed or adaptive radius? | Adaptive by density, capped at about 3 000 candidates | 7.3 |
| Q-04 | Example: How fresh must the index be? | Changes visible within 60 s; closures within 5 min | 7.5 |
| [Q-nn] | [Question] | [Option] | [Section] |
1.4 45-minute interview path
Use this order when the document serves an interview or a short design review. The written sections below are the long form of each step.
| Minutes | Cover | Output on the whiteboard |
|---|---|---|
| 0 to 5 | Scope: search nearby places, view a place, owners edit places; below-the-line items (2.3) | Three functional requirements, read-heavy note |
| 5 to 10 | Estimates: peak searches per second, read to write ratio, index size (section 3) | "Index fits in memory, so replicate" consequence |
| 10 to 15 | Interface: GET /v1/search/nearby and the place endpoints (section 4) | One request, one response |
| 15 to 25 | High-level design: search service with in-memory index, place service with database, change stream (section 6) | One diagram |
| 25 to 40 | One deep dive: the spatial index (default, 7.2), or adaptive radius (7.3), or keeping the index fresh (7.5) | Options, choice, reason |
| 40 to 45 | Failures (stale index, hot area), the 10x row in section 10 | Two failure rows |
Start the default deep dive with the naive answer, a two-dimensional range query on latitude and longitude, and show why it is slow: a B-tree index on latitude alone returns a whole horizontal band of the planet. Then show how geohash turns two dimensions into one sortable key, why neighbouring cells are needed at cell edges, and why hexagonal cells keep all neighbours at one distance.
2. Requirements
2.1 Core functional requirements
| ID | Requirement | Priority |
|---|---|---|
| FR-01 | Example: Return places within a radius of a point, default 2 km, maximum 25 km | Must |
| FR-02 | Example: Filter by category and "open now"; return 20 results per page | Must |
| FR-03 | Example: Rank by a blend of distance, rating and relevance | Must |
| FR-04 | Example: Show place details (name, address, hours, photos, rating) | Must |
| FR-05 | Example: Owners add, edit and close places; changes appear in search within 60 s | Must |
| FR-06 | Example: Expand the radius automatically when too few results are found | Should |
| FR-07 | Example: Search inside a map viewport (bounding box) as well as a radius | Should |
| [FR-nn] | [Requirement] | [Must / Should / Could] |
2.2 Non-functional requirements
| ID | Quality | Target | Measured as |
|---|---|---|---|
| NFR-01 | Latency | Example: p99 at or below 150 ms server side for nearby search | Service timing |
| NFR-02 | Availability | Example: 99.95% monthly for search; place edits may degrade first | Synthetic probes per region |
| NFR-03 | Freshness | Example: 99% of edits searchable within 60 s | Edit-to-visible probe |
| NFR-04 | Throughput | Example: 17 400 searches/s globally at peak | Load test |
| NFR-05 | Result quality | Example: At least 5 results for 95% of queries outside wilderness areas | Offline replay of query logs |
| NFR-06 | Privacy | Example: Precise searcher location never stored in logs | Log review, automated scanner |
| [NFR-nn] | [Quality] | [Target] | [Measurement] |
2.3 Below the line (out of scope)
Name these explicitly so reviewers and interviewers do not pull the design toward them.
- [Example: tracking moving objects such as drivers or couriers (see
system-design/design-ride-matching)] - [Example: turn-by-turn routing and travel-time ranking (see
system-design/design-maps-navigation)] - [Example: full-text search over reviews and menus (see
system-design/design-full-text-search)] - [Example: review writing, photo upload and moderation]
- [Item deferred, with the release that will take it]
3. Capacity estimation
3.1 Assumptions
| Input | Symbol | Value | Source |
|---|---|---|---|
| Places in the catalogue | Example: 200 million | Catalogue count x growth | |
| Daily active users | Example: 100 million | Product analytics | |
| Nearby searches per user per day | Example: 5 | Product analytics | |
| Peak to average ratio | Example: 3 | Hourly traffic curve | |
| Share of places changed per day | Example: 0.5% | Catalogue audit log | |
| Place record size in the catalogue | Example: 1 KiB (1 024 B) | Schema estimate | |
| Searches one index node serves | Example: 4 000/s | Benchmark on target instance | |
| Share of peak in the busiest region | Example: 50% | Traffic by region |
3.2 Request rates
Place changes: per day, about 11.6 per second. The read to write ratio is about 5 787 / 11.6, or 500 to 1, so the design optimises the read path and accepts a slower, asynchronous write path into the index.
3.3 Storage and index size
Catalogue: , which fits a sharded relational or document store with ordinary replicas.
Search index per place: a compact record (latitude, longitude, category bits, rating, hours bitmap) of 32 bytes, plus the place ID in three cell lists (resolutions 7, 8 and 9) at 8 bytes each, doubled for hash-map and list overhead:
Consequence: the whole index fits in the memory of one ordinary 64 GB server, so every search node holds a full copy and the service scales by adding replicas. There is no scatter-gather across shards on the hot path.
3.4 Search nodes
Busiest region peak: searches/s.
The factor 1.5 keeps headroom for uneven load and garbage-collection pauses.
3.5 Summary
| Quantity | Estimate | Design consequence |
|---|---|---|
| Searches | ~17 400/s peak globally, ~8 700/s in the busiest region | Stateless search nodes behind a load balancer |
| Writes | ~11.6 place changes/s | Asynchronous index updates through a change stream |
| Read to write ratio | ~500 to 1 | Optimise and cache the read path; index can lag by seconds |
| Index size | ~16 GB | Full copy per node, replicate instead of shard |
| Catalogue | ~205 GB | Sharded database, separate from search |
| Search nodes | 5 in the busiest region | Add replicas, not shards, as traffic grows |
4. API design
4.1 Endpoints
| Method and path | Purpose | Notes |
|---|---|---|
GET /v1/search/nearby?lat=&lng=&radius_m=&category=&open_now=&cursor= | Nearby search | Radius optional; service may widen it (FR-06) |
GET /v1/search/box?min_lat=&min_lng=&max_lat=&max_lng=&category= | Viewport search | Used when the user pans the map |
GET /v1/places/{place_id} | Place details | Served from the catalogue with a cache |
POST /v1/places | Create a place | Owner or moderator; returns place_id |
PATCH /v1/places/{place_id} | Edit a place | If-Match with the record version |
DELETE /v1/places/{place_id} | Close a place | Soft delete; removed from search within 5 min |
4.2 Request and response example
GET /v1/search/nearby?lat=40.4168&lng=-3.7038&radius_m=2000&category=cafe&open_now=true HTTP/1.1
Accept: application/json
{"query": {"lat": 40.417, "lng": -3.704, "requested_radius_m": 2000, "effective_radius_m": 500, "resolution": 9}, "results": [{"place_id": "pl_7Hq2", "name": "Example Coffee Corner", "distance_m": 140, "rating": 4.6, "open_now": true, "category": "cafe"}, {"place_id": "pl_9Xa1", "name": "Example Bakery and Cafe", "distance_m": 310, "rating": 4.4, "open_now": true, "category": "cafe"}], "next_cursor": "c_2f9a", "index_version": "2026-10-30T09:14:05Z"}
The response echoes the effective radius so the client can show "results within 500 m" when the service narrowed a dense query (7.3). Coordinates in the echo are rounded to three decimals (about 110 m of latitude) so logs and caches do not hold precise user locations (9.2).
4.3 Errors and limits
| Case | Status | Body |
|---|---|---|
| Latitude outside -90 to 90 or longitude outside -180 to 180 | 400 | Problem details, invalid-coordinates |
| Radius above 25 km | 400 | Problem details with the maximum |
| Too many requests | 429 | Retry-After, see system-design/design-rate-limiter |
| Index node not ready (loading snapshot) | 503 | Load balancer removes the node until ready |
5. Data model
5.1 Entities
The place record is the source of truth; cell entries and counts are derived from it and can always be rebuilt.
5.2 Storage choices
| Data | Store | Key | Main queries | Why |
|---|---|---|---|---|
| Places (source of truth) | Example: Sharded relational database | place_id, sharded by hash of place_id | Read and update one place | Transactions on edits, ordinary backups |
| Search index | In-memory maps in every search node | H3 cell ID to list of place IDs, at resolutions 7, 8, 9 | All places in a set of cells | 16 GB fits in memory; no network hop |
| Place summaries for ranking | In-memory array in every search node | Dense integer per place | Coordinates, category, rating, hours | Cache-friendly scan of candidates |
| Density map | In-memory map | H3 resolution 7 cell to place count | Choose radius and resolution | Tiny, rebuilt with each snapshot |
| Change stream | Example: Log-based message queue | place_id partition key | Apply edits in order per place | Ordered per place, replayable |
| Index snapshots | Object storage | Snapshot version | Load a new node quickly | Faster than rebuilding from the database |
5.3 Access patterns
| Access pattern | Frequency | Key or index that serves it | Partition key rationale |
|---|---|---|---|
| Find places near a point | ~17 400/s peak | gridDisk of the cell containing the point, then cell lists | No partitioning: every node holds the full index, so any node answers any query |
| Find places in a viewport | ~10% of searches | Cells covering the box at a resolution chosen from the box size | Same full index |
| Decide radius and resolution | Every search | Density map keyed by resolution 7 cell | Small enough to replicate everywhere |
| Read place details | Every result click | place_id in the catalogue, behind a cache | Hash of place_id spreads load evenly |
| Edit a place | ~11.6/s | place_id row, then change event | Change stream partitioned by place_id keeps per-place order |
| Rebuild the index | Daily snapshot | Full scan of the catalogue | Offline job, never on the request path |
6. High-level design
| Component | Responsibility | Scales by |
|---|---|---|
| Search service | Pick radius and resolution, read cells, filter, rank | Replicas per region |
| In-memory index | Cell lists, place summaries, density map | Memory per node (16 GB now) |
| Place service | CRUD on places, validation, emit change events | Stateless replicas |
| Place database | Source of truth | Shards by place_id |
| Change stream | Ordered edits per place | Partitions |
| Snapshot builder | Daily full index build and density map | Batch job |
| Place detail cache | Hot place records | Cache nodes |
Read path: client, load balancer, any search node, local memory, response. Write path: client, place service, database commit, change event, every search node applies the change. The two paths share no synchronous dependency, so a database incident does not stop search (9.1).
7. Detailed design
The deep dives in this section, each with options, choice and reason:
| Deep dive | Options considered | Choice | Reason | Section |
|---|---|---|---|---|
| Spatial index | Lat/lng B-tree, geohash, quadtree, S2, H3 | H3 at resolutions 7, 8, 9 | Uniform neighbour distance, fixed cell sets, mature library | 7.2 |
| Search radius | Fixed radius, expanding rings, density-based | Density-based with expansion | Bounded work downtown, results in the countryside | 7.3 |
| Ranking | Distance only, rating only, blended score | Blended score on a bounded candidate set | Matches user intent; cost bounded by 7.3 | 7.4 |
| Index freshness | Rebuild nightly, query the database, stream changes | Stream changes plus daily snapshot | Edits visible in seconds; fast node start | 7.5 |
7.1 Distance
Exact distance uses the haversine formula on a sphere of radius :
Example: from (40.000, -3.000) to (40.010, -3.010) the distance is about 1.40 km. The spherical model is accurate to well under 1% for search ranking; Redis documents up to 0.5% worst-case error for the same model.
7.2 Spatial index options
| Index | Cell shape and key | Neighbour search | Strengths | Weaknesses |
|---|---|---|---|---|
| B-tree on latitude and longitude | None | Range on one column, filter the other | No new concepts | Scans a whole band of the planet; slow at scale |
| Geohash | Rectangles; base32 string, prefix = parent | Cell plus 8 neighbours | Sortable string, works in any key-value store | Cell sizes vary with latitude; neighbours at two distances; edge effects at prefix boundaries |
| Quadtree | Squares split when full | Tree walk | Adapts to density automatically | In-memory tree to build and rebalance; harder to distribute |
| S2 | Quadrilateral cells on a cube, 64-bit ID on a Hilbert curve, levels 0 to 30 | Region coverer returns a cell union | Cell IDs keep locality; precise coverings | Squares, so neighbour distances differ |
| H3 | Hexagons (12 pentagons per resolution), 64-bit ID, resolutions 0 to 15 | gridDisk(cell, k) returns cells | All six neighbours at one distance; simple rings | Children do not tile the parent exactly; pentagons need care |
Geohash cell sizes, computed from bits per character (5 bits per character, alternating longitude and latitude):
| Geohash length | Cell width at equator | Cell height | Width at latitude 40 degrees |
|---|---|---|---|
| 4 | ~39.1 km | ~19.4 km | ~30.0 km |
| 5 | ~4.89 km | ~4.86 km | ~3.75 km |
| 6 | ~1.22 km | ~0.61 km | ~0.94 km |
| 7 | ~153 m | ~152 m | ~117 m |
H3 cell sizes used here, from the H3 cell statistics table (average hexagon area and edge; H3 states that edge lengths for resolutions 7 and finer are extrapolated):
| Resolution | Average hexagon area | Average edge length | Role in this design |
|---|---|---|---|
| 7 | 5.161 km² | 1.406 km | Rural searches and the density map |
| 8 | 0.737 km² | 0.531 km | Default suburban search |
| 9 | 0.105 km² | 0.201 km | Dense city centres |
Choice: H3. Every place is indexed at three resolutions at write time (three cell lists, 3.3), so a query never converts between resolutions. Geohash remains a valid choice when the index must live in a key-value store that only supports prefix or range scans.
7.3 Adaptive radius and ring count
For edge length , the distance between neighbouring cell centres is . A conservative number of rings that covers radius from any point in the origin cell is:
| Area type | Example density | Radius | Resolution | Rings | Cells read | Area read | Area read / circle area | Expected candidates |
|---|---|---|---|---|---|---|---|---|
| Example: Dense centre | 1 500 places/km² | 0.5 km | 9 | 2 | 19 | 2.00 km² | 2.55 | ~3 000 |
| Example: Suburb | 100 places/km² | 2 km | 8 | 3 | 37 | 27.3 km² | 2.17 | ~2 700 |
| Example: Rural | 5 places/km² | 5 km | 7 | 3 | 37 | 191 km² | 2.43 | ~950 |
A fixed 2 km radius at resolution 8 in the dense centre would read 27.3 km² x 1 500, about 41 000 candidates, which breaks the latency budget. So the service reads the place count of the resolution 7 cell around the query point, picks the row whose expected candidates stay under about 3 000, and if fewer than 20 results survive the filters it widens by one ring at a time up to the 25 km cap. The response reports the effective radius (4.2).
7.4 Filtering and ranking
- Read candidate IDs from the chosen cells.
- Drop candidates that fail the category bitmask or the hours bitmap for the local time of the place.
- Compute haversine distance only for survivors; drop those beyond the effective radius (cells overshoot the circle by the ratio in 7.3).
- Score: Example: , where relevance comes from the query category match.
- Keep the top 20 with a bounded heap; the cursor encodes the last score and place ID so the next page is stable.
Ranking 3 000 candidates is a few hundred microseconds of CPU, well inside the latency budget in section 8.
7.5 Index freshness and node start
- Change stream: every committed edit emits an event with the place version. Each search node consumes all partitions and applies an event only if its version is newer than the one it holds, so replays and duplicates are harmless.
- Moves: an edit that changes coordinates removes the place from its old cells and adds it to the new ones at all three resolutions.
- Closures: a closed place is removed from the cell lists; the place record is kept for history.
- Snapshots: a daily job builds the full index from the database, records the stream offset it reflects and writes it to object storage. A new node loads the snapshot (16 GB) and then replays the stream from that offset; it reports ready only when its lag is under 60 s.
7.6 How real systems do it
How real systems do it (paraphrased, not copied). The Redis documentation for
GEOADDexplains that a Redis geospatial index is an ordinary sorted set: latitude and longitude bits are interleaved into a 52-bit geohash integer that is stored as the member's score, which a double can hold exactly. A radius or box query covers the shape with the centre area and its eight neighbours, turns each into a score range by dropping low-order bits, and discards members outside the shape. Distances use the haversine formula on a spherical Earth, which the documentation says can be off by up to 0.5%. This is the geohash row of 7.2 in production form. The S2 Geometry documentation describes the other family: 31 cell levels numbered along a Hilbert curve so nearby cells get nearby 64-bit IDs, and a region coverer that approximates any shape with a small union of cells (8 by default). The H3 documentation publishes the cell statistics used in 7.2 and 7.3. Read the originals in 12.3; this box keeps only the general idea.
8. Scalability and performance
| Concern | Approach | Limit before redesign |
|---|---|---|
| Search rate | Stateless replicas with a full index each | Example: about 4 000 searches/s per node; add nodes linearly |
| Index size | Compact summaries, three cell lists | Example: about 40 GB, past which memory per node gets costly; then shard by region (10) |
| Dense areas | Adaptive radius caps candidates at about 3 000 | Very dense venues (stadium, mall) need resolution 10 |
| Hot areas (events) | Cache results per (resolution 9 cell, category, page) for 30 s | Personalised ranking defeats the cache |
| Edit bursts (bulk import) | Stream throttled; snapshot rebuild for imports over 1 million places | Import window planned with operations |
Latency budget for nearby search (target p99 150 ms server side):
| Step | Budget |
|---|---|
| Parse, authenticate, rate limit | 5 ms |
| Density lookup and ring selection | 1 ms |
| Read up to 37 cell lists | 5 ms |
| Filter, haversine, rank about 3 000 candidates | 10 ms |
| Fetch display fields for 20 results from cache | 20 ms |
| Serialise and respond | 5 ms |
| Headroom (garbage collection, network) | 104 ms |
At the busiest region peak, 8 681 searches/s x up to 37 cells is about 321 000 in-memory cell reads per second spread over 5 nodes, which is trivial for local hash maps.
9. Reliability, security and operations
9.1 Failure modes
| Component | Failure | Detection | Blast radius | Mitigation | RTO |
|---|---|---|---|---|---|
| Search node | Crash or out of memory | Health check, load balancer | Requests in flight on one node | Other replicas serve; spare node per region | Example: 30 s |
| Change stream | Consumer lag or outage | Lag metric per node | Edits not visible; search still works on slightly stale data | Alert at 60 s lag; nodes keep serving; replay on recovery | Example: 15 min |
| Place database | Primary failover | Error rate on writes | Place edits and details for one shard | Search unaffected; details served from cache | Example: 60 s |
| Snapshot | Corrupt or missing snapshot | Checksum and place-count check before load | New nodes cannot start | Keep the last 7 snapshots; fall back to the previous one plus longer replay | Example: 1 h |
| Density map | Stale after a big import | Result-count monitor by area | Too many or too few candidates in one area | Rebuild density map with the import; expansion loop still finds results | Example: 1 day |
| Whole region | Region loss | Global health checks | Users in that region | DNS steering to the next region; every region has the full index | Example: 5 min |
| [Component] | [Failure] | [Signal] | [Scope] | [Mitigation] | [Time] |
9.2 Security and privacy
| Threat or duty | Control |
|---|---|
| Searcher location is personal data in many jurisdictions | Round coordinates in logs and cache keys (4.2); keep raw coordinates only in memory for the request |
| Scraping the whole catalogue through tiled searches | Per-key and per-IP rate limits; anomaly detection on grid-like query patterns |
| Fake or malicious place edits | Ownership verification, moderation queue for name and location changes |
| Revealing a private home location (home-based businesses) | Owners can hide the exact address; search returns a rounded location |
| [Threat] | [Control] |
9.3 Observability
| Signal | Metric or log | Alert threshold |
|---|---|---|
| Latency | p99 per region | Above 150 ms for 10 min |
| Empty results | Share of queries with zero results by area type | Doubling vs. 7-day baseline |
| Candidates per query | p99 candidate count | Above 5 000 |
| Index lag | Seconds behind the change stream per node | Above 60 s |
| Node memory | Heap use | Above 80% |
9.4 Cost estimate
| Item | Unit cost | Quantity | Monthly cost |
|---|---|---|---|
| Search nodes (64 GB memory) | [$/node-month] | Example: 5 in the busiest region, 3 in each other region | [$] |
| Place database | [$/month] | [Shards x replicas] | [$] |
| Change stream | [$/month] | [Partitions] | [$] |
| Snapshot storage | [$/GB-month] | Example: 7 x 16 GB per region | [$] |
| Total | [$] |
10. Trade-offs and alternatives
| Decision | Options | Chosen | Why | What we give up |
|---|---|---|---|---|
| Spatial index | Geohash; quadtree; S2; H3 | H3 | Rings with one neighbour distance, fixed cell sets per resolution | A library dependency; pentagon edge cases |
| Index placement | Geo-enabled database; Redis GEO; in-process memory | In-process memory | No network hop; 16 GB fits one node | Each node must load 16 GB at start |
| Replicate or shard | Shard by region or cell; full replica | Full replica | Any node answers any query; no scatter-gather | Memory cost grows with every replica |
| Radius | Fixed; adaptive | Adaptive by density | Bounded work and non-empty results | Users see a different effective radius |
| Freshness | Query the database; nightly rebuild; stream | Stream plus snapshot | Seconds of lag, fast recovery | Two paths to keep consistent |
| What changes at 10x / 0.1x | 10x (about 174 000 searches/s, 2 billion places): about 33 nodes in the busiest region by the 3.4 formula, and an index near 160 GB, so shard by continent or by resolution 3 cell and route queries by location, with cross-shard reads only near shard borders. 0.1x (about 1 700 searches/s): one node per region plus a spare, or a geo-enabled database with a geohash or GiST index is enough and simpler to run | Review at each doubling of places or traffic | Index size, not query rate, forces sharding first | [Note] |
| [Decision] | [Options] | [Chosen] | [Why] | [Cost] |
11. Delivery plan and risks
11.1 Milestones
| Milestone | Scope | Exit criterion | Target date |
|---|---|---|---|
| M1 | Snapshot builder and in-memory index, shadow traffic | Shadow results overlap current results at 90% for the top 10 | YYYY-MM-DD |
| M2 | Change stream and freshness probe | 99% of edits visible within 60 s for 14 days | YYYY-MM-DD |
| M3 | Adaptive radius and ranking, 10% of traffic | Empty-result rate and p99 latency at or better than baseline | YYYY-MM-DD |
| M4 | All traffic, old database query retired | Database CPU from search under 5% | YYYY-MM-DD |
11.2 Rollout and rollback
[Example: Traffic shifts by percentage with a feature flag per region. Rollback routes traffic back to the database query path, which stays available until M4 plus 30 days.]
11.3 Risks
| ID | Risk | Likelihood | Impact | Mitigation | Owner |
|---|---|---|---|---|---|
| R-01 | Example: Ranking change lowers click-through | Medium | High | A/B test with click and conversion metrics before M4 | [@name] |
| R-02 | Example: Node start too slow during scale-out | Medium | Medium | Pre-warmed spare per region; snapshot in the same region | [@name] |
| R-03 | Example: Location data in logs breaches privacy policy | Low | High | Rounding at the edge, log scanner in CI | [@name] |
| [R-nn] | [Risk] | [L / M / H] | [L / M / H] | [Mitigation] | [@name] |
12. Appendices
12.1 Open questions
| ID | Question | Owner | Due |
|---|---|---|---|
| OQ-01 | Example: Do we need indoor or multi-floor search for malls? | [@name] | YYYY-MM-DD |
| OQ-02 | Example: Should "open now" use the searcher's or the place's time zone near borders? | [@name] | YYYY-MM-DD |
| [OQ-nn] | [Question] | [@name] | YYYY-MM-DD |
12.2 Glossary
| Term | Meaning |
|---|---|
| Geohash | Base32 string built by interleaving longitude and latitude bits; a shared prefix means a shared parent rectangle |
| H3 | Hexagonal hierarchical grid with 16 resolutions (0 to 15) and 64-bit cell IDs |
gridDisk | H3 function that returns all cells within steps of a cell |
| S2 | Hierarchical grid of quadrilateral cells on a cube projection, numbered along a Hilbert curve |
| Quadtree | Tree that splits a square into four children when it holds too many points |
| Haversine | Great-circle distance formula on a sphere |
| Effective radius | The radius the service actually searched after density adaptation |
| [Term] | [Meaning] |
12.3 References
| Reference | Use |
|---|---|
| H3 documentation, "Tables of Cell Statistics Across Resolutions" (h3geo.org, library 4.x) | Cell areas and edge lengths in 7.2 and 7.3 |
| H3 documentation, "Grid traversal functions" | gridDisk, ring sizes, pentagon note |
| Redis documentation, "GEOADD" | Geohash in a sorted set, 0.5% distance error, 7.6 |
| S2 Geometry, "S2 Cell Hierarchy" | S2 levels, Hilbert curve IDs, region coverer, 7.6 |
| RFC 9457 Problem Details for HTTP APIs | Error bodies in 4.3 |
Ride-Hailing Dispatch System Design (system-design/design-ride-matching) | Moving objects on the same H3 grid |
Maps and Navigation System Design (system-design/design-maps-navigation) | Routing and travel-time ranking |
Distributed Cache System Design (system-design/design-distributed-cache) | Place detail cache |
CDC Pipeline System Design (system-design/design-cdc-pipeline) | Change stream from the place database |
Back-of-Envelope Estimation and Capacity Model (system-design/back-of-envelope-estimation) | Method for section 3 |
Standard and licence note
Based on common industry practice for geospatial search and on the public documentation of H3 (Apache 2.0 licensed library, documentation version 4.x), S2 Geometry and Redis cited in 12.3. Cell statistics are quoted as published on h3geo.org in October 2026, where edge lengths for resolutions 7 and finer are extrapolated; check the current tables before relying on them. The documentation pages are paraphrased in 7.6 only; no text, figures or code were copied, and all capacity numbers here are illustrative examples. Original text written for this collection. Template licence: MIT.
Related templates
Zanzibar-Style Authorization Service System Design Template
Relationship-based authorization service: relation tuples, namespace config, Check/Expand/List/Watch APIs, consistency tokens, caching, sharding, failure modes
System DesignNotification Service System Design Template (Reference Design)
Push, email, SMS and in-app: templates, preferences and opt-out, priority queues, caps, provider failover, retries, dedup
System DesignAd Click Aggregation System Design Template (Reference Design)
45-minute path, click dedupe, event-time windows, exactly-once counting, exact and Count-Min top-K, batch reconciliation for billing, failure modes, 10x row