Markdown Editor

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

System DesignFullH3 4.x cell statistics; docs/48 design standarddesignproximityservicemath

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

FieldValue
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 updatedYYYY-MM-DD
Classification[Internal]
DifficultyIntermediate
Spatial index and version[H3 4.x, resolutions 7, 8 and 9]
Related[Place catalogue service], [Search ranking design], [ADR: spatial index choice]

Approvals

RoleNameDecisionDate
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

VersionDateAuthorChange summary
0.1YYYY-MM-DD[@name]First draft for design review
0.2YYYY-MM-DD[@name]Example: Replaced fixed 2 km radius with density-based adaptive radius
0.3YYYY-MM-DD[@name]Example: Moved index from a geo-enabled database to in-memory replicas
1.0YYYY-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

IDQuestionRecommended optionSection
Q-01Example: Which spatial index?H3 cells at resolutions 7, 8 and 97.2, 10
Q-02Example: Shard or replicate the index?Replicate the full index to every search node3.3, 7.5
Q-03Example: Fixed or adaptive radius?Adaptive by density, capped at about 3 000 candidates7.3
Q-04Example: How fresh must the index be?Changes visible within 60 s; closures within 5 min7.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.

MinutesCoverOutput on the whiteboard
0 to 5Scope: search nearby places, view a place, owners edit places; below-the-line items (2.3)Three functional requirements, read-heavy note
5 to 10Estimates: peak searches per second, read to write ratio, index size (section 3)"Index fits in memory, so replicate" consequence
10 to 15Interface: GET /v1/search/nearby and the place endpoints (section 4)One request, one response
15 to 25High-level design: search service with in-memory index, place service with database, change stream (section 6)One diagram
25 to 40One 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 45Failures (stale index, hot area), the 10x row in section 10Two 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

IDRequirementPriority
FR-01Example: Return places within a radius of a point, default 2 km, maximum 25 kmMust
FR-02Example: Filter by category and "open now"; return 20 results per pageMust
FR-03Example: Rank by a blend of distance, rating and relevanceMust
FR-04Example: Show place details (name, address, hours, photos, rating)Must
FR-05Example: Owners add, edit and close places; changes appear in search within 60 sMust
FR-06Example: Expand the radius automatically when too few results are foundShould
FR-07Example: Search inside a map viewport (bounding box) as well as a radiusShould
[FR-nn][Requirement][Must / Should / Could]

2.2 Non-functional requirements

IDQualityTargetMeasured as
NFR-01LatencyExample: p99 at or below 150 ms server side for nearby searchService timing
NFR-02AvailabilityExample: 99.95% monthly for search; place edits may degrade firstSynthetic probes per region
NFR-03FreshnessExample: 99% of edits searchable within 60 sEdit-to-visible probe
NFR-04ThroughputExample: 17 400 searches/s globally at peakLoad test
NFR-05Result qualityExample: At least 5 results for 95% of queries outside wilderness areasOffline replay of query logs
NFR-06PrivacyExample: Precise searcher location never stored in logsLog 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

InputSymbolValueSource
Places in the cataloguePPExample: 200 millionCatalogue count x growth
Daily active usersDDExample: 100 millionProduct analytics
Nearby searches per user per dayssExample: 5Product analytics
Peak to average ratioffExample: 3Hourly traffic curve
Share of places changed per dayccExample: 0.5%Catalogue audit log
Place record size in the cataloguerrExample: 1 KiB (1 024 B)Schema estimate
Searches one index node servesqqExample: 4 000/sBenchmark on target instance
Share of peak in the busiest regionggExample: 50%Traffic by region

3.2 Request rates

Qavg=D×s86 400=108×586 400≈5 787 searches/s,Qpeak=3×5 787≈17 361 searches/sQ_{avg} = \frac{D \times s}{86\,400} = \frac{10^{8} \times 5}{86\,400} \approx 5\,787 \ \text{searches/s}, \qquad Q_{peak} = 3 \times 5\,787 \approx 17\,361 \ \text{searches/s}

Place changes: P×c=2×108×0.005=106P \times c = 2 \times 10^{8} \times 0.005 = 10^{6} 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: 2×108×1 024 B≈205 GB2 \times 10^{8} \times 1\,024 \ \text{B} \approx 205 \ \text{GB}, 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:

