Design a Proximity Service
This page is one interview loop in three rounds. All three rounds design the same system. Each round opens with the interviewer raising the scope, and the design from the round before has to evolve to meet it.
| Round 1: Mid-level | Round 2: Senior | Round 3: Architect | |
|---|---|---|---|
| Story | A city guide app: "coffee near me" in one city | A Yelp-like service: 200M places worldwide | Worldwide regional stacks, live "busy now", personal ranking |
| Level (Amazon) | SDE II (L5) | Senior SDE (L6) | Principal (L7) |
| Traffic | 1M searches/day; 100/s peak | 500M searches/day; 58,000/s peak | 2.6B searches/day; 300,000/s summed over 4 regional peaks |
| Data | 50,000 places, about 75 MB | 200M places, 300 GB (603 GB in 5 years) | 400M places, 600 GB, plus live signals |
| Footprint | 1 region | 1 region, 3 AZs | 4 regions, each backing up a buddy region |
| Targets | P99 < 200 ms; 99.9% | Search P99 < 30 ms; edits visible in 30 s; 99.99% | Search P99 < 50 ms in every region; 99.99% per region |
| Reading time | ~35 min | ~40 min | ~45 min |
You can start at any round. Rounds 2 and 3 open with a "Where we left off" summary that catches you up.
Loop Opener: What Is a Proximity Service?
You Already Use One Every Day
Open a maps app and type "coffee". In a fraction of a second you get a list of cafés, nearest first, each with a distance, a rating and whether it's open. Food delivery, dating, real estate and ride-hailing apps all ask the same question millions of times a minute: what is near this point?
A proximity service answers it. It stores places (restaurants, shops, gas stations), each with a latitude and a longitude, and answers "places within r meters of (lat, lon), matching these filters".
A few words we'll use all page:
| Word | What it means on this page |
|---|---|
| Place | A business or point of interest with a fixed location. Places barely move. |
| Latitude, longitude | Position on Earth in degrees. Latitude runs from −90 (South Pole) to +90 (North Pole); longitude from −180 to +180, with ±180 being the same line (the antimeridian, where the date line runs). |
| Radius search | "Everything within r meters of this point." |
| Recall | Of the places truly within r, how many we returned. Missing a café 20 m away is a recall failure. |
| Precision | Of the places we returned, how many truly are within r. Showing a café 3 km away for a 2 km search is a precision failure. |
What Makes It Hard
- Databases index one dimension; closeness has two. A normal index sorts values on a line. "Near" means close in latitude and longitude at the same time.
- The world is unevenly filled. A block in Manhattan holds hundreds of places. A square kilometer of rural Montana may hold none. Any fixed grid is too fine in one place and too coarse in the other.
- The Earth is round. A degree of longitude is 111 km wide at the equator and zero at the poles. Grids have edges, and the ±180° line cuts the map in two.
- Reads dwarf writes. Places change a few times a year; searches never stop.
The Question the Whole Loop Answers
How do we turn "near me" into a fast index lookup that never misses a place just across a line?
The answer grows every round:
- Round 1: map 2-D onto 1-D cells, search the cell and its neighbors, then check exact distances. A spatial database does all of this for one city.
- Round 2: keep a complete cell index in memory, adapt the cell size to the density, and fix the edges (cell borders, the date line, the poles).
- Round 3: put the index close to users in several regions, add live signals and personal ranking, and keep user locations private.
Round 1 · Mid-level · "Nearby Coffee Shops for One City"
~35 min · SDE II (L5) · 1 region · 50,000 places · 100 searches/s peak · P99 < 200 ms · 99.9%
R1.1 Establish Design Scope
The interviewer says: "Design the backend for a city guide app. Users want to find coffee shops near them." Before drawing anything, we ask.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| What search radius? | 500 m to 5 km. | Our index must answer both a small and a 10× larger circle. We cap the radius, so no query can ask for the whole city. |
| How many results? | The top 20, with "load more". | We need pagination, and a stable order for it. |
| Do places move? | No. They're shops and cafés. | Locations are static data; we can precompute anything that depends on them. (Moving things, like drivers, are a different problem.) |
| How often do places change? | Rarely. An owner edits hours or a phone number a few times a year. | The system is read-heavy. Caching is safe if we handle the rare edit. |
| Filters? | Category: coffee, bakery, restaurant, and so on. | The index must combine "near" with "category". |
| How do we rank? | By distance, nearest first. | Ranking needs the exact distance to each candidate. |
| How big is the city? | About 50,000 places. | Small. One database holds it in memory. |
Out of scope for this round: other cities, ranking by rating, "open now", and search by name.
R1.2 Functional Requirements, Derived Step by Step
| Phrase from the problem | Operation |
|---|---|
| "Find coffee near me" | searchNearby(lat, lon, radius, category) returns up to 20 places, nearest first, each with its distance |
| "Load more" | The same search with a cursor returns the next 20 |
| "Tap a café" | getPlace(id) returns its details |
| "Owners keep their listing current" | createPlace and updatePlace |
Not yet: worldwide coverage, ranking by rating, "open now", and search by name.
R1.3 Non-Functional Requirements: the Questions
We name each quality in words first; the numbers come in R1.7.
- Read-heavy. Searches outnumber edits by thousands to one. We should spend effort making reads cheap, even if writes get a bit more expensive.
- Latency. The list must feel instant while someone stands on a street corner.
- Freshness of edits. When an owner changes their hours, customers should see it soon. "Soon" is a number we'll pick.
- Never miss a nearby place. A café 20 m away that doesn't show up is the worst bug this product can have. Users notice it immediately and stop trusting the app. In our terms: recall inside the radius must be 100%.
R1.4 The API
Search nearby
httpGET /v1/places/nearby?lat=37.7740&lon=-122.4098&radius_m=1000&category=coffee&limit=20 HTTP/1.1 Host: api.cityguide.example
json{ "places": [ { "place_id": "p_48213", "name": "Mint Plaza Coffee", "category": "coffee", "lat": 37.7740, "lon": -122.40955, "distance_m": 22 }, { "place_id": "p_10977", "name": "Sixth Street Roasters", "category": "coffee", "lat": 37.7781, "lon": -122.4129, "distance_m": 531 } ], "next_cursor": "c1.Gx7Qm2fLr8kPz0aT" }
Why a cursor, and not page=2 or offset=20? With an offset, page 2 re-runs the search and skips the first 20 rows. If a new café opened in between, or the user walked 50 m and the phone sent a new position, the order shifts: one place shows up twice and another never appears. A cursor is an opaque token that remembers where page 1 ended: the original center and radius, and the last result's (distance, place_id). Page 2 asks for places strictly after that pair in the same order (keyset pagination), from the same center. The place_id breaks ties between two places at the same distance.
Place details
httpGET /v1/places/p_48213 HTTP/1.1
json{ "place_id": "p_48213", "name": "Mint Plaza Coffee", "category": "coffee", "lat": 37.7740, "lon": -122.40955, "address": "12 Mint Plaza, San Francisco, CA", "phone": "+14155550123", "hours": { "mon": [["07:00", "17:00"]] }, "version": 7 }
Owner create and update
httpPOST /v1/places HTTP/1.1 Authorization: Bearer <owner token> Content-Type: application/json { "name": "Mint Plaza Coffee", "category": "coffee", "lat": 37.7740, "lon": -122.40955, "address": "12 Mint Plaza, San Francisco, CA", "hours": { "mon": [["07:00", "17:00"]] } }
httpPATCH /v1/places/p_48213 HTTP/1.1 If-Match: "7" Content-Type: application/json { "hours": { "mon": [["08:00", "16:00"]] } }
The update carries the version it was based on (If-Match). If someone else changed the place meanwhile, we return 412 Precondition Failed instead of silently overwriting their change.
Status codes
| Code | Meaning |
|---|---|
200 OK / 201 Created | Done |
400 Bad Request | Latitude outside −90..90, longitude outside −180..180, or a radius outside 100 m..5 km |
404 Not Found | No such place |
412 Precondition Failed | The place changed since the version the owner edited |
429 Too Many Requests | A client is over its rate limit (scrapers love this endpoint) |
Recap
- Search: a point, a radius from 500 m to 5 km, a category; 20 results, nearest first, with a cursor.
- 50,000 static places in one city; rare owner edits.
- Recall inside the radius must be 100%.
R1.5 Design Evolution: From a Box Query to a Spatial Index
Each step is a problem, your turn to think, the answer, and what it costs us.
Step 1.0: The Baseline
A places table with lat and lon columns and a B-tree index on each. To search, we turn the circle into a box in degrees and filter:
sqlSELECT place_id, name, lat, lon FROM places WHERE category = 'coffee' AND lat BETWEEN 37.7650 AND 37.7830 -- 1 km north and south AND lon BETWEEN -122.4212 AND -122.3984; -- 1 km east and west, widened by 1/cos(latitude)
The service then computes each row's distance, drops rows outside the circle, sorts, and keeps 20.
For 50,000 places this works fine, and it's where every candidate should start. The weakness is in how the database uses the indexes.
Step 1.1: The Box Query Scans Too Much
The problem: the database picks the lat index, finds every place in a 2 km tall stripe that runs across the whole city, and checks lon on each one. For a city 20 km wide, the stripe holds about ten times more places than the box. Add more cities and it gets worse: the stripe then runs through every city at that latitude.
What would you do? How can one index answer "close in two dimensions"?
Step 1.2: The Café 22 m Away Is Missing
The problem: a user stands at (37.7740, −122.40980), inside cell 9q8yyk about 11 m from its east edge. We return places in 9q8yyk. The list includes a café at (37.7740, −122.4200), 897 m away, but not Mint Plaza Coffee at (37.7740, −122.40955), 22 m away. That café is in the east neighbor, 9q8yys.
What would you do?
Synthesizing vector architecture diagram...
The block of nine cells guarantees the café across the edge is a candidate; the distance filter throws away the corners of the block that are outside the circle.
Step 1.3: Which Cell Size?
The problem: a 500 m search and a 5 km search can't both use 6-character cells: the 3×3 block of 9q8yyk covers only 607 m.
What would you do? How do we pick the precision (the number of characters)?
Step 1.4: The Distances Are Wrong
The problem: a tester in San Francisco reports that a café 1.76 km due east shows as 2.22 km away, and falls outside a 2 km search. The code computes sqrt(Δlat² + Δlon²) × 111.2 km.
What would you do?
Step 1.5: Or Just Use the Database's Spatial Index?
The problem: we've designed geohash cells, a neighbor function, a precision table and a distance filter. The interviewer asks: "Would you build all of this for one city?" What would you do?
Round 1 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.0 | (baseline) | lat/lon B-trees, box query, sort in code | Reads a whole stripe |
| 1.1 | Box query scans too much | Geohash: interleaved bits, Base32, prefix = area | Cells are rectangles; neighbors can have different prefixes |
| 1.2 | Café across the edge missing | 3×3 block + exact distance filter | Nine cells of candidates |
| 1.3 | Which cell size? | Precision from radius and latitude | Several precisions |
| 1.4 | Distances wrong | Haversine (or equirectangular with cos φ) | Tiny CPU cost |
| 1.5 | Build it or use a database? | PostGIS geography + GiST + ST_DWithin | All reads hit one database |
R1.6 Architecture v1
Synthesizing vector architecture diagram...
Everything goes through one small API service. Searches and details read the Aurora reader; owner edits go to the writer. If the writer fails, Aurora promotes the reader, and the API reconnects through the cluster endpoint.
The table
sqlCREATE EXTENSION IF NOT EXISTS postgis; CREATE EXTENSION IF NOT EXISTS btree_gist; CREATE TABLE places ( place_id BIGINT PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL, location GEOGRAPHY(Point, 4326) NOT NULL, -- WGS-84 longitude/latitude address TEXT NOT NULL, phone TEXT, hours JSONB, -- weekly schedule owner_id BIGINT NOT NULL, version INTEGER NOT NULL DEFAULT 1, -- bumped on every update updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX places_loc ON places USING GIST (location); CREATE INDEX places_cat_loc ON places USING GIST (category, location);
Tracing a search: "coffee within 1 km"
- The app sends
GET /v1/places/nearby?lat=37.7740&lon=-122.4098&radius_m=1000&category=coffee. - WAF checks the caller's rate; the ALB forwards to a task.
- The task validates the input and runs the
ST_DWithinquery on the reader, ordered by(distance_m, place_id), limit 21 (one extra tells us whether a next page exists). - The reader walks the
(category, location)GiST index, reads the few hundred matching rows, computes ellipsoidal distances, sorts and returns 21 rows. - The task returns 20, and a cursor built from the 20th row's distance and ID.
Tracing an edit: an owner changes Monday's hours
PATCH /v1/places/p_48213withIf-Match: "7".- The task runs
UPDATE places SET hours = ..., version = 8, updated_at = now() WHERE place_id = 48213 AND version = 7on the writer. Zero rows updated means someone else changed it:412. - The response goes back to the owner, whose own app reads details from the writer for a minute afterward, so they see their own edit at once.
- Other users may see the old hours for up to 30 s, until the details cache entry in each task expires. The reader itself is usually well under a second behind.
R1.7 Numbers
Targets
| Quality | Target | Why this number |
|---|---|---|
| Availability | 99.9% | At most 0.1% of a 30.4-day month: 43,776 min × 0.001 ≈ 44 minutes. Fine for a city guide. |
| Latency | P99 < 200 ms at our load balancer | The list must feel instant; the phone's network adds its own time on top. |
| Edit freshness | Visible to others within 30 s | Owners change hours, not prices; 30 s is invisible to them. |
| Recall | 100% inside the radius | From R1.3. |
Traffic
| Item | Math | Result |
|---|---|---|
| Searches per day | given | 1,000,000 |
| Average | 1,000,000 ÷ 86,400 s | 11.6/s |
| Busiest hour | we assume 15% of a day's searches happen in the lunch or dinner hour: 150,000 ÷ 3,600 | 41.7/s |
| Peak | we assume the busiest minutes run at twice the busiest hour's rate: 2 × 41.7 = 83; we plan for | 100/s |
| Details | we assume one detail view per two searches | 50/s peak |
| Edits | 50,000 places, each edited about once a month: 50,000 ÷ 30 days | about 1,700/day, 0.02/s |
Storage
| Item | Math | Result |
|---|---|---|
| Per place | name, address, phone, hours, location, IDs | ≈ 1.5 KB |
| Table | 50,000 × 1.5 KB | 75 MB |
| With indexes | × 1.8 (primary key, two GiST indexes) | ≈ 135 MB |
The whole database fits in the memory of the smallest sensible instance, so searches never wait on disk.
Work per search. We assume the city covers about 400 km², so about 125 places per km². A 1 km circle is π × 1² ≈ 3.1 km²: about 390 places, of which coffee (say 5%) is about 20. A 5 km circle is 78.5 km²: about 9,800 places, about 490 coffee. With the (category, location) index, the database reads roughly the coffee places in the circle's box, not the whole city. We plan on 2 to 20 ms per query (an assumption to load-test), so 100 searches a second use well under one CPU.
Latency budget, search P99 (dependent steps add):
| Step | P99 |
|---|---|
| WAF and ALB | 3 ms |
| API task: validate, build query | 2 ms |
| PostGIS query (5 km worst case) | 20 ms |
| Serialize 20 results | 2 ms |
| Total | 27 ms, far under 200 ms |
Monthly cost (us-east-1 on-demand list prices, 730 hours a month; check the AWS Pricing Calculator before quoting):
| Item | Math | Monthly |
|---|---|---|
| Aurora writer + reader | 2 × db.r6g.large (2 vCPU, 16 GiB) × $0.26/h × 730 h | ≈ $380 |
| Aurora storage and I/O | under 1 GB at $0.10/GB, plus light I/O (data is cached in memory) | ≈ $5 |
| Fargate | 3 tasks × (0.5 vCPU × $0.03238 + 1 GB × $0.00356)/h × 730 h, ARM | ≈ $43 |
| Application Load Balancer | $0.0225/h × 730 h ≈ $16, plus about 1 capacity unit × $0.008/h × 730 h ≈ $6 | ≈ $22 |
| AWS WAF | $5 per web ACL + a few rules at $1 each + about 45M requests (30M searches, 15M details views) × $0.60/million ≈ $27 | ≈ $35 |
| NAT gateway | 1 × $0.045/h × 730 h, plus a little data | ≈ $35 |
| CloudWatch, logs | an estimate | ≈ $20 |
| Total | ≈ $540/month |
R1.8 Trade-Offs
Ways to index "near"
| PostGIS GiST (R-tree) | Geohash | Quadtree | S2 | H3 | |
|---|---|---|---|---|---|
| Idea | Nested bounding boxes around the actual points | Fixed grid; interleaved bits as a string | Split a square into 4 when it holds too many points | Project the sphere onto a cube's 6 faces; quadtree per face; cells ordered along a Hilbert curve into 64-bit IDs | Hexagons at 16 resolutions; each has 6 neighbors at equal distance |
| Adapts to density | Yes (boxes follow the data) | No, fixed per precision | Yes, by design | Mixed-level cell coverings | No, fixed per resolution |
| Cell shape | n/a | Rectangles, 2:1 at even precisions, distorted toward the poles | Squares in the projection used | Near-square, similar areas | Hexagons (plus 12 pentagons per resolution) |
| Neighbors | n/a | 8, computed; can differ in prefix | Walk the tree | Built-in functions | grid_disk: rings at equal distance |
| Edges, date line, poles | Handled for geography | Our job | Our job | Handled by the cube projection | Handled |
| Where it shines | One database, any filters | Simple keys for caches and key-value stores | In-memory index with skewed data | Covering a circle or polygon with few cells | Equal-distance rings, aggregation (ride-hailing, heat maps) |
For Round 1, PostGIS wins: one query, correct everywhere, no code. The other rows matter when the data outgrows one database.
Precomputed cells vs query-time search. We could store each place's geohash in a column and look up the 9 cells by prefix, or let the GiST index find points at query time. Precomputed cells make reads simple key lookups (great for caches and key-value stores) but fix the cell size in advance. A query-time spatial index adapts to any radius and shape but needs a database that understands geometry. Round 1 uses query-time search; Round 2 precomputes.
R1.9 Failure Modes
| Trigger | What you'd see | How the design responds |
|---|---|---|
| The Aurora writer fails | Edits fail for a short time; searches on the reader continue | Aurora promotes the reader to writer, usually in under a minute. The API connects through the cluster endpoints, so it follows automatically. Owners get 503 and retry. |
| The reader fails | Searches fail until Aurora routes reads elsewhere | The reader endpoint falls back to the writer, which can carry 100 searches a second on its own. |
| A huge radius is requested | One query scans a large part of the city and slows everyone | The API refuses radii above 5 km (400), and the query has a statement timeout (for example 500 ms). |
| A stale cache after an edit | A customer sees old hours for up to 30 s | By design: 30 s TTL on the in-memory details cache, and the owner reads their own edits from the writer. If an owner fixes something urgent, 30 s is the promise. |
| A scraper walks the whole city | Thousands of searches a minute from one client | WAF rate-based rules per IP and per API key return 429. |
R1.10 Pillar Check
| Pillar | What Round 1 covers |
|---|---|
| Reliability | Aurora writer and reader in different AZs with automatic failover; API tasks in three AZs; radius cap and statement timeout stop one query from hurting all REL 10 · REL 11 |
| Performance Efficiency | A true 2-D index (GiST) instead of a stripe scan; the data fits in memory; P99 about 27 ms PERF 3 |
| Security | WAF rate limits against scraping; owners can edit only places they own; database reachable only from the API's security group SEC 3 · SEC 5 |
| Cost Optimization | About $540 a month; the smallest memory-optimized instances, because the data is 135 MB COST 6 |
| Operational Excellence | Skipped this round: one service and one database; basic latency and error alarms. |
| Sustainability | Skipped this round: a handful of small ARM tasks and instances. |
R1.11 Round 1 Rubric and Follow-Ups
What a strong mid-level (L5) answer shows
- Explains why two 1-D indexes can't answer a 2-D question efficiently.
- Encodes a geohash by hand, and knows a shared prefix means a shared cell but not the converse.
- Searches the 3×3 block and filters by exact distance, and separates recall (the block) from precision (the filter).
- Picks the cell size from the radius and the latitude.
- Uses Haversine or ellipsoidal distance, never Pythagoras on degrees.
- Chooses PostGIS for one city, and says when it stops being enough.
Follow-up questions
-
"Why does the 3×3 block need the cell to be at least as big as the radius?" Answer: the user can stand in a corner of the center cell. The circle then reaches r past that corner in two directions. The neighbors are one cell deep, so they catch everything only if a cell is at least r tall and r wide. If r is bigger, part of the circle falls in the second ring of cells and those places are silently missed.
-
"Why not cache search results by the exact (lat, lon)?" Answer: two users a meter apart send different coordinates, so the cache would almost never hit. If we cache, the key must be a cell (and the query's other parameters), not a raw coordinate. Round 2 does exactly that, and recomputes distances per user.
-
"A user searches at 89.9°N. What happens?" Answer: with PostGIS
geography, the query is correct: it's just a distance on the ellipsoid. With hand-built geohash cells, the cells near the pole are thin slivers and the circle covers every longitude, so the 3×3 block is wrong. We'd treat it as a polar cap: all longitudes above a latitude. It's rare, and Round 2 handles it.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
"A composite (lat, lon) B-tree" | It still reads a full latitude stripe. |
| "Search only the user's cell" | Misses every place just across the edge, however close. |
| "Precision 5 cells are 4.9 km" | Only near the equator; 3.9 km in San Francisco, 2.4 km in Oslo. |
"sqrt(Δlat² + Δlon²) × 111 km" | East-west distances off by 1/cos(latitude): +26% in San Francisco. |
"ST_MakePoint(lat, lon)" | The argument order is (longitude, latitude). |
| "Offset pagination" | Pages shift when places change or the user moves; use a cursor. |
Round 2 · Senior · "200M Places Worldwide, 100M Daily Users"
~40 min · Senior SDE (L6) · 1 region, 3 AZs · 58,000 searches/s peak · 200M places · search P99 < 30 ms · edits visible in 30 s · 99.99%
R2.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 2. If you're starting here, it's everything you need from Round 1.
Round 1 in 60 seconds. "We built nearby search for one city: 50,000 places, about 100 searches a second at peak, radii from 500 m to 5 km, 20 results nearest first with a cursor. We learned why two 1-D indexes can't answer a 2-D question, and how a geohash maps 2-D to 1-D: interleave longitude and latitude bits and write them in Base32, so a shared prefix means a shared cell. Because a user can stand next to a cell edge, we search the cell and its 8 neighbors and then filter by exact distance: the 3×3 block gives recall, the filter gives precision, and the block only covers the circle if a cell is at least the radius tall and wide at that latitude. We measure distance with Haversine, never Pythagoras on degrees. Then we noticed PostGIS does all of this for one city: a
geographycolumn, a GiST index andST_DWithin. So v1 is an API on three Fargate tasks over an Aurora PostgreSQL writer and reader with PostGIS, with a 30-second details cache. About $540 a month. Open costs: every search runs on the database, and one fixed cell size can't fit both a dense and an empty area."
Architecture v1, compact
Synthesizing vector architecture diagram...
Round 1 in one picture: the database is the spatial index, and every search reaches it.
Round 1 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | Box query scans a stripe | Geohash cells | Rectangles; prefixes can differ across an edge |
| 1.2 | Café across the edge missing | 3×3 block + exact distance | Nine cells of candidates |
| 1.3 | Cell size | From radius and latitude | Several precisions |
| 1.4 | Wrong distances | Haversine | A little CPU |
| 1.5 | Build or buy | PostGIS GiST + ST_DWithin | All reads on one database |
Open costs: the database carries every search; fixed precision; no ranking beyond distance.
R2.1 The Scope Raise
Interviewer: "The app went global. We now list 200 million places worldwide and have 100 million daily users, each searching about five times a day. Dinner time is brutal. People want the best places, not just the nearest, and they want to hide places that are closed right now. Owners complain that their changes take too long to show up. And we have users in Fiji and in Norway's far north who report odd results."
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How spread out are places? | Very unevenly. A block in Midtown Manhattan has hundreds; parts of Montana have none for tens of kilometers. | One cell size can't work everywhere; we adapt to density (step 2.2). |
| What does "best" mean? | Rating, with enough reviews to trust it, within the radius. | A precomputed quality score, and ranked lists per area (step 2.3). |
| "Open now" in whose time? | The place's local time. | We store each place's time zone and hours; the filter runs at query time (step 2.3). |
| How fast must owner edits show? | Within 30 seconds, in search and on the details page. | A change stream from the database to every copy, and a timing budget that adds every delay (step 2.6). |
| What's the latency target? | Search P99 under 30 ms at our load balancer. | Search can't touch the database on the normal path (step 2.1). |
| Any special events? | New Year's Eve in Times Square, stadiums letting out, a push campaign sending millions to one area. | Hot areas need caching and coalescing (step 2.5). |
| Where do the odd results come from? | Users near the date line see half the places; near the poles, garbage. | Split boxes at ±180°, special handling near the poles (step 2.4). |
Scope change
| Round 1 | Round 2 | |
|---|---|---|
| Places | 50,000 in one city | 200 million worldwide, growing 15% a year |
| Searches | 1M/day, 100/s peak | 500M/day, 58,000/s peak |
| Ranking | Distance | Distance or quality, with rating and "open now" filters |
| Radius | 500 m to 5 km | 100 m to 20 km, chosen automatically by density |
| Edits visible | 30 s (in-process cache) | 30 s end to end, in search and details |
| Edges | One city, no date line | Cell edges, the antimeridian and the poles |
| Targets | P99 < 200 ms; 99.9% | Search P99 < 30 ms; 99.99% (4.4 min/month) |
The "Not yet" list from R1.2 comes back: worldwide coverage, ranking by rating and "open now" are now in scope. Search by name stays out; it belongs to a separate text-search system.
R2.2 What Breaks in the Round 1 Design
| Round 1 choice | How it fails at the new scope |
|---|---|
Every search runs ST_DWithin on Aurora | 58,000 spatial queries a second, each touching hundreds of index entries and rows, needs dozens of large readers, and P99 under 30 ms is out of reach with queueing. |
| Fixed cell size | In Midtown a 1 km circle holds thousands of places, so we'd read and sort thousands to return 20. In Montana, a 5 km circle holds none, so the user sees an empty screen. |
| Sort by distance only | "Best coffee" needs a quality score, and sorting every candidate by it at query time is expensive in dense areas. |
| Box math that assumes −180 < west < east < 180 | A box that crosses ±180° gets a west edge bigger than its east edge and matches nothing, so Fiji users see half their island. |
| Cells near the poles | Cells become slivers and the circle covers every longitude, so the 3×3 block is wrong. |
| Every user hits the same database for the same Times Square area | A viral area makes thousands of identical queries a second. |
| 30 s in-process cache per task | Fine for details, but a search cache keyed by coordinates never hits, and a longer TTL would break the 30 s edit promise. |
The order we fix it in: take reads off the database first (2.1), because every later fix lives in the new read path. Then density (2.2), ranking and filters (2.3), edges (2.4), hot spots (2.5) and freshness (2.6). Sizing comes in R2.6.
R2.3 New Requirements and API Additions
Search, with sorting and filters
httpGET /v1/places/nearby?lat=40.7549&lon=-73.9840&category=coffee&sort=distance&open_now=true&min_rating=4.0&limit=20 HTTP/1.1 Host: api.places.example
sort=distance(nearest first) orsort=quality(best first, within the radius).radius_mis now optional, from 100 m to 20 km. Without it, the service chooses a radius from the local density and widens it until it has enough results or reaches 20 km.open_nowuses the place's own time zone.min_ratingandpricefilter on stored values.
json{ "places": [ { "place_id": "p_7730192", "name": "Bryant Park Espresso", "category": "coffee", "lat": 40.7536, "lon": -73.9832, "distance_m": 158, "rating": 4.5, "review_count": 812, "open_now": true, "open_until_local": "20:00", "why": ["158 m away", "4.5 stars from 812 reviews", "open until 20:00"] } ], "search": { "radius_used_m": 400, "sort": "distance", "exhausted": false }, "next_cursor": "c2.Hq9Zr4LwX1bV" }
whyexplains each result in plain words: the distance, the rating and its number of reviews, and why it passed the filters. Users trust a ranking they can read, and support staff can answer "why is this first?".radius_used_msays how far we looked.exhausted: truemeans we reached 20 km and there are no more results, so the app can say "no more gas stations within 20 km" instead of an unexplained short list.
Owner edits. The same PATCH with If-Match. The promise: within 30 seconds of a 200 OK, every search and details view shows the change.
Details are now served through a CDN (step 2.6) with Cache-Control: max-age=0, s-maxage=15: browsers and apps don't cache, and the CDN caches for at most 15 seconds.
"Open now" rules.
- Hours are stored in local time per weekday, for example
fri: [["18:00", "02:00"]]. An interval that crosses midnight belongs to the day it starts, so at 01:00 on Saturday we also check Friday's intervals. - "Now" is computed in the place's IANA time zone (for example
America/New_York), using the time-zone database, so daylight-saving changes are handled for us. - Special days (holidays) override the weekly schedule for that date.
R2.4 Design Evolution: An Index That Fits the World
Step 2.1: The Database Can't Serve 58,000 Searches a Second
The problem: at 58,000 searches a second, PostGIS on Aurora would need dozens of large readers, and every search would still wait in a database queue. The P99 target is 30 ms. What would you do?
Why not one Redis/Valkey GEO key and GEOSEARCH (or GEORADIUS, deprecated since Redis 6.2 in favour of GEOSEARCH)? A GEO key is a sorted set whose score is a 52-bit interleaved geohash. GEOSEARCH key FROMLONLAT lon lat BYRADIUS r M checks the cell and 8 neighbors at a step chosen for the radius, examines every member in them, and returns those inside the circle (its cost is proportional to the members in the grid-aligned box around the circle). It's a good tool, and a fine answer for a small data set. For us it falls short on four counts:
- One key lives on one shard. A whole country in one key puts all of its searches on one shard's CPU, and a hot city can't be spread out.
- It filters only by distance. It can't filter by category or rating or rank by quality, so in Midtown it would return thousands of members for us to filter. (One key per country and category helps, but not with ranking.)
COUNTdoesn't reduce the work. WithoutANY, the server still examines and sorts every member in the area before returning count of them; withANYthe results may not be the nearest.- It can't index the poles.
GEOADDrejects latitudes beyond ±85.05112878° (the Web Mercator limit), and its distances use Haversine on a sphere (up to 0.5% error).
We keep the idea (sorted sets, geohash cells) but choose our own cell size per area, per category, ranked by quality.
Synthesizing vector architecture diagram...
Writes flow one way: database, log, topic, updater, index. The search service only reads the index, so it never waits on the database.
Primitives: Distributed Cache Patterns and Eviction · Change Data Capture and the Outbox Pattern
Drill: The dual-write that broke search consistency (the dual-write failure and "CDC versus polling an outbox" are answered in the paragraph "Why CDC" above)
Step 2.2: Manhattan Returns 5,000 Candidates; Montana Returns None
The problem: with one precision for everyone, a 1 km search in Midtown reads nine precision-5 cells holding thousands of places. The same search in rural Montana reads nine cells holding nothing. The density of places varies by more than a thousand times. What would you do?
Primitive: Geospatial Indexing: Geohash, Quadtree and S2
Step 2.3: Rank by Quality, Filter by "Open Now"
The problem: "the best coffee within 1 km of Times Square, open now." Within 1 km of Midtown there are hundreds of coffee places. Reading all of them, fetching every card and sorting on each search is too much work at 58,000 searches a second. What would you do?
Step 2.4: Users in Fiji See Half the Results
The problem: Taveuni, an island in Fiji, straddles the 180° line. A user at (−16.80, 179.99) searching 2 km gets only the places west of the line. And a user at a research station at 89.9°S gets nonsense. What would you do?
Synthesizing vector architecture diagram...
Only the box needs splitting; the distance formula already works across the line.
Step 2.5: Times Square on New Year's Eve
The problem: at midnight, a push campaign and a million people in Midtown produce about 20,000 searches a second (an assumption) for the same few cells around Times Square (dr5ru7, and its precision-7 children like dr5ru7v). Every search task computes nearly the same answer thousands of times a second, and the shards holding those keys queue up.
What would you do?
Drill: The ride-hailing hotspot on New Year's Eve (why one fixed precision fails under a thousandfold density difference is step 2.2; why not query GEORADIUS directly instead of our own index, and the in-memory S2 or quadtree alternative, are the note in step 2.1 and the last paragraph above)
Step 2.6: The Owner Fixed Their Hours, but Search Still Shows the Old Ones
The problem: an owner changes Friday's closing time from 22:00 to 18:00 at 17:55. Customers keep arriving at 18:30. The owner was promised 30 seconds. There are now four copies of the place: Aurora, the Valkey index and card, the task caches, and the CDN. What would you do?
Drill: The product page that melted Redis (the cold-cache stampede and "why not cache forever" are the two bullets above)
Round 2 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | Database can't serve 58K/s | Complete Valkey cell index (sorted sets per precision, cell, category) + cards, fed by Debezium CDC | 150 GB of memory; a pipeline |
| 2.2 | Dense vs empty areas | Density map → starting radius → precision; expand until 20 or 20 km; precision 7 where dense | Loops; several copies per place |
| 2.3 | Rank and filter | Bayesian quality score as the set score; top-K per cell, exact inside, deeper at the rim; "open now" in local time | Approximate at the rim |
| 2.4 | Date line and poles | Split boxes at ±180°; polar caps to PostGIS | Extra tested paths |
| 2.5 | Hot areas | Per-cell candidate cache (r + half-diagonal), single-flight, AZ-local replicas | 10 s staleness in hot cells |
| 2.6 | Edits not visible | CDC to cards; versioned writes; CDN TTL 15 s; budget ≤ 17 s | Lag monitoring; reconciliation |
R2.5 Architecture v2
Synthesizing vector architecture diagram...
Searches go straight to the regional load balancer and never touch Aurora. Details go through CloudFront to a small service that reads Aurora readers. Owner edits go to the writer, and flow back into Valkey through the log, the topic and the updater.
Tracing a dense-city search: "nearest open coffee" at (40.7549, −73.9840)
Synthesizing vector architecture diagram...
Two dependent round trips to Valkey. The next request from the same precision-7 cell within 10 s reuses the cached candidates and only recomputes distances.
Tracing a sparse-area search: "nearest gas station" near Jordan, Montana (47.32, −106.91)
- The density map says 0.02 gas stations per km² here, so the starting radius is 25.2 km, capped at 20 km.
- At 47.5°N, a precision-4 cell is 19.4 km tall and 26.4 km wide. It's shorter than 20 km, so no 3×3 block works; we list every precision-4 cell touching the 40 km × 40 km box: at most 4 rows × 3 columns = 12.
- One pipeline of 12
ZRANGEreads; most keys don't exist, which means "no gas stations in that cell". We get 5 members. - Exact distance keeps 3 within 20 km. We fetch 3 cards and return them with
radius_used_m: 20000, exhausted: true.
Tracing an owner edit: Friday closing time 22:00 → 18:00
PATCH /v1/places/p_7730192withIf-Match: "41"reaches the place management API.UPDATE places SET hours = ..., version = 42 WHERE place_id = 7730192 AND version = 41commits on the writer.- Within about a second (at most 5 s by alarm), Debezium publishes the old and new rows to the topic, keyed by the place ID.
- The updater sees the location and categories are unchanged, so the index members don't change (hours aren't in them). It writes card version 42, because the stored card is version 41.
- New searches show "open until 18:00" at once; hot cells within their 10 s cache TTL. The details page shows it after at most 15 s of CDN caching, since the details service reads the reader directly.
Losing a whole shard. Each shard has a primary and two replicas in different AZs, and ElastiCache promotes a replica if the primary fails. Replication is asynchronous, so a promotion can lose the last few milliseconds of writes. After any failover, the updater replays the topic from 10 minutes before the failover, in order; because it applies each place's events in order, replaying a window ends in the right state. If all three nodes of a shard were lost at once, its slots would come back empty, and a missing key would wrongly mean "no places". So we keep one small canary key per hash slot (16,384 keys, with names chosen so one lands in each slot). A checker reads them every 5 seconds; if any is missing, it publishes the list of lost slots to the search tasks, which send searches touching those slots to PostGIS under a rate limit while a bulk job reloads the slots from Aurora. After the reload, the updater replays the topic from before the reload began, and the checker restores the canaries last.
R2.6 Numbers and Cost
Traffic
| Item | Math | Result |
|---|---|---|
| Searches per day | 100M DAU × 5 | 500M |
| Average | 500M ÷ 86,400 s | 5,787/s |
| Peak | We assume the busiest minute of the week (dinner, weekends, holidays) runs at 10× the daily average: 5,787 × 10 = 57,870 | 58,000/s |
| Details views | We assume 2 per user per day: 200M ÷ 86,400 | 2,315/s average, 23,150/s peak |
| Place writes | 200M places, each changed about once in 30 days: 200M ÷ (30 × 86,400) | 77/s average; we assume 500/s at peak (bulk imports) |
We plan every component for 58,000 searches a second of traffic. The fleet's capacity is higher, because it must still carry that peak after losing an AZ; that headroom is capacity, not a second traffic number.
Storage in Aurora
| Item | Math | Result |
|---|---|---|
| Per place | profile (~700 B) + location and cells (~70 B) + hours, tags, photo keys (~730 B) | ≈ 1.5 KB |
| Today | 200M × 1.5 KB | 300 GB |
| In 5 years at 15% a year | 200M × 1.15⁵ = 200M × 2.011 ≈ 402M places × 1.5 KB | ≈ 603 GB |
| With indexes | × 1.8 (primary key, GiST, B-trees) | 540 GB today, ≈ 1.09 TB in 5 years |
Aurora keeps six copies of the data across three AZs, but the storage price already covers them: we pay once for 540 GB, not three or six times.
The Valkey index
| Item | Math | Result |
|---|---|---|
| (place, category) pairs | 200M × 1.5 categories per place (assumption) | 300M |
| Index entries | 300M × (3 precisions + 0.25 for precision 7, assuming a quarter of places are in dense cells) | 975M |
| Bytes per entry | 18-byte member + score + per-entry overhead: about 25 B in small sets (Valkey's compact listpack encoding, up to 128 members), about 110 B in large ones (skiplist plus hash table). We budget 80 B on average (an assumption to check with MEMORY USAGE on real cells) | |
| Index | 975M × 80 B | ≈ 78 GB |
| Cards | 200M × (300 B + about 70 B key and object overhead) | ≈ 74 GB |
| Total | ≈ 152 GB |
A sanity check against the rough "hot set" estimate: about 500,000 busy urban precision-6 cells × 150 place IDs × 16 bytes ≈ 1.2 GB of IDs serve most searches. Our index is much bigger because it is complete (every place, three or four precisions, per category) and carries coordinates and ratings in each entry. That's the price of never falling back to the database, and it still fits in a few nodes. The cards are the 60 GB of summaries (200M × 300 B), kept for every place rather than a 1.5 KB summary for the top 20%, so no search ever misses.
Valkey nodes. We use cache.r7g.2xlarge nodes (8 vCPU, 52.82 GiB). ElastiCache reserves 25% of memory by default, and we fill the rest to 80%: 56.7 GB × 0.75 × 0.8 ≈ 34 GB of data per shard. Memory alone needs 152 ÷ 34 ≈ 4.5 shards. Throughput:
- Commands per search (assumptions): a distance search reads 9 to 12 keys and about 40 cards, about 52 commands; a quality search reads 20 to 50 keys and about 60 cards, about 90. With half of each, about 71; with 30% of searches answered from task candidate caches, about 50 per search.
- At peak: 58,000 × 50 ≈ 2.9M commands a second, at a planning figure of 400,000 pipelined commands a second per node (an assumption to load-test).
We run 6 shards × 3 nodes (a primary and two replicas, one per AZ) = 18 nodes. Losing an AZ leaves 12 nodes: 12 × 400,000 = 4.8M, so 60% busy. Memory per shard is 152 ÷ 6 ≈ 25 GB, with room for about two years of growth before we reshard online.
Why AZ-local reads matter for cost. An average search moves about 26 KB from Valkey (about 14 KB of index entries and 12 KB of cards). At 5,787 searches a second × 70% not cached × 26 KB ≈ 105 MB/s, a month is about 277 TB. If two-thirds of it crossed AZs at $0.02/GB ($0.01 each way), that's about $3,700 a month; with a copy of every shard in every AZ, reads stay in the task's AZ and that line disappears.
Search fleet. We plan 500 searches a second per vCPU (an assumption). If one AZ is lost, the other two must carry 58,000, so each AZ needs 29,000 ÷ 1,000 = 29 tasks of 2 vCPU: 87 tasks at peak. Traffic averages a tenth of peak, and the fleet scales with it; we assume a monthly average of 36 tasks (12 per AZ, never below that).
Aurora. The search path doesn't read Aurora. Details do: 23,150/s at peak, of which about half miss CloudFront's 15-second cache (an assumption for a long-tail catalog), so about 11,600 primary-key reads a second. At a planning figure of 10,000 a second per db.r6g.2xlarge reader, three readers (one per AZ) carry it with one AZ lost: 5,800 each. Plus the writer: 4 instances. RDS Proxy pools the connections.
Bandwidth. A search response is about 8 KB of JSON, about 3 KB compressed (an assumption). Average egress: 5,787 × 3 KB ≈ 17.4 MB/s; over a 30.4-day month (2,626,560 s), about 45.6 TB. Details through CloudFront: 2,315/s × 2 KB ≈ 4.6 MB/s, about 12.2 TB a month. Requests per month: searches 5,787 × 2,626,560 ≈ 15.2 billion; details 2,315 × 2,626,560 ≈ 6.08 billion.
Latency budget, search P99 (at our load balancer; dependent steps add):
| Step | P99 |
|---|---|
| WAF and ALB | 3 ms |
| Parse, pick cells from the in-memory density map | 1 ms |
| Valkey index reads (one pipeline, same AZ) | 2 ms |
| One expansion round, when needed | 2 ms |
| Exact distance, filters, ranking on up to about 1,500 members | 2 ms |
| Valkey card reads | 2 ms |
| Serialize and compress | 2 ms |
| Total | 14 ms, under 30 ms |
Availability. 99.99% of a 30.4-day month is 43,776 min × 0.0001 ≈ 4.4 minutes. Search depends on the ALB, the tasks and Valkey, all spread across three AZs, and not on Aurora; an Aurora failover (typically under a minute) affects only details misses and edits.
Security edge. AWS WAF bills $0.60 per million requests: 21.3 billion requests a month would cost about $12,800. AWS Shield Advanced costs $3,000 a month (one-year commitment) plus a data-transfer fee ($0.05/GB for load balancers and $0.025/GB for CloudFront, for the first 100 TB), and it covers AWS WAF request charges for the resources it protects, up to 50 billion requests a month. That's $3,000 + 45,600 GB × $0.05 + 12,200 GB × $0.025 ≈ $5,590, and it adds DDoS response help. We choose Shield Advanced.
Monthly cost (us-east-1 on-demand list prices, 730 hours a month):
| Item | Math | Monthly |
|---|---|---|
| Aurora instances | 4 × db.r6g.2xlarge × $1.038/h × 730 h | ≈ $3,030 |
| Aurora storage and I/O | 540 GB × $0.10 ≈ $54; I/O from details misses: about 1,160 reads/s average × 2.63M s ≈ 3B I/Os × $0.20/million ≈ $610; plus writes | ≈ $700 |
| RDS Proxy | 4 instances × 8 vCPU × $0.015/h × 730 h | ≈ $350 |
| Valkey | 18 × cache.r7g.2xlarge at about $0.70/h (Valkey is about 20% below the Redis OSS price of $0.873) × 730 h | ≈ $9,170 |
| Fargate (ARM) | search: 36 average tasks × (2 × $0.03238 + 4 × $0.00356)/h × 730 h ≈ $2,076; details 6 and place API 3 tasks of 1 vCPU/2 GB ≈ $259; updaters 3 small ≈ $43 | ≈ $2,380 |
| MSK and Debezium | 3 × kafka.m7g.large ($0.204/h) ≈ $447, storage ≈ $30, 1 MSK Connect unit × $0.11/h ≈ $80 | ≈ $560 |
| Application Load Balancer | $16 fixed + about 84 capacity units (84 GB/h processed) × $0.008/h × 730 h | ≈ $510 |
| Data transfer out (search) | 45.6 TB: 10 TB × $0.09 + 35.6 TB × $0.085 (list tiers) | ≈ $3,930 |
| CloudFront (details) | 6.08B HTTPS requests × $0.0100 per 10,000 ≈ $6,080 (US price; Europe and Asia $0.012, South America $0.022); 12.2 TB: 10 TB × $0.085 + 2.2 TB × $0.080 ≈ $1,030 (US and Europe data prices; other areas cost more) | ≈ $7,110 |
| Shield Advanced, WAF | from above | ≈ $5,590 |
| NAT gateways | 3 × $0.045/h × 730 h, plus a little processing | ≈ $120 |
| CloudWatch, logs, alarms | an estimate | ≈ $1,500 |
| Total | ≈ $34.9K/month |
The biggest lines are the index (Valkey, about $9.2K) and the edge (CloudFront, Shield and data transfer, about $16.6K). The database, which Round 1 was built around, is now about $4K. At 15 billion searches a month, that's about $2.30 per million searches.
R2.7 Trade-Offs
Spatial index at this scale
| Geohash cells in Valkey (chosen) | Quadtree | S2 cells | H3 cells | PostGIS or OpenSearch for every query | |
|---|---|---|---|---|---|
| Density | Fixed precisions + density map | Adapts by splitting | Mixed-level coverings | Fixed resolutions | Adapts (index follows data) |
| Covering a circle | 3×3 or a box of cells; wasteful corners | Leaves touching the circle | Few cells of mixed sizes, tight fit | grid_disk rings, near-circular | Exact |
| Keys in a key-value store | Simple strings | Needs a shared tree | 64-bit IDs; easy | 64-bit IDs; easy | n/a |
| Edge cases | Ours to handle (date line, poles) | Ours | Built in | Built in | Built in |
| Cost per query | Two cache round trips | In-process | Two cache round trips | Two cache round trips | A database query |
Geohash is not the most elegant grid. S2 covers a circle more tightly and handles the sphere for us; H3's equal-distance rings suit aggregation. We chose geohash because it's simple to explain, the prefix and precision table are easy to reason about, and the in-memory index hides its waste. Moving to S2 would change the cell function, not the architecture.
Valkey index vs OpenSearch. Amazon OpenSearch Service can index a geo_point field, filter with geo_distance, and rank with a function score in one query, with filters and text search built in. At 58,000 queries a second it needs a large cluster, and P99 under 30 ms would be hard with scoring on every query. We keep OpenSearch as the answer for name and text search, and use the Valkey index for "near me".
Precomputed vs query-time ranking. Precomputed scores in the sorted sets make "best within 1 km" a few ZRANGE calls; the cost is approximate ranking at the rim and a recompute when reviews change. Query-time ranking of every candidate is exact but reads everything in dense areas. Round 3 adds a personal re-rank on top of the precomputed candidates.
CloudFront for details. It costs about $7.1K a month, mostly per-request fees. Serving details straight from the load balancer would cost about $1K in egress, but would double the Aurora reads and lose edge TLS termination for far-away users. We keep it, and revisit it in Round 3 when the per-request fee grows.
R2.8 Failure Modes
| Trigger | What you'd see | How the design responds |
|---|---|---|
| A Valkey primary fails | A few seconds of write errors on one shard; reads continue from replicas | ElastiCache promotes a replica. The updater replays the topic from 10 minutes before the failover, in order, to restore any writes lost to asynchronous replication. |
| All nodes of a shard lost | Canary keys missing for its slots | Searches touching those slots go to PostGIS under a rate limit (about 2,000 a second across the readers), with shorter radii; a bulk job reloads the slots; then a replay; then canaries restored. |
| The index goes stale (connector stopped, updater stuck) | Replication slot lag and updater lag alarms; edits not visible | Restart the connector from its stored position; WAL is kept by the slot, so nothing is lost. If slot lag threatens the writer's storage, drop and recreate the slot and rebuild the index from a snapshot. |
| Aurora reader connections run out during a surge | Details requests wait for connections; errors rise | RDS Proxy multiplexes many client connections onto a fixed pool; the details service has a bounded queue and fails fast with 503 beyond it; CloudFront keeps serving what it has cached. |
| A viral hotspot | Many identical searches for a few cells | Task candidate cache with single-flight (step 2.5); CloudFront request collapsing and single-flight for details. |
| A bad density map ships | Distance searches start too wide in dense areas | The 500-member cap per key triggers "halve the radius"; the map is versioned, and the tasks can roll back to the previous one. |
Primitive: Circuit Breaker, Bulkhead and Fault Tolerance
R2.9 Production Gotchas
| Gotcha | Why it hurts | What we do |
|---|---|---|
| Flat-earth math | Pythagoras on degrees overstates east-west distances by 1/cos(latitude): +32% in New York, +100% at 60°N. | Haversine (or equirectangular with cos φ) everywhere; PostGIS geography in SQL. |
| Putting moving things in the places database | Delivery drivers or friends updating every few seconds would mean tens of thousands of writes a second into a GiST index built for rarely changing data: bloat, WAL volume and vacuum pressure. | Places only. Moving objects are a different design with short-lived in-memory state (the nearby-friends and ride-hailing problems). |
| A spatial index with no attribute filtering | ST_DWithin(...) AND category = 'coffee' AND rating >= 4 with only a GiST on location reads every place in the circle, then filters. In Midtown that's thousands of rows. | In SQL, a multi-column GiST with btree_gist; in Valkey, keys per category and rating in each member. |
| Unbounded radius | A 500 km search touches thousands of cells and returns huge candidate sets. | Radius capped at 20 km; per-key read cap of 500 members; broader searches go to a different product (region browsing). |
| Geohash neighbors at the edges | Naive neighbor code fails at ±180° or wraps across a pole. | Fixed test cases: ruzzrc → 2hbp21 eastward; no northern neighbor at the pole. |
| An empty set looks like a missing key | Valkey deletes a set when its last member goes. | Our index is complete, so a missing key means empty; lost data is detected separately by canary keys. |
R2.10 Pillar Check
| Pillar | What Round 2 covers |
|---|---|
| Reliability | Search doesn't depend on Aurora; Valkey shards with a replica in every AZ; replay after failover; canary keys detect lost slots; PostGIS fallback under a rate limit REL 5 · REL 10 · REL 11 |
| Performance Efficiency | A complete in-memory index; density-chosen radius and precision; coordinates in each member so cards are fetched only for survivors; P99 about 14 ms PERF 1 · PERF 3 |
| Security | Shield Advanced and WAF on the load balancer and CloudFront; only the place API can write to Aurora; the updater is the only writer to Valkey; owners edit only their own places SEC 3 · SEC 5 |
| Cost Optimization | About $34.9K a month, about $2.30 per million searches; Shield Advanced instead of per-request WAF; AZ-local reads avoid about $3.7K of cross-AZ traffic COST 5 · COST 8 |
| Operational Excellence | Alarms on connector slot lag, updater lag, canary keys, empty-result rate and search P99; reconciliation job OPS 8 |
| Sustainability | Light this round: Graviton throughout; the search fleet scales with the daily curve instead of running at peak size SUS 2 |
R2.11 Round 2 Rubric and Follow-Ups
What a strong senior (L6) answer shows
- Takes the database off the read path, and explains why a complete index is safer than a cache-aside cell cache (empty vs missing keys, partial records, refill races).
- Feeds the index by CDC with ordering per place, and explains dual writes and why log reading beats polling.
- Adapts the radius and precision to density, and proves the distance search is exact.
- Ranks with a Bayesian score, knows where top-K per cell is exact and where it's approximate.
- Handles the date line and the poles explicitly.
- Caches per cell but computes per user, and adds up every delay in the freshness budget.
Follow-up questions
-
"Why store coordinates inside each sorted-set member instead of just place IDs?" Answer: so we can compute exact distances and apply rating and price filters before fetching any card. In Midtown a search reads about 760 members but fetches only about 40 cards. With bare IDs we'd fetch 760 cards (about 230 KB) per search, which at 58,000 searches a second is over 13 GB/s of cache traffic.
-
"The updater crashes after adding a place to its new cell but before removing it from the old one. What does a user see?" Answer: for a moment the place is in both cells. Both members carry their own coordinates, and results are de-duplicated by place ID, so at worst the old position's member passes the distance filter once. When the updater restarts, it re-reads the event from its last committed offset and removes the old member. Applying each place's events in order makes the replay converge.
-
"How would you know the index silently lost some places?" Answer: three signals. Canary keys catch lost slots within seconds. The daily reconciliation compares sampled Aurora rows with members and cards. And the empty-result rate per area, compared with the density map, catches areas that suddenly return nothing.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "More read replicas" for 58K spatial queries a second | Expensive, capped at 15 replicas, and the tail latency stays. |
| "One precision for the world" | Thousands of candidates in Manhattan, nothing in Montana. |
| "Cache-aside for cells" | A missing key can't tell empty from not loaded; edits create partial cells. |
"GEOSEARCH with COUNT 20 is cheap" | Without ANY, the server still examines and sorts everything in the area. |
| "Sort by average rating" | Two 5-star reviews beat 1,842 reviews at 4.6; use a Bayesian average. |
| "Invalidate the CDN on every edit" | Invalidation paths cost $0.005 each beyond 1,000 a month; use a short TTL inside the budget. |
Round 3 · Architect · "Global, Fresh, Personalized, and Multi-Region"
~45 min · Principal (L7) · 4 regions · 300,000 searches/s summed over regional peaks · 400M places · search P99 < 50 ms in every region · 99.99% per region
R3.0 Where We Left Off
Round 2 in 60 seconds. "We serve 200 million places and 58,000 searches a second at peak from one region, with search P99 under 30 ms. Aurora PostgreSQL with PostGIS is the source of truth but is off the search path. A complete index lives in ElastiCache for Valkey: sorted sets per geohash precision, cell and category, whose members carry each place's ID, coordinates, rating and price, scored by a Bayesian quality score, plus a 300-byte card per place. Debezium reads Aurora's log into MSK, and an updater applies each place's changes in order, so a missing key means 'no places', never 'not loaded'. A density map picks the starting radius, the radius and latitude pick the precision, and the search widens until it has 20 results or reaches 20 km; distance search is exact, quality search is exact inside the circle and approximate at the rim. We split boxes at ±180° and send polar caps to PostGIS. A per-cell candidate cache in each task, with single-flight, absorbs hot spots while each user still gets exact distances. Edits show within 17 seconds in search and 16 on details, under the 30-second promise. About $34.9K a month. Open costs: one region serves the world, the data is static, everyone gets the same ranking, and raw coordinates sit in our request logs."
Architecture v2, compact
Synthesizing vector architecture diagram...
Round 2 in one picture: search reads only the in-memory index; the database feeds it through its own log.
Round 2 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | DB can't serve 58K/s | Complete Valkey index + cards via CDC | 150 GB of memory; a pipeline |
| 2.2 | Dense vs empty | Density map → radius → precision; expand | Loops; copies per precision |
| 2.3 | Rank and filter | Bayesian score in the set; top-K per cell | Approximate at the rim |
| 2.4 | Date line and poles | Split boxes; polar caps to PostGIS | Extra tested paths |
| 2.5 | Hot areas | Per-cell candidate cache; single-flight | 10 s staleness |
| 2.6 | Edits slow to show | CDC, versioned cards, 15 s CDN TTL | Lag monitoring |
Open costs: a single region; no live data; one ranking for all; precise locations in logs.
R3.1 The Scope Raise
Interviewer: "We're now the default places app on four continents. 400 million places and 400 million daily users. Users in Tokyo and Sydney say we're slow. Product wants a 'busy right now' indicator, built from anonymous phone counts, and results personalized to each user's taste. Legal reminds us that precise location is sensitive personal data in many countries. And each region must keep serving if another region goes down."
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Where are the users? | 35% in the Americas, 25% in Europe, Africa and the Middle East, 25% in East Asia, 15% in South and Southeast Asia and Oceania. | Four regional stacks, each holding its area's places (step 3.1). |
| Do people search far from where they are? | Some do, planning trips: maybe 5% of searches. | Route a search by where it searches, not only by where the user is (step 3.1). |
| Where does "busy now" come from? | Phones of users who opted in to share visits. No account data, and nothing about any single person may be revealed. | An anonymous event stream, aggregated per place per time window, published only above a minimum count (step 3.2). |
| How fast must personalization be? | Inside the 50 ms target. | Re-rank a few dozen candidates with a light model; no heavy model per candidate (step 3.3). |
| Which location data is sensitive? | Any precise position tied to a person, including request logs. | Coarsen and minimize everywhere we store it (step 3.4). |
| Where do places come from? | Owners, three licensed data providers, user reports and open data. They overlap and disagree. | A conflation pipeline in front of the source of truth, with sources and licenses kept per field (step 3.5). |
| What if a region is down? | Its users must still get search results, even if slower; edits can wait a little. | A buddy region with a warm copy, and capacity sized for it (step 3.6). |
Scope change
| Round 2 | Round 3 | |
|---|---|---|
| Places | 200M | 400M |
| Searches | 500M/day, 58K/s peak | 2.6B/day; 300K/s summed over the four regions' peaks |
| Regions | 1 | 4, paired as buddies |
| Data | Static places | + live "busy now" signals |
| Ranking | Same for everyone | Personalized, with an opt-out |
| Privacy | Not addressed | No precise user locations stored or logged; aggregate-only signals |
| Targets | P99 < 30 ms; 99.99% | P99 < 50 ms in every region, measured at the regional load balancer; 99.99% per region; survive a region loss |
R3.2 What Breaks in the Round 2 Design
| Round 2 choice | What breaks at the new scope |
|---|---|
| One region in us-east-1 | A user in Tokyo pays about 150 ms of network round trip before our 14 ms even starts. And if the region fails, everyone is down. |
| Static data only | "Busy now" changes every few minutes; our pipeline is built for rare edits. |
| One ranking | Everyone near Shinjuku sees the same 20 ramen shops, whatever they like. |
| Coordinates in the query string | Every search's exact position is written to load-balancer and CDN access logs, kept for months: a location history of every user. |
| Place data from one source | Several providers list the same café differently; without merging we'd show it three times. |
| Index sized for 200M places | Doubles to about 330 GB, and must now exist in more than one region. |
R3.3 New Requirements and API Additions
Search moves into a request body, so the position never appears in a URL, and therefore never in access logs:
httpPOST /v1/places:search HTTP/1.1 Host: api.places.example Content-Type: application/json X-Taste-Profile: tp1.AAECAwQF...<signed, expires 2027-03-02T08:00Z> { "lat": 35.6938, "lon": 139.7034, "category": "ramen", "sort": "quality", "radius_m": 1000, "open_now": true, "personalize": true }
json{ "places": [ { "place_id": "p_90331877", "name": "Menya Kaze", "distance_m": 412, "rating": 4.4, "busy": { "level": "busier_than_usual", "as_of": "2027-03-01T12:10:00Z" }, "why": ["412 m away", "4.4 stars from 1,203 reviews", "matches ramen you liked"], "sources": [{ "name": "Owner", "fields": ["hours"] }, { "name": "Provider B", "fields": ["name", "address"] }], "attribution": "Some data © Provider B" } ], "search": { "radius_used_m": 1000, "personalized": true, "served_by": "ap-northeast-1" } }
personalize: falseis the opt-out, per request. The account-level opt-out also stops the app from sending a profile at all.X-Taste-Profilecarries the user's taste as a short vector (step 3.3), signed by our profile job and valid for 24 hours. The user's profile travels with the request; no region has to look it up.busyis a level ("quieter than usual", "usual", "busier than usual") and its time, never a count.sourcesandattributionrecord where each field came from, because some licensed data must be credited or can't be shown everywhere.- Accounts gain a
data_region: where that user's personal data (profile, history, taste vector) is stored and processed.
A presence endpoint for "busy now", anonymous by design:
httpPOST /v1/presence HTTP/1.1 Content-Type: application/json { "place_id": "p_90331877", "visit_token": "vt_3kq9...", "window": "2027-03-01T12:05Z" }
No account, no coordinates: the phone decides on its own that it's inside a place it already knows from recent results. The visit_token is random, changes every day, and is never linked to the account; it only lets us count one phone once per window.
R3.4 Design Evolution: Going Global Without Losing the Plot
Step 3.1: Serve Locally Worldwide
The problem: users in Tokyo and Sydney wait about 150 ms of network time to reach us-east-1. The index doubles to 400M places. What would you do?
Synthesizing vector architecture diagram...
Each region owns its area and keeps a warm copy of its buddy's. Halo copies between neighbors (for example Europe and South Asia) are left out of the picture.
Primitives: Database Sharding and Partition Keys · Cloud Disaster Recovery and Multi-Region Active-Active
Step 3.2: Show How Busy a Place Is Right Now
The problem: product wants "busier than usual" on each result, from phones that opted in. Millions of phones, all day. What would you do?
Synthesizing vector architecture diagram...
The only thing that leaves the stream is a coarse level for places with at least 10 distinct visitors.
Primitive: Message Queues vs Event Streams
Step 3.3: Personal Ranking Within 50 ms
The problem: two users near Shinjuku should see different ramen shops: one likes rich tonkotsu, the other light shio. The budget is 50 ms, and we already spend about 14. What would you do?
Step 3.4: User Locations Are Sensitive
The problem: in Round 2, every search URL contained lat=40.7549&lon=-73.9840. The load balancer and CDN access logs kept it, along with the client IP, for months. Analysts copied search logs into a warehouse to study demand.
What would you do?
Step 3.5: Place Data Comes From Many Sources
The problem: three licensed providers, owners, user reports and open data all describe the same café. One provider says it's at number 12, another at 14, and the owner changed the hours yesterday. Loading them all shows the café three times. What would you do?
Step 3.6: A Region Is Down
The problem: ap-southeast-1 stops answering. 15% of our users (Singapore, Mumbai, Sydney) get errors. What would you do?
Round 3 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 3.1 | Far users are slow; a region is a single point | 4 home regions by geohash partition map; halos; buddies; route by the searched point | Border logic; forwarded searches |
| 3.2 | "Busy now" | Anonymous presence events → Kinesis → Flink distinct counts; publish levels only above 10 | A stream per region; approximate |
| 3.3 | Personal ranking | Retrieve, then re-rank top 50 with embeddings and a signed taste vector in the request | Training pipeline; cold start |
| 3.4 | Sensitive locations | Coordinates in bodies; coarse cells in logs; short retention; consent; residency | Coarser analytics |
| 3.5 | Many sources | Conflation: normalize, match, merge by field priority, keep sources | Data engineering; merge bugs |
| 3.6 | A region is down | Buddy with a warm copy; DNS failover; edits paused, promoted by an operator if long | Warm copies; paired capacity |
R3.5 Global Architecture
Synthesizing vector architecture diagram...
One region's stack. Its home places live in Aurora and flow into Valkey through the log; presence events flow into the same Valkey as busy levels. Dotted lines leave the region: change topics and the database copy go to the buddy, and far-away searches go to their owning region.
Tracing a search in Tokyo: "best ramen within 1 km, open now, personalized" at (35.6938, 139.7034)
Synthesizing vector architecture diagram...
The same two round trips as Round 2, plus the busy hashes in the second one and a sub-2 ms re-rank. The coordinates arrive in the body and are never logged; the search log gets xn775, category and result count.
Tracing a busy-now update
- A phone that opted in has been inside Menya Kaze for 5 minutes. It sends
POST /v1/presencewith the place ID, today's random token and the window12:05. - The ingest service checks the device-integrity token and a rate limit per
visit_token, then puts the event into Kinesis with the place ID as the partition key. - At 12:10, Flink's 15-minute window (11:55 to 12:10) holds 23 distinct tokens for this place. The usual count for Monday 21:00 local time (12:00 UTC) is 14, so the level is "busier than usual".
- Flink writes
HSET busy:xn775 p_90331877 "busier|2027-03-01T12:10Z". - Searches from 12:10 show the level; searches after 12:30 ignore it unless a newer window has replaced it.
Tracing a region failover: ap-southeast-1 fails at 09:00 UTC
Synthesizing vector architecture diagram...
Search recovers in a few minutes without anyone acting. Users land wherever latency routing sends them, and any region that isn't the buddy forwards the failed region's cells to it. Edits wait, and only an operator turns the buddy's database copy into a writer.
R3.6 Numbers and Cost
Traffic
| Item | Math | Result |
|---|---|---|
| Searches per day | 400M DAU × 6.5 searches (assumption) | 2.6B |
| Average | 2.6B ÷ 86,400 s | 30,093/s |
| Region | Share | Average | Peak (10× the region's own average, as in Round 2) |
|---|---|---|---|
| us-east-1 | 35% | 10,533/s | 105,000/s |
| eu-central-1 | 25% | 7,523/s | 75,000/s |
| ap-northeast-1 | 25% | 7,523/s | 75,000/s |
| ap-southeast-1 | 15% | 4,514/s | 45,000/s |
| Sum | 30,093/s | 300,000/s |
The 300,000 is the sum of the four regional peaks, which is what we must provision, since each region is sized for its own peak. The four peaks happen at different hours, so the whole world's traffic at any one moment is lower.
Places and memory per region. Each place costs about 830 bytes in Valkey: 390 B of index entries (4.875 entries at 80 B, from Round 2), 370 B of card, and 64 B of embedding. Each region holds its home places, its halo (2%) and its buddy's places:
| Region | Places held | Memory |
|---|---|---|
| us-east-1 | 120M home + 120M buddy (Europe) | 240M × 830 B × 1.02 ≈ 203 GB |
| eu-central-1 | 120M + 120M (Americas) | ≈ 203 GB |
| ap-northeast-1 | 100M + 60M (South and SE Asia) | 160M × 830 B × 1.02 ≈ 135 GB |
| ap-southeast-1 | 60M + 100M (East Asia) | ≈ 135 GB |
Busy hashes add little: at most one short field per place with a signal.
Valkey capacity. About 55 commands per search: Round 2's 50 plus about 5 busy-hash reads (the re-rank uses cards we already fetch). Planning figure: 400,000 commands a second per cache.r7g.2xlarge node. Each region must carry (a) its own peak after losing an AZ, and (b) its own load plus its buddy's peak during a regional failover (we don't plan for both at once).
| Region | (a) own peak / (2/3) | (b) failover load | Nodes needed | We run |
|---|---|---|---|---|
| us-east-1 | 105K × 55 / (2/3) = 8.7M/s | Europe's peak (75K) + Americas at about half its peak then (52.5K) = 127.5K × 55 = 7.0M/s | 8.7M ÷ 400K = 22 | 8 shards × 3 = 24 (9.6M/s) |
| eu-central-1 | 75K × 55 / (2/3) = 6.2M/s | Americas' peak (105K) + Europe at about 20% then (15K) = 120K × 55 = 6.6M/s | 6.6M ÷ 400K = 17, rounded to 6 per AZ = 18 | 7 shards × 3 = 21 (8.4M/s), 7 shards for memory: 6 would hold 33.8 GB each, at the 34 GB limit |
| ap-northeast-1 | 6.2M/s | Own 75K + 90% of Singapore's 45K = 115.5K × 55 = 6.4M/s | 16, rounded to 6 per AZ = 18 | 6 shards × 3 = 18 (7.2M/s) |
| ap-southeast-1 | 45K × 55 / (2/3) = 3.7M/s | Own 45K + 90% of Tokyo's 75K = 112.5K × 55 = 6.2M/s | 16 | 6 shards × 3 = 18 (7.2M/s) |
The shares of each region's peak during the buddy's peak (half, 20%, 90%) are assumptions from the time-zone offsets. Memory per shard is 22 to 29 GB, under the 34 GB we allow. Singapore runs 1.5 times the nodes its own traffic needs (it needs 12, runs 18), because its buddy peaks at almost the same time: the cost of pairing regions an hour apart.
Search fleet. Re-ranking adds CPU, so we plan 400 searches a second per vCPU, 800 per 2-vCPU task. Each AZ must carry half the region's peak:
| Region | Tasks per AZ at peak | Tasks at peak | Average tasks (41% of peak, as in Round 2) |
|---|---|---|---|
| us-east-1 | 52,500 ÷ 800 → 66 | 198 | 81 |
| eu-central-1 | 37,500 ÷ 800 → 47 | 141 | 58 |
| ap-northeast-1 | 47 | 141 | 58 |
| ap-southeast-1 | 22,500 ÷ 800 → 29 | 87 | 36 |
During a regional failover the buddy's fleet scales out on Fargate within minutes; tasks are cheap to add, unlike Valkey memory, which is why Valkey is the part sized ahead.
Details and Aurora. Details views: 400M × 2 = 800M a day, 9,259/s average, 92,600/s summed peak; half miss CloudFront. us-east-1's origin peak is 35% × 92,600 × 50% ≈ 16,200/s; with three readers at 10,000/s each, losing one AZ leaves two at 8,100/s. Each region runs a writer and three readers for its home cluster, plus a two-instance Aurora Global Database secondary for its buddy: 6 db.r6g.2xlarge per region. Aurora storage: 1.5 KB × 1.8 per place, home plus buddy copy: 2 × 400M × 2.7 KB ≈ 2.2 TB in total.
Live signals. 20% of users opt in (assumption): 80M phones × 4 visits a day = 320M events a day, 3,704/s on average. Peaks for presence are flatter than for search (people are in places all day), so we plan 3× each region's average: us-east-1 3,889/s → 5 Kinesis shards (1,000 records/s each, plus one spare); 16 shards in total. Flink: about 16 processing units across the four regions.
Latency budget, search P99 at the regional ALB:
| Step | P99 |
|---|---|
| Round 2 path (WAF, ALB, cells, two Valkey rounds, filters, serialize) | 14 ms |
| Busy hashes (same pipeline as cards) | 0.5 ms |
| Re-rank 50 candidates | 2 ms |
| Partition-map check, profile signature check | 0.5 ms |
| Total | 17 ms, under 50 ms |
Forwarded searches add the backbone round trip (for example about 230 ms Tokyo to Frankfurt) and are reported separately.
Egress. Responses grow to about 3.5 KB compressed. A 30.4-day month is 2,626,560 s.
| Region | Traffic | List price tiers (per GB) | Monthly |
|---|---|---|---|
| us-east-1 | 10,533/s × 3.5 KB ≈ 96.8 TB | 10 TB × $0.09 + 40 TB × $0.085 + 46.8 TB × $0.07 | ≈ $7,580 |
| eu-central-1 | 7,523/s × 3.5 KB ≈ 69.2 TB | 10 × $0.09 + 40 × $0.085 + 19.2 × $0.07 | ≈ $5,640 |
| ap-northeast-1 | ≈ 69.2 TB | 10 × $0.114 + 40 × $0.089 + 19.2 × $0.086 | ≈ $6,350 |
| ap-southeast-1 | 4,514/s × 3.5 KB ≈ 41.5 TB | 10 × $0.12 + 31.5 × $0.085 | ≈ $3,880 |
| Total | 276.7 TB | ≈ $23.4K |
Security edge. Requests a month: searches 2.6B × 30.4 ≈ 79.0B, details 800M × 30.4 ≈ 24.3B, presence 320M × 30.4 ≈ 9.7B: about 113 billion. Shield Advanced covers WAF request fees up to 50 billion a month per payer account; the other 63 billion cost $0.60 per million: about $37.8K. Shield's data-transfer fee is tiered: load balancers 100 TB × $0.05 + 176.7 TB × $0.04 = $12,070; CloudFront 48.6 TB × $0.025 ≈ $1,220; plus the $3,000 subscription: about $16.3K.
Monthly cost. Prices outside us-east-1 are higher. We use each region's list price for Valkey nodes (about +20% in Frankfurt, Tokyo and Singapore) and Aurora instances (about +21%), and assume +15% in eu-central-1 and +25% in the two Asia-Pacific regions for Fargate and the smaller lines (check the calculator per region).
| Item | Math | Monthly |
|---|---|---|
| Valkey | 24 × $0.698/h (US) + 21 × $0.840 (Frankfurt) + 18 × $0.838 (Tokyo) + 18 × $0.838 (Singapore), × 730 h: $12,230 + $12,880 + $11,010 + $11,020 | ≈ $47.1K |
| Fargate | search: 81 × $57.67 + 58 × $66.32 + 58 × $72.09 + 36 × $72.09 per task-month ≈ $15.3K; details, place API, updaters, presence ingest ≈ $1.7K | ≈ $17.0K |
| Aurora | 6 × db.r6g.2xlarge at $1.038/h in the US + 18 at about $1.253/h in the other three regions, × 730 h: $4,550 + $16,460 ≈ $21.0K; 2.2 TB storage and I/O ≈ $2.7K | ≈ $23.7K |
| RDS Proxy | 24 instances × 8 vCPU × $0.015/h × 730 h, with uplifts | ≈ $2.4K |
| MSK, Debezium, MSK Replicator | about $650 per region, plus replicators for the two buddy pairs (an estimate) | ≈ $3.9K |
| Kinesis and Flink | 16 shards ≈ $175 + 9.7B PUT units × $0.014/million ≈ $136; about 16 Flink units × $0.11/h × 730 h ≈ $1.3K; with uplifts | ≈ $1.9K |
| Load balancers | about 169 capacity units in us-east-1 (169 GB/h processed) and fewer elsewhere | ≈ $3.3K |
| Data transfer out | table above | ≈ $23.4K |
| CloudFront (details) | 24.3B requests × about $0.012 per 10,000 (blended: $0.0100 in the US and Canada, $0.012 in Europe and Asia, more in Australia and South America) ≈ $29.2K; 48.6 TB × about $0.09 ≈ $4.4K | ≈ $33.6K |
| Shield Advanced | subscription + data-transfer fees | ≈ $16.3K |
| WAF beyond Shield's 50B | 63B × $0.60/million | ≈ $37.8K |
| Personalization | training and nightly embedding and profile jobs (an estimate) | ≈ $3.0K |
| Conflation | Glue, Step Functions, S3 (an estimate) | ≈ $4.0K |
| NAT, CloudWatch, logs | about $2K per region | ≈ $8.0K |
| Total | ≈ $225K/month |
The edge (data transfer, CloudFront, Shield and WAF) is about $111.1K, 49% of the bill. The index is 21%, and the database 11%. At 79 billion searches a month, that's about $2.85 per million searches, a little more than Round 2's $2.30 because of the buddy copies, the busy pipeline and WAF requests past the covered 50 billion.
R3.7 Trade-Offs
Geographic ownership vs a copy of everything everywhere
| Home regions + halo + buddy (chosen) | Full copy in every region | |
|---|---|---|
| Memory | Home + buddy: about 676 GB in total | 4 × about 332 GB ≈ 1.33 TB of primary data, before replicas |
| Edits applied | 2 times (home + buddy), plus halos | 4 times |
| Far searches | Forwarded, about 230 ms more | Local everywhere |
| Region failover | Only the buddy can take over | Any region can |
| Data placement | Places live where they are | Every place in every region |
We chose ownership because 95% of searches are near the user, and it halves the memory. If trip planning grew to a large share of searches, a full-copy tier for the index only (not the databases) would be the next step.
Live-signal threshold vs coverage. A minimum of 10 distinct phones protects individuals and filters noise, but only busy places ever get a level; small cafés never do. Lowering the threshold covers more places and reveals more about fewer people. We start at 10 and publish relative levels only; raising coverage means more opted-in users, not a lower threshold.
Personalization vs latency. Re-ranking 50 candidates with a small model costs 2 ms; re-ranking 500 with a large model could lift relevance a little and would cost tens of milliseconds and much more CPU. The profile in the request avoids a lookup entirely, at the price of a 24-hour-old profile. We accept both.
Closing the loop on CloudFront. At this scale CloudFront's per-request fee ($29.2K) is one of the biggest lines. The alternative is serving details from each regional stack: about $4K of egress plus doubled Aurora reads (roughly 12 more readers, about $11K), so about $18K a month cheaper, with regional stacks now close to users anyway. The other levers are a spend commitment with AWS that lowers CloudFront and WAF prices, and smaller responses. We would measure the CDN's real hit ratio first; if it's near our 50% assumption, moving details to the regional stacks wins.
What changed from Round 1. Round 1 answered "near me" with one database query. Round 3 answers it with four regions of in-memory indexes that the database feeds but never serves from. The idea at the core didn't change: cells that map 2-D to 1-D, a neighbor search that never misses across an edge, and exact distance to decide.
R3.8 Failure Modes
| Trigger | What you'd see | How the design responds |
|---|---|---|
| A region is down | Health checks fail; the region's users get errors until DNS moves them | Buddy serves from its warm copy within a few minutes; edits paused; operator promotion after an hour (step 3.6). |
| A conflation bug duplicates places | Duplicate-pair rate (two places within 30 m with nearly the same name) jumps; users see a café twice | Every run is diffed before publishing; if more than 0.5% of places change, or the duplicate rate rises, the run is held for review. Merges keep their source records, so a bad run is undone by re-publishing the previous run's output. The worse bug, merging two real places, is caught by the same diff and by owner reports. |
| The busy pipeline lags | Kinesis iterator age grows; levels get older | Search ignores levels older than 20 minutes, so users see no level rather than a wrong one. Scale the Flink application; Kinesis keeps 24 hours of events. |
| Personalization breaks (bad model, profile job down) | Click-through drops; profiles expire | A flag turns re-ranking off, and results fall back to Round 2 ranking. Expired profiles fall back automatically. |
| A halo gap | Searches near a region border miss places on the other side | A daily check compares each halo with the neighbor's source of truth; until fixed, border searches are forwarded to both owners and merged. |
| Cross-region replication stalls | Replicator lag grows; the buddy's copy ages | Alarm on lag; the copy is only used during a failover; if it's too old then, we say so in the response (stale_since). |
R3.9 Runbook and Incident Response
Golden signals, per region OPS 8 · REL 6
| Signal | Alarm | Severity | First action |
|---|---|---|---|
| Search P99 at the regional ALB | > 50 ms for 5 min | P1 | Check Valkey CPU per shard and the candidate cache hit ratio; scale the search fleet |
| Candidate cache hit ratio | < 20% for 15 min (normally about 30%) | P3 | A deploy reset the caches, or query keys changed; check the key normalization |
| Candidates per query (P99 members read) | > 3,000 | P2 | The density map is stale or wrong in an area; roll back the map version |
| Empty-result rate per area vs the density map | an area that has places returns nothing 3× more than its baseline | P1 | Index loss or a halo gap; check canary keys and halo checks |
| Invalidation lag: Debezium slot lag + updater lag + replicator lag | > 5 s, > 2 s, > 60 s | P2 | Restart the connector or scale the updater; the 30-second edit promise is at risk |
| Canary keys missing | any | P1 | Lost slots; the fallback to PostGIS starts automatically; confirm the reload job is running |
| Busy-level age (Kinesis iterator age) | > 5 min | P3 | Scale Flink; stale levels are already being ignored |
| Forwarded-search share | > 10% | P3 | Partition map or halo problem, or a real shift in behavior |
Hotspot procedure REL 7
- Identify the hot cells: top keys by read rate on the busiest shard, and the top cache keys in the search tasks.
- Confirm the candidate cache is hitting for those cells; if not, check for a query parameter that breaks the cache key (a client sending unrounded radii, for example).
- If one shard is still hot, add copies of the hottest keys under suffixed names on other shards, and turn on random selection for them.
- For announced events (New Year's Eve, a stadium final), pre-scale the search fleet in that region the hour before.
Regional failover procedure REL 13
- Confirm it's the region: several services and AWS health signals agree. Route 53 moves users on its own; confirm every region's partition map now marks the failed region's cells as owned by the buddy, so the other regions forward those searches.
- Watch the buddy: search P99, Valkey CPU, fleet scaling. Shed personalization first if the buddy is short of capacity.
- If the outage lasts more than an hour and edits matter: set the failed region's writer flag to "fenced", then promote the Aurora secondary in the buddy (command 5). Point the place API for those cells at it. Then start a Debezium connector on the promoted cluster (a new replication slot and a fresh snapshot of those cells), feeding the buddy's updater; without it, edits written to the promoted cluster never reach the index and stay invisible in search.
- When the region returns: it rejoins as a secondary, catches up, and a planned switchover (command 6) moves its places back.
Go deeper: CLI playbook
Plain commands an on-call engineer runs one at a time. Replace names and ARNs with real ones.
text# 1. Alarms firing for the places service in a region aws cloudwatch describe-alarms --region ap-northeast-1 --state-value ALARM --alarm-name-prefix places- # 2. Health check status for a regional search endpoint aws route53 get-health-check-status --health-check-id 1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d # 3. State of a region's Valkey index cluster aws elasticache describe-replication-groups --region ap-northeast-1 --replication-group-id places-index # 4. Emergency scale-out of the search fleet aws ecs update-service --region ap-northeast-1 --cluster places --service search --desired-count 200 # 5. Unplanned promotion of the buddy's Aurora secondary (can lose the replication lag) aws rds failover-global-cluster --global-cluster-identifier places-sea --target-db-cluster-identifier arn:aws:rds:ap-northeast-1:111122223333:cluster:places-sea-apne1 --allow-data-loss # 6. Planned switchover back once the region is healthy (waits for replication) aws rds switchover-global-cluster --global-cluster-identifier places-sea --target-db-cluster-identifier arn:aws:rds:ap-southeast-1:111122223333:cluster:places-sea-apse1 # 7. The presence stream's shape and retention aws kinesis describe-stream-summary --region ap-northeast-1 --stream-name presence-events # 8. State of the busy-now Flink application aws kinesisanalyticsv2 describe-application --region ap-northeast-1 --application-name busy-now # 9. State of the cross-region copy of the buddy's change topic aws kafka describe-replicator --region ap-northeast-1 --replicator-arn arn:aws:kafka:ap-northeast-1:111122223333:replicator/sea-to-apne1/1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d-2
Audit query (read-only, on a reader): places whose index card version lags the database are found by the reconciliation job; this query lists the rows it samples.
sqlSELECT place_id, version, updated_at FROM places TABLESAMPLE SYSTEM (0.01) WHERE updated_at < now() - interval '1 minute';
R3.10 Pillar Check
| Pillar | What Round 3 adds |
|---|---|
| Reliability | Four regions with buddies; search fails over by DNS in minutes from a warm copy; edits paused and promoted only by an operator behind a writer fence; capacity sized for the pair REL 10 · REL 13 |
| Security | Coordinates kept out of URLs and logs; logging deny-list; presence events without accounts; signed, expiring taste profiles; personal data in the user's data_region SEC 7 · SEC 8 |
| Performance Efficiency | Search served in the region that owns the area; halos keep border searches local; re-rank 50 candidates in-process; P99 about 17 ms PERF 1 · PERF 4 |
| Cost Optimization | About $225K a month, $2.85 per million searches; the edge is 49% of it; ownership instead of full copies halves index memory; the CloudFront decision measured, not assumed; regional prices applied COST 5 · COST 8 |
| Operational Excellence | Golden signals with first actions; hotspot and failover procedures; conflation runs held for review when their diff is too large OPS 8 · OPS 10 |
| Sustainability | Regions chosen by where users and places are; a buddy copy instead of a world copy in every region; raw presence data kept 24 hours SUS 1 · SUS 4 |
R3.11 Round 3 Rubric and Follow-Ups
What an architect (L7) answer adds over L6
- Places data by geography, routes by the searched point, and handles borders with halos.
- Plans region failure with a warm buddy, sizes capacity for the pair's overlapping peaks, and doesn't promise more nines than DNS failover allows.
- Builds live signals that are anonymous by construction: no account, no coordinates, distinct counts, a minimum count and relative levels.
- Personalizes with retrieval then re-ranking, and keeps profiles out of every region by sending them with the request.
- Finds every place a location can leak (URLs, access logs, traces, analytics) and closes each one.
- Treats place data as merged from sources with field-level priority and licenses.
- Sees that the edge, not the index, is half the bill, and names the levers.
Follow-up questions
-
"A user stands in Strasbourg, right at the France-Germany border, and both are in eu-central-1. What about a user at the US-Mexico border?" Answer: both sides of both borders belong to the same home region, so nothing special happens. Region borders are drawn in the partition map by us, through oceans and sparse areas where possible. Where a border does cut through populated land, the halo covers it: each region indexes places up to 20 km beyond its border, the maximum radius.
-
"Why not send exact counts for busy-now? More useful." Answer: an exact small count can identify people ("the one visitor at 7 am is my neighbor"), and a count is only meaningful against the place's size, which users don't know. A level relative to the usual for that hour answers the real question ("will it be crowded?") and reveals less.
-
"Your 99.99% per region: what does a regional outage do to it?" Answer: 99.99% of a month is 4.4 minutes. A DNS failover takes about 1.5 to 3 minutes for most clients, so one regional failover a month uses most of that region's budget. That's why we promise 99.99% per region and not 99.999% (5.26 minutes a year): a single failover would spend the year's budget.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Route by the user's location" only | A trip planner in Tokyo searching Paris needs Paris's data; route by the searched point. |
| "Count pings with user IDs" | A location history of every opted-in user; count anonymous, distinct, thresholded. |
| "Fetch the profile from the home region" | A cross-region round trip per search, or personal data copied everywhere. |
| "Coordinates in the query string are fine" | Access logs keep them, with the IP, for months. |
| "Last writer wins" for place data | A provider file overwrites yesterday's owner edit. |
| "Five nines with DNS failover" | One failover takes minutes; five nines allows about 5 minutes a year. |
| "Any region can take over any region" | Needs the world's data and any region's peak everywhere. |
Loop Closer: Interview Strategy for All Three Rounds
How to Run Each 60-Minute Round
| Time | Round 1 | Round 2 | Round 3 |
|---|---|---|---|
| 0–5 min | Scoping: radius, results, static places, filters, ranking | Restate Round 1 in 60 seconds | Restate Round 2 in 60 seconds |
| 5–15 min | Requirements and API (cursor pagination, If-Match edits) | Scope raise → what breaks | Scope raise → what breaks |
| 15–40 min | Steps 1.0–1.5: box query → geohash by hand → 3×3 + exact distance → precision by latitude → Haversine → PostGIS | Steps 2.1–2.6: complete index via CDC → density-chosen radius → ranked cells → date line and poles → hot cells → freshness budget | Steps 3.1–3.6: home regions and halos → busy now → re-rank → privacy → conflation → buddy failover |
| 40–50 min | Numbers, cost, index comparison table | Memory, commands, fleet, cost, trade-offs | Per-region capacity for the pair, edge costs, trade-offs |
| 50–60 min | Failures and pillar check | Failures, gotchas, pillar check | Failures, runbook, pillar check |
For how to spend a single 45-minute round, see the 45-minute interview blueprint.
The Two Sentences That Matter Most
- Opening any round: "Databases index one dimension and closeness has two, so I'll map positions to cells, search the user's cell and its neighbors with cells at least as big as the radius at that latitude, and let an exact distance decide: the cells give recall, the distance gives precision."
- When scale arrives: "I'll keep the spatial database as the source of truth and serve reads from a complete in-memory cell index fed by its change log, so a missing key means 'nothing here', and I'll let density choose the radius so Manhattan and Montana both do bounded work."
Well-Architected Review Sheet
Interviewers rarely ask "which pillar is this?". They ask the pillar's question in plain words. Rehearse one sentence per row.
| Pillar | Question you'll hear | One-sentence answer | Round | Backed by |
|---|---|---|---|---|
| Reliability | "What if the index loses a shard?" (REL 11) | Replicas in every AZ take over, the updater replays 10 minutes of changes, and canary keys catch a total loss so searches fall back to PostGIS under a rate limit. | 2 | R2.5, R2.8 |
| "What if everyone searches Times Square at once?" (REL 5) | A per-cell candidate cache with single-flight in each task, exact distances per user, and AZ-local replicas. | 2 | Step 2.5 | |
| "What if a whole region fails?" (REL 13) | Route 53 moves search to the buddy, which has a warm copy and capacity for both peaks; edits pause until an operator promotes. | 3 | Step 3.6 | |
| Performance | "Why not just use PostGIS?" (PERF 3) | For one city we do; at 58,000 searches a second it becomes the source of truth behind an in-memory index. | 1–2 | Steps 1.5, 2.1 |
| "How do you handle Manhattan and Montana?" (PERF 1) | A density map picks the starting radius, the radius and latitude pick the precision, and we widen until we have 20. | 2 | Step 2.2 | |
| "Why is it fast in Tokyo?" (PERF 4) | Tokyo owns East Asia's places and serves them locally; only far-away searches are forwarded. | 3 | Step 3.1 | |
| Security | "Where do user locations end up?" (SEC 7) | Nowhere precise: coordinates travel in the body, logs keep a few-kilometer cell for 30 days, and presence events carry no account. | 3 | Step 3.4 |
| "Who can change a place?" (SEC 3) | Its verified owner through the place API, and the conflation pipeline; the updater is the only writer to the index. | 2–3 | R2.10, step 3.5 | |
| Cost | "What does it cost?" (COST 5) | About $540, $34.9K and $225K a month: roughly $2.30 to $2.85 per million searches at scale. | 1–3 | R1.7, R2.6, R3.6 |
| "Where does the money go at scale?" (COST 8) | Half is the edge (egress, CDN requests, Shield, WAF beyond 50 billion requests); AZ-local reads avoid cross-AZ charges. | 2–3 | R2.6, R3.6 | |
| Operations | "How do you know search is right?" (OPS 8) | Empty-result rate against the density map, candidates per query, invalidation lag, canary keys and a daily reconciliation. | 2–3 | R3.9 |
| Sustainability | "Do you need everything everywhere?" (SUS 1) | No: each region holds its own places and one buddy's, not the world. | 3 | R3.7 |
Rubric Across Levels
| Dimension | L5 (Round 1) | L6 (Round 2) | L7 (Round 3) |
|---|---|---|---|
| 2-D to 1-D | Geohash by hand; prefix property and its limit | Precision by radius and latitude, several precisions indexed | Cell choice as an architecture detail (geohash vs S2 vs H3), partition map by cell |
| Never missing a place | 3×3 block + exact distance; recall vs precision | Density-driven expansion that is provably exact for distance; date line and poles | Halos at region borders; checks for halo gaps |
| Read path | PostGIS GiST and ST_DWithin | Complete in-memory index fed by CDC; cards; AZ-local reads | Regional indexes, warm buddy copies, routing by the searched point |
| Ranking | Distance | Bayesian quality in the key; exact inside, approximate at the rim | Retrieve then re-rank with a profile carried in the request |
| Freshness | 30 s cache TTL | A budget that adds every delay: ≤ 17 s | Live signals with their own freshness and a stale cut-off |
| Privacy and data | Owner-only edits | Rate limits against scraping | Coarse logs, anonymous presence, residency, conflation with licenses |
| Evolving under new scope | Builds from a box query step by step | Opens with "what breaks", takes the DB off the read path first | Changes the shape (regions, streams, models) without giving up recall |