M=P×(32+3×8×2)=2×108×80 B=16 GBM = P \times (32 + 3 \times 8 \times 2) = 2 \times 10^{8} \times 80 \ \text{B} = 16 \ \text{GB}

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: g×Qpeak=0.5×17 361≈8 681g \times Q_{peak} = 0.5 \times 17\,361 \approx 8\,681 searches/s.

N=8 6814 000×1.5≈3.3⇒4 nodes, plus 1 spare for a zone loss=5 nodesN = \frac{8\,681}{4\,000} \times 1.5 \approx 3.3 \Rightarrow 4 \ \text{nodes, plus 1 spare for a zone loss} = 5 \ \text{nodes}

The factor 1.5 keeps headroom for uneven load and garbage-collection pauses.

3.5 Summary

QuantityEstimateDesign consequence
Searches~17 400/s peak globally, ~8 700/s in the busiest regionStateless search nodes behind a load balancer
Writes~11.6 place changes/sAsynchronous index updates through a change stream
Read to write ratio~500 to 1Optimise and cache the read path; index can lag by seconds
Index size~16 GBFull copy per node, replicate instead of shard
Catalogue~205 GBSharded database, separate from search
Search nodes5 in the busiest regionAdd replicas, not shards, as traffic grows

4. API design

4.1 Endpoints

Method and pathPurposeNotes
GET /v1/search/nearby?lat=&lng=&radius_m=&category=&open_now=&cursor=Nearby searchRadius optional; service may widen it (FR-06)
GET /v1/search/box?min_lat=&min_lng=&max_lat=&max_lng=&category=Viewport searchUsed when the user pans the map
GET /v1/places/{place_id}Place detailsServed from the catalogue with a cache
POST /v1/placesCreate a placeOwner or moderator; returns place_id
PATCH /v1/places/{place_id}Edit a placeIf-Match with the record version
DELETE /v1/places/{place_id}Close a placeSoft 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

CaseStatusBody
Latitude outside -90 to 90 or longitude outside -180 to 180400Problem details, invalid-coordinates
Radius above 25 km400Problem details with the maximum
Too many requests429Retry-After, see system-design/design-rate-limiter
Index node not ready (loading snapshot)503Load 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

DataStoreKeyMain queriesWhy
Places (source of truth)Example: Sharded relational databaseplace_id, sharded by hash of place_idRead and update one placeTransactions on edits, ordinary backups
Search indexIn-memory maps in every search nodeH3 cell ID to list of place IDs, at resolutions 7, 8, 9All places in a set of cells16 GB fits in memory; no network hop
Place summaries for rankingIn-memory array in every search nodeDense integer per placeCoordinates, category, rating, hoursCache-friendly scan of candidates
Density mapIn-memory mapH3 resolution 7 cell to place countChoose radius and resolutionTiny, rebuilt with each snapshot
Change streamExample: Log-based message queueplace_id partition keyApply edits in order per placeOrdered per place, replayable
Index snapshotsObject storageSnapshot versionLoad a new node quicklyFaster than rebuilding from the database

5.3 Access patterns

Access patternFrequencyKey or index that serves itPartition key rationale
Find places near a point~17 400/s peakgridDisk of the cell containing the point, then cell listsNo partitioning: every node holds the full index, so any node answers any query
Find places in a viewport~10% of searchesCells covering the box at a resolution chosen from the box sizeSame full index
Decide radius and resolutionEvery searchDensity map keyed by resolution 7 cellSmall enough to replicate everywhere
Read place detailsEvery result clickplace_id in the catalogue, behind a cacheHash of place_id spreads load evenly
Edit a place~11.6/splace_id row, then change eventChange stream partitioned by place_id keeps per-place order
Rebuild the indexDaily snapshotFull scan of the catalogueOffline job, never on the request path

6. High-level design

ComponentResponsibilityScales by
Search servicePick radius and resolution, read cells, filter, rankReplicas per region
In-memory indexCell lists, place summaries, density mapMemory per node (16 GB now)
Place serviceCRUD on places, validation, emit change eventsStateless replicas
Place databaseSource of truthShards by place_id
Change streamOrdered edits per placePartitions
Snapshot builderDaily full index build and density mapBatch job
Place detail cacheHot place recordsCache 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 diveOptions consideredChoiceReasonSection
Spatial indexLat/lng B-tree, geohash, quadtree, S2, H3H3 at resolutions 7, 8, 9Uniform neighbour distance, fixed cell sets, mature library7.2
Search radiusFixed radius, expanding rings, density-basedDensity-based with expansionBounded work downtown, results in the countryside7.3
RankingDistance only, rating only, blended scoreBlended score on a bounded candidate setMatches user intent; cost bounded by 7.37.4
Index freshnessRebuild nightly, query the database, stream changesStream changes plus daily snapshotEdits visible in seconds; fast node start7.5

7.1 Distance

Exact distance uses the haversine formula on a sphere of radius RE=6 371 kmR_{E} = 6\,371 \ \text{km}:

d=2REarcsin⁡(sin⁡2Δφ2+cos⁡φ1cos⁡φ2sin⁡2Δλ2)d = 2 R_{E} \arcsin\left(\sqrt{\sin^{2}\frac{\Delta\varphi}{2} + \cos\varphi_{1}\cos\varphi_{2}\sin^{2}\frac{\Delta\lambda}{2}}\right)

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

IndexCell shape and keyNeighbour searchStrengthsWeaknesses
B-tree on latitude and longitudeNoneRange on one column, filter the otherNo new conceptsScans a whole band of the planet; slow at scale
GeohashRectangles; base32 string, prefix = parentCell plus 8 neighboursSortable string, works in any key-value storeCell sizes vary with latitude; neighbours at two distances; edge effects at prefix boundaries
QuadtreeSquares split when fullTree walkAdapts to density automaticallyIn-memory tree to build and rebalance; harder to distribute
S2Quadrilateral cells on a cube, 64-bit ID on a Hilbert curve, levels 0 to 30Region coverer returns a cell unionCell IDs keep locality; precise coveringsSquares, so neighbour distances differ
H3Hexagons (12 pentagons per resolution), 64-bit ID, resolutions 0 to 15gridDisk(cell, k) returns 1+3k(k+1)1 + 3k(k+1) cellsAll six neighbours at one distance; simple ringsChildren 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 lengthCell width at equatorCell heightWidth 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):

ResolutionAverage hexagon areaAverage edge lengthRole in this design
75.161 km²1.406 kmRural searches and the density map
80.737 km²0.531 kmDefault suburban search
90.105 km²0.201 kmDense 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 ee, the distance between neighbouring cell centres is d=3 ed = \sqrt{3}\,e. A conservative number of rings that covers radius RR from any point in the origin cell is:

k=⌈R+ed−12⌉,cells=1+3k(k+1)k = \left\lceil \frac{R + e}{d} - \frac{1}{2} \right\rceil, \qquad \text{cells} = 1 + 3k(k+1)
Area typeExample densityRadiusResolutionRings kkCells readArea readArea read / circle areaExpected candidates
Example: Dense centre1 500 places/km²0.5 km92192.00 km²2.55~3 000
Example: Suburb100 places/km²2 km833727.3 km²2.17~2 700
Example: Rural5 places/km²5 km7337191 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

  1. Read candidate IDs from the chosen cells.
  2. Drop candidates that fail the category bitmask or the hours bitmap for the local time of the place.
  3. Compute haversine distance only for survivors; drop those beyond the effective radius (cells overshoot the circle by the ratio in 7.3).
  4. Score: Example: score=0.5⋅(1−d/R)+0.3⋅rating/5+0.2⋅relevancescore = 0.5 \cdot (1 - d/R) + 0.3 \cdot rating/5 + 0.2 \cdot relevance, where relevance comes from the query category match.
  5. 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 GEOADD explains 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

ConcernApproachLimit before redesign
Search rateStateless replicas with a full index eachExample: about 4 000 searches/s per node; add nodes linearly
Index sizeCompact summaries, three cell listsExample: about 40 GB, past which memory per node gets costly; then shard by region (10)
Dense areasAdaptive radius caps candidates at about 3 000Very dense venues (stadium, mall) need resolution 10
Hot areas (events)Cache results per (resolution 9 cell, category, page) for 30 sPersonalised ranking defeats the cache
Edit bursts (bulk import)Stream throttled; snapshot rebuild for imports over 1 million placesImport window planned with operations

Latency budget for nearby search (target p99 150 ms server side):

StepBudget
Parse, authenticate, rate limit5 ms
Density lookup and ring selection1 ms
Read up to 37 cell lists5 ms
Filter, haversine, rank about 3 000 candidates10 ms
Fetch display fields for 20 results from cache20 ms
Serialise and respond5 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

ComponentFailureDetectionBlast radiusMitigationRTO
Search nodeCrash or out of memoryHealth check, load balancerRequests in flight on one nodeOther replicas serve; spare node per regionExample: 30 s
Change streamConsumer lag or outageLag metric per nodeEdits not visible; search still works on slightly stale dataAlert at 60 s lag; nodes keep serving; replay on recoveryExample: 15 min
Place databasePrimary failoverError rate on writesPlace edits and details for one shardSearch unaffected; details served from cacheExample: 60 s
SnapshotCorrupt or missing snapshotChecksum and place-count check before loadNew nodes cannot startKeep the last 7 snapshots; fall back to the previous one plus longer replayExample: 1 h
Density mapStale after a big importResult-count monitor by areaToo many or too few candidates in one areaRebuild density map with the import; expansion loop still finds resultsExample: 1 day
Whole regionRegion lossGlobal health checksUsers in that regionDNS steering to the next region; every region has the full indexExample: 5 min
[Component][Failure][Signal][Scope][Mitigation][Time]

9.2 Security and privacy

Threat or dutyControl
Searcher location is personal data in many jurisdictionsRound coordinates in logs and cache keys (4.2); keep raw coordinates only in memory for the request
Scraping the whole catalogue through tiled searchesPer-key and per-IP rate limits; anomaly detection on grid-like query patterns
Fake or malicious place editsOwnership 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

SignalMetric or logAlert threshold
Latencyp99 per regionAbove 150 ms for 10 min
Empty resultsShare of queries with zero results by area typeDoubling vs. 7-day baseline
Candidates per queryp99 candidate countAbove 5 000
Index lagSeconds behind the change stream per nodeAbove 60 s
Node memoryHeap useAbove 80%

9.4 Cost estimate

ItemUnit costQuantityMonthly 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

DecisionOptionsChosenWhyWhat we give up
Spatial indexGeohash; quadtree; S2; H3H3Rings with one neighbour distance, fixed cell sets per resolutionA library dependency; pentagon edge cases
Index placementGeo-enabled database; Redis GEO; in-process memoryIn-process memoryNo network hop; 16 GB fits one nodeEach node must load 16 GB at start
Replicate or shardShard by region or cell; full replicaFull replicaAny node answers any query; no scatter-gatherMemory cost grows with every replica
RadiusFixed; adaptiveAdaptive by densityBounded work and non-empty resultsUsers see a different effective radius
FreshnessQuery the database; nightly rebuild; streamStream plus snapshotSeconds of lag, fast recoveryTwo paths to keep consistent
What changes at 10x / 0.1x10x (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 runReview at each doubling of places or trafficIndex size, not query rate, forces sharding first[Note]
[Decision][Options][Chosen][Why][Cost]

11. Delivery plan and risks

11.1 Milestones

MilestoneScopeExit criterionTarget date
M1Snapshot builder and in-memory index, shadow trafficShadow results overlap current results at 90% for the top 10YYYY-MM-DD
M2Change stream and freshness probe99% of edits visible within 60 s for 14 daysYYYY-MM-DD
M3Adaptive radius and ranking, 10% of trafficEmpty-result rate and p99 latency at or better than baselineYYYY-MM-DD
M4All traffic, old database query retiredDatabase 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

IDRiskLikelihoodImpactMitigationOwner
R-01Example: Ranking change lowers click-throughMediumHighA/B test with click and conversion metrics before M4[@name]
R-02Example: Node start too slow during scale-outMediumMediumPre-warmed spare per region; snapshot in the same region[@name]
R-03Example: Location data in logs breaches privacy policyLowHighRounding 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

IDQuestionOwnerDue
OQ-01Example: Do we need indoor or multi-floor search for malls?[@name]YYYY-MM-DD
OQ-02Example: 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

TermMeaning
GeohashBase32 string built by interleaving longitude and latitude bits; a shared prefix means a shared parent rectangle
H3Hexagonal hierarchical grid with 16 resolutions (0 to 15) and 64-bit cell IDs
gridDiskH3 function that returns all cells within kk steps of a cell
S2Hierarchical grid of quadrilateral cells on a cube projection, numbered along a Hilbert curve
QuadtreeTree that splits a square into four children when it holds too many points
HaversineGreat-circle distance formula on a sphere
Effective radiusThe radius the service actually searched after density adaptation
[Term][Meaning]

12.3 References

ReferenceUse
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 APIsError 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