Design a Nearby Friends Location 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 campus app: which friends are within 5 km? | A social app's "friends nearby", live on a map | Worldwide, private by design, inside a battery budget |
| Level (Amazon) | SDE II (L5) | Senior SDE (L6) | Principal (L7) |
| Traffic | 100K users sharing at peak; 3,333 updates/s peak | 100M DAU, 10M connected on average, 15M at peak; 333K updates/s average, 500K peak | 50M connected across 4 regions' peaks; 783K updates/s at those peaks |
| Data | Latest location per user, about 15 MB | Live state ≈ 1.8 GB; friend lists ≈ 64 GB raw (32 GB as we store them) | Live state per home region; opt-in history ≈ 3.7 TB |
| Footprint | 1 region | 1 region, 3 AZs | 4 regions; users travel between them |
| Targets | Friends' positions under 1 min old; 99.9% | Delivery P95 < 1 s; 99.99% | Same per region; privacy by default; a battery budget |
| 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 Nearby-Friends Feature?
A Room Where You Only See the Friends Who Walked In
Imagine a huge room with the whole world in it. You can't see strangers at all. But whenever one of your friends is within a few steps of you, they light up, and you can see where they stand and watch them move. They see you the same way. Nobody else sees anything.
That is a nearby-friends feature: Snap Map, Find My, and the old Facebook "Nearby Friends" all work like this. Each phone reports where it is. The service tells each person which of their friends are close right now, and keeps everyone else's location away from them.
A few words we'll use all page:
| Word | What it means on this page |
|---|---|
| Location update | One report from a phone: latitude, longitude, accuracy and the time it was measured. |
| Sharer | The person whose location is being sent. |
| Viewer | The friend who sees it. Every user is both. |
| Online | The phone is currently connected and sending updates. |
| Nearby | Within 5 km (our product's radius). |
| Fan-out | Sending one update to many recipients. |
What Makes It Hard
- Everything moves. In the proximity loop, the places were static and only the searcher moved, so we could build an index once and read it forever. Here every "place" is a person, and every person moves all the time. There is nothing to precompute.
- Every update could go to hundreds of people. A user with 400 friends who sends an update every 30 seconds could produce 400 deliveries each time. Most of them are wasted: the friend is offline, or on another continent.
- Location is the most sensitive data a phone has. A trail of positions reveals where someone sleeps, works and worships. One leaked update to the wrong person can put someone in danger.
- Batteries. GPS and the phone's radio are among its most power-hungry parts. A feature that drains the battery gets turned off.
The Question the Whole Loop Answers
How do we tell each person which friends are near, right now, without sending every update to everyone or leaking where anyone is?
The answer grows every round:
- Round 1: keep only each person's latest location, in memory, and compute distances to their friends when they open the app.
- Round 2: push live updates over persistent connections, and prune the fan-out down to friends who are online and nearby. Enforce privacy modes on the server.
- Round 3: serve users from their home region wherever they travel, minimize what we keep, design against stalking, and spend the phone's battery only when someone is looking.
Round 1 · Mid-level · "Friends Nearby for a Campus App"
~35 min · SDE II (L5) · 1 region · 100K users sharing at peak · 3,333 updates/s peak · positions under 1 min old · 99.9%
R1.1 Establish Design Scope
The interviewer says: "A university app wants a 'friends nearby' tab. Students share their location with friends and see which friends are within 5 km. Design the backend." Before drawing anything, we ask.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| What radius? | 5 km. | We compute distances only against friends, and filter at 5 km. |
| How fresh must a friend's position be? | Within a minute is fine. | We can poll. Phones don't need to send more often than every 30 s, and the list doesn't need live push. |
| Do we need live updates on a map? | No. The list refreshes while the tab is open. | Plain HTTPS requests; no persistent connections this round. |
| Is there privacy control? | On or off. When it's off, nobody sees you. | A sharing switch that must take effect at once, including for updates already in flight (step 1.3). |
| How long do we keep locations? | Only the latest one. No history. | We store one record per user and overwrite it. Nothing to archive, nothing to leak later. |
| How many friends does a student have? | About 200. | Each "open" computes at most 200 distances. |
| How many users? | 200K students; up to 100K share at the same time, between classes. | Small enough for one cache node and a small API fleet. |
Out of scope for this round: live map updates, fuzzy location, "a friend is within 1 km" alerts, and location history.
R1.2 Functional Requirements, Derived Step by Step
| Phrase from the problem | Operation |
|---|---|
| "Students share their location" | updateLocation(lat, lon, accuracy, measured_at) stores my latest position |
| "See which friends are within 5 km" | nearbyFriends() returns friends within 5 km, nearest first, each with its distance and how old the position is |
| "On or off" | setSharing(enabled) turns sharing on or off, effective immediately |
Not yet: live push, fuzzy mode, proximity alerts.
R1.3 Non-Functional Requirements: the Questions
We name each quality first; the numbers come in R1.7.
- Freshness. A friend's position on screen should be under a minute old. That bounds how often phones send and how often the list refreshes, together.
- Privacy. Only friends see you, only while sharing is on, and turning it off must take effect at once. We keep nothing we don't need.
- Battery. A phone that sends a GPS fix every 5 seconds all day is dead by dinner. The send rate is a product decision, not just a server one.
- Cost of frequent writes. Every sharing phone writes every 30 seconds, and we only ever read the latest value. Storing every write durably would be paying for data we throw away.
R1.4 The API
Send my location
httpPUT /v1/me/location HTTP/1.1 Host: api.campusfriends.example Authorization: Bearer <access token> Content-Type: application/json { "lat": 37.7793, "lon": -122.4193, "accuracy_m": 12, "measured_at": "2026-10-05T14:02:31Z" }
httpHTTP/1.1 200 OK Content-Type: application/json { "stored": true, "next_update_s": 30 }
measured_atis when the phone measured the position, not when the request arrived. A request that was delayed in a tunnel and arrives after a newer one must not overwrite it.next_update_sis the server's hint for when to send again (step 1.4). The server can slow every phone down without shipping a new app.
List nearby friends
httpGET /v1/me/nearby-friends HTTP/1.1 Authorization: Bearer <access token>
json{ "friends": [ { "user_id": 4411, "name": "Bob", "distance_m": 1418, "age_s": 12 }, { "user_id": 5120, "name": "Carol", "distance_m": 2193, "age_s": 27 } ], "refresh_after_s": 20 }
Turn sharing on or off
httpPUT /v1/me/sharing HTTP/1.1 Authorization: Bearer <access token> Content-Type: application/json { "enabled": false }
Status codes
| Code | Meaning |
|---|---|
200 OK | Done. For PUT /v1/me/location while sharing is off: { "stored": false }, so the app can stop sending |
400 Bad Request | Latitude outside −90..90, longitude outside −180..180, or measured_at in the future |
401 Unauthorized | Missing or expired token |
429 Too Many Requests | A phone is sending more often than every 10 s |
503 Service Unavailable | The cache is recovering (R1.9); retry after the given delay |
Recap
- One latest position per user; a list of friends within 5 km, computed on request.
- 200 friends each, 100K sharing at peak.
- Sharing off must be immediate; positions under a minute old.
R1.5 Design Evolution: From a Ping Log to Live State
Each step is a problem, your turn to think, the answer, and what it costs us.
Step 1.0: The Baseline
A pings table in a relational database. Every update inserts a row. To list nearby friends, we look up the friend list, fetch each friend's newest row, and compute distances.
sqlCREATE TABLE pings ( user_id BIGINT NOT NULL, measured_at TIMESTAMPTZ NOT NULL, lat DOUBLE PRECISION NOT NULL, lon DOUBLE PRECISION NOT NULL, accuracy_m REAL, PRIMARY KEY (user_id, measured_at) ); -- the newest row per friend, for one "open" SELECT DISTINCT ON (user_id) user_id, lat, lon, measured_at FROM pings WHERE user_id = ANY(:friend_ids) ORDER BY user_id, measured_at DESC;
It works on day one, and it's where every candidate should start. The trouble is what it keeps.
Step 1.1: The Database Fills With Pings We Never Read
The problem: at 3,333 updates a second between classes, the table grows by millions of rows an hour. Every read wants only the newest row per friend. The rest is dead weight, and it's a location history of every student that nobody asked us to keep. What would you do?
Primitive: Distributed Cache Patterns and Eviction
Step 1.2: On Open, We Load 200 Friends' Locations
The problem: Bob opens the tab. We need his friends within 5 km, nearest first. What would you do?
Step 1.3: Friends Who Stopped Sharing Still Appear
The problem: Carol turns sharing off at 14:02:31.000. Her phone had sent an update at 14:02:30.900 that was still in flight. It lands at 14:02:31.200 and rewrites her location. Bob still sees her. What would you do?
Step 1.4: Phones Drain Their Batteries Sending Every 5 Seconds
The problem: the first app version sends a GPS fix every 5 seconds, always. Students complain the app kills their battery, and half of them turn sharing off. What would you do?
Round 1 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.0 | (baseline) | pings table, newest row per friend | Endless history; heavy writes |
| 1.1 | Pings we never read | Latest location per user in Valkey, 15-minute TTL | Locations lost on a cache failure (refilled in 30 s) |
| 1.2 | 200 friends per open | Cached friend set (versioned), one pipeline, Haversine | 200 reads per open |
| 1.3 | Non-sharers still appear | Sharing flag checked by the write script and the read; SET NX refills | A flag that must never be wrong |
| 1.4 | Battery drain | Adaptive interval, server hint, jitter | Stationary users up to 5 min old |
R1.6 Architecture v1
Synthesizing vector architecture diagram...
Every request goes to a small stateless API. Positions, flags and cached friend lists live in Valkey; the durable facts (who is friends with whom, and the sharing switch) live in DynamoDB.
Key layout
| Key | Type | Contents | Expiry | Written by |
|---|---|---|---|---|
loc:{u} | Hash | lat, lon, acc, ts | 900 s | location script |
share:{u} | String | on / off (copy of DynamoDB) | none | sharing API; refill with SET NX |
friends:{u} | Set of 64-bit IDs | the user's friends | 3,600 s | refill, guarded by fver |
fver:{u} | Integer | friend-list version | none | friendship changes |
The cluster uses the volatile-lru eviction policy: if memory ever fills, only keys with a TTL (locations and cached friend lists) can be evicted, never the sharing flags or versions. We alarm long before that, at 70% memory.
Tracing an update: Alice walks to the library
Synthesizing vector architecture diagram...
One round trip to the cache. The script is the only writer of loc:{u}, so the sharing check and the write can't be separated by a toggle.
Tracing an open: Bob looks at the tab
Synthesizing vector architecture diagram...
On a friend-list cache miss, the API queries DynamoDB (pk = USER#4411, about 200 items) and refills the set only if fver is unchanged.
Alice at (37.7793, −122.4193) and Bob at (37.7880, −122.4075): Δφ = 0.0087°, Δλ = 0.0118°, and Haversine gives 1,418 m. Carol at (37.7599, −122.4148) is 3,190 m from Bob. (The API example in R1.4 is Alice's own view: Bob 1,418 m away and Carol 2,193 m.)
R1.7 Numbers
Targets
| Quality | Target | Why this number |
|---|---|---|
| Freshness | Position on screen ≤ 1 min old | From the scope; the chain in step 1.4 gives ≤ 55 s with jitter |
| Latency | nearby-friends P99 < 300 ms at the load balancer | A list that pops up while you glance at it |
| Availability | 99.9% | 0.1% of a 30.4-day month: 43,776 min × 0.001 ≈ 44 minutes |
| Privacy | Sharing off takes effect before the next request is served | From step 1.3 |
Traffic
| Item | Math | Result |
|---|---|---|
| Users sharing at peak | given | 100,000 |
| Updates at peak (everyone walking between classes, 30 s) | 100,000 ÷ 30 s | 3,333/s |
| Updates in a normal peak hour | we assume 40% moving, 60% stationary: 100,000 × (0.4 ÷ 30 + 0.6 ÷ 300) | 1,533/s |
| Monthly average sharing users | an assumption: nights, weekends and holidays are quiet | 25,000 |
| Monthly average updates | 25,000 × (0.4 ÷ 30 + 0.6 ÷ 300) | 383/s |
| Tabs open at peak | we assume 10% of sharing users | 10,000 |
| Opens at peak | 10,000 ÷ 20 s refresh | 500/s |
| Opens on average | 25,000 × 10% ÷ 20 s | 125/s |
Memory
| Item | Math | Result |
|---|---|---|
One loc hash | 4 small fields, key and TTL overhead in Valkey's compact encoding: about 150 B (check with MEMORY USAGE) | 150 B |
| All positions | at most 200,000 students × 150 B | ≈ 30 MB (15 MB at the 100K peak) |
| Friend sets | 200,000 × 200 IDs × 8 B (Valkey stores small integer sets as a packed array) | ≈ 320 MB |
| Flags and versions | 200,000 × about 100 B | ≈ 20 MB |
Reads per open. 1 set read + 400 pipelined commands. At 500 opens a second that's about 200,000 commands a second, in 500 pipelines. We read the primary, not the replica, so a sharing change is seen at once; a node of this size handles a few hundred thousand pipelined reads a second (an assumption to load-test).
API fleet. We plan 1,000 requests a second per vCPU (an assumption). Peak is 3,333 + 500 ≈ 3,833 requests a second. We run 6 tasks of 1 vCPU, 2 per AZ; if an AZ is lost, 4 tasks carry 958 a second each.
Latency budget, nearby-friends P99 (dependent steps add):
| Step | P99 |
|---|---|
| ALB | 3 ms |
| Friend set read | 1 ms |
| 400-command pipeline | 3 ms |
| 200 Haversines, sort, serialize | 1 ms |
| Total | 8 ms (a DynamoDB refill adds about 10 ms on a miss) |
Monthly cost (us-east-1 on-demand list prices, 730 hours a month):
| Item | Math | Monthly |
|---|---|---|
| Valkey | 2 × cache.r7g.large (13.07 GiB) × $0.1752/h × 730 h. Far more memory than we need; it's the smallest memory-optimized node, and burstable nodes risk CPU credit exhaustion at the bell | ≈ $256 |
| Fargate (ARM) | 6 tasks × (1 vCPU × $0.03238 + 2 GB × $0.00356)/h × 730 h | ≈ $173 |
| Application Load Balancer | $0.0225/h × 730 ≈ $16; capacity units: about 200 new connections a second on average (phones in the background reconnect) ÷ 25 per unit = 8 units × $0.008/h × 730 ≈ $47 | ≈ $63 |
| DynamoDB | 40M friendship items × 100 B = 4 GB storage ≈ $1; refill reads and rare writes | ≈ $10 |
| NAT gateway | $0.045/h × 730 h, plus a little data | ≈ $35 |
| Data transfer out | 125 opens/s × 2 KB × 2,626,560 s ≈ 657 GB × $0.09/GB | ≈ $59 |
| CloudWatch, logs | an estimate | ≈ $20 |
| Total | ≈ $616/month |
R1.8 Trade-Offs
Polling on open vs live push
| Polling on open (chosen) | Live push | |
|---|---|---|
| What the phone does | Refreshes the list every 20 s while the tab is open | Holds a connection; the server sends each friend's move |
| Server state | None per client | A connection per phone, and a way to route updates to it |
| Freshness | ≤ 55 s | About a second |
| Work when nobody's looking | None | Connections still held (or torn down and rebuilt) |
The product asked for "within a minute", so polling wins: stateless servers, no connection fleet. Round 2 asks for watching friends move, and that flips the answer.
In-memory TTL vs a database
| Valkey hash with TTL (chosen) | DynamoDB item with TTL | |
|---|---|---|
| Expiry | Deleted on time (a read after expiry never returns the key) | Deleted "typically within a few days"; reads must check ts |
| Cost at our average rate | ≈ $256 for two nodes | ≈ $630 on-demand for writes alone |
| Durability | Lost on node failure; refilled in 30 s | Durable, which we don't need |
| Batch read of 200 | One pipeline | BatchGetItem, up to 100 items per call |
R1.9 Failure Modes
| Trigger | What you'd see | How the design responds |
|---|---|---|
| The Valkey primary fails | A few seconds of errors; ElastiCache promotes the replica | Replication is asynchronous, so the last few writes can be lost. Positions refill within one interval. Flags are riskier: a lost off would expose someone. So after any failover the API returns 503 for nearby-friends while a job reloads every share flag from DynamoDB (200K small items, a few seconds), and only then serves again. |
| Both cache nodes lost | Empty cache | Same reload, then friend lists refill on demand; positions return within 30 s as phones send. |
| DynamoDB is slow or throttled | Friend-list refills time out | Most opens hit the cached set (1-hour TTL). A miss that can't be refilled returns 503 for that open, with a retry hint; we never guess a friend list, because a wrong one shows someone to a stranger. |
| A phone sends every second | One user's writes spike | 429 above one update per 10 s per user, counted in Valkey. |
| Everyone leaves class at once | 3,333 updates a second for a few minutes | Planned for: that's our peak. If it grows, next_update_s stretches the interval for everyone. |
R1.10 Pillar Check
| Pillar | What Round 1 covers |
|---|---|
| Reliability | API tasks in three AZs; cache primary and replica in different AZs; flags rebuilt from DynamoDB before serving after a failover REL 10 · REL 11 |
| Performance Efficiency | Friend list first, one pipelined batch read, P99 about 8 ms PERF 3 |
| Security | Only mutual friends see you; the sharing flag guards both writes and reads; no history kept SEC 7 |
| Cost Optimization | About $616 a month; disposable data in memory instead of durable writes COST 5 |
| Operational Excellence | Skipped this round: one service; basic latency, error and memory alarms. |
| Sustainability | Skipped this round: adaptive intervals already cut phone and server work; a handful of small ARM tasks. |
R1.11 Round 1 Rubric and Follow-Ups
What a strong mid-level (L5) answer shows
- Keeps only the latest position, in memory, with a TTL, and says why history is a liability.
- Starts from the friend list (the small set), not from a geo search over strangers.
- Uses Haversine for distance.
- Makes the sharing switch airtight: a guarded write, a checked read,
SET NXrefills and a rebuild after failover. - Adds up every delay to prove the freshness target, and ties the send interval to movement and battery.
Follow-up questions
-
"Why not just store locations in DynamoDB with a TTL?" Answer: it works, but its TTL deletes only "typically within a few days", so staleness still has to be checked on every read, and at our rate on-demand writes cost more than the cache. The data is disposable; a store that forgets on time fits it.
-
"A student unfriends someone. How long until the ex-friend stops seeing them?" Answer: the next open. The unfriend deletes both cached friend sets and bumps each user's
fver, so no in-flight refill can write the old list back. The DynamoDB transaction removes both directions at once. -
"What if two updates from the same phone arrive out of order?" Answer: the write script compares
measured_atwith the storedtsand keeps the newer one, so a delayed old update is rejected.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Keep every ping just in case" | Millions of rows an hour nobody reads, and a location history nobody agreed to. |
| "Geo-search everyone, then filter to friends" | Reads 100K strangers to find 30 friends, and moves strangers' locations through the request. |
| "Delete the location when sharing turns off" | An in-flight update recreates it; guard the write. |
| "DynamoDB TTL expires stale positions" | Deletes happen within days, not on time. |
| "A 30 s send interval gives 30 s freshness" | Add the fix, the request and the screen's refresh period too. |
Round 2 · Senior · "10M People Moving at Once"
~40 min · Senior SDE (L6) · 1 region, 3 AZs · 100M DAU · 10M connected on average, 15M at peak · 333K updates/s average, 500K peak · delivery P95 < 1 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 'friends nearby' for a campus: 100K students sharing at peak, 200 friends each, a 5 km radius, positions under a minute old. We keep only each user's latest position, in a Valkey hash with a 15-minute TTL, because history is a liability and the data is disposable. Bob's list starts from his friend list, not a geo search over strangers: one cached set of friend IDs, one pipelined batch read, Haversine, sort. A sharing flag guards both the write script and the read, and refills use
SET NX, so turning sharing off is airtight even with updates in flight; after a cache failover we rebuild the flags from DynamoDB before serving. Phones send every 30 seconds while moving and every 5 minutes while still, with jitter and a server hint, and the list refreshes every 20 seconds, which keeps positions at most 55 seconds old. About $616 a month. Open costs: nothing is live, the list is polled, and privacy is all or nothing."
Architecture v1, compact
Synthesizing vector architecture diagram...
Round 1 in one picture: a stateless API over a small in-memory store; phones poll.
Round 1 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | Pings we never read | Latest position in Valkey, 15-min TTL | Lost on cache failure, refilled in 30 s |
| 1.2 | 200 friends per open | Versioned friend-set cache, one pipeline, Haversine | 200 reads per open |
| 1.3 | Non-sharers appear | Sharing flag in the write script and the read | A flag that must never be wrong |
| 1.4 | Battery | Adaptive interval, server hint, jitter | Stationary users up to 5 min old |
Open costs: polling, no live map, all-or-nothing privacy, one small cache.
R2.1 The Scope Raise
Interviewer: "A social app with 100 million daily users wants this. People want to open a map and watch their friends move, live. They want an alert when a friend comes within a kilometer. Some want to share a rough location with some friends and nothing with others. And the whole thing must survive losing a data center."
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How many people are sharing at once? | About 10% of daily users: 10M connected on a normal evening, 15M on the busiest weekend evenings. | 10M to 15M open connections (step 2.1). |
| How often do phones send? | Every 30 seconds while the map is live. | 333K updates a second on average, 500K at peak. |
| How live is "live"? | A friend's move should reach my screen within a second, most of the time. | Push over persistent connections; P95 < 1 s from the sharer's send to the viewer's screen (R2.6). |
| How many friends? | 400 on average, with a long tail into the thousands. | The fan-out problem (step 2.2). |
| What privacy options? | Per friend: precise, approximate ("fuzzy") or nothing ("ghost"). | Enforced on the server before anything leaves it (step 2.5). |
| Alerts? | "Alice is within 1 km", for friends you pick. | An alert engine with hysteresis (step 2.6). |
| Failure? | Losing an AZ must not take the feature down. | A reconnect storm to design for (step 2.7). |
Scope change
| Round 1 | Round 2 | |
|---|---|---|
| Users | 100K sharing at peak | 10M connected on average, 15M at peak |
| Delivery | Polled every 20 s | Pushed; P95 < 1 s |
| Friends | 200 | 400 on average |
| Privacy | On or off | Per friend: precise, fuzzy, ghost |
| Alerts | None | Friend within 1 km |
| Failure | Survive a node | Survive an AZ; 99.99% (4.4 min a month) |
The "Not yet" list from R1.2 comes back: live push, fuzzy mode and proximity alerts are all in scope now.
R2.2 What Breaks in the Round 1 Design
| Round 1 choice | How it fails at the new scope |
|---|---|
| Polling every 20 s | Not live. Polling every second instead would be 10M requests a second, almost all returning "nothing changed". |
| Computing the list per open | Watching a map means continuous updates, not one list; we need to send changes. |
| Naive push to every friend | 333,333 updates/s × 400 friends ≈ 133M messages a second, most to friends who are offline or far away (step 2.2). |
| One global sharing flag | Per-friend modes need a privacy decision for every (sharer, viewer) pair, and it can't be a check on every one of 133M messages. |
| Phones reconnect whenever they like | After an AZ fails, 3.3M phones (a third of 10M) reconnect within seconds and flatten whatever they hit (step 2.7). |
| One cache for everything | Friend lists alone are 64 GB of raw IDs; live state, subscriptions and settings need a real cluster. |
| Nothing special about crowds | A festival puts 80,000 users, many of them friends, in one park (R2.8). |
The order we fix it in: connections first (2.1), then the fan-out math (2.2) and how updates are routed (2.3), then the cells we still need (2.4), privacy (2.5), alerts (2.6) and the reconnect storm (2.7). Sizing comes in R2.6.
R2.3 New Requirements and API Additions
The phone now holds one WebSocket (a long-lived, two-way connection that starts as an HTTPS request and then carries small messages called frames in both directions).
Connect
httpGET /v1/live HTTP/1.1 Host: live.nearby.example Upgrade: websocket Connection: Upgrade Sec-WebSocket-Version: 13 Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Authorization: Bearer <access token>
Up: my location (every 30 s while the map is live)
json{ "t": "loc", "lat": 37.7801, "lon": -122.4180, "acc_m": 9, "measured_at_ms": 1791216151000, "seq": 4821 }
Down: a friend moved, left, or hid
json{ "t": "friend_loc", "user_id": 1007, "lat": 37.7801, "lon": -122.4180, "distance_m": 1274, "mode": "precise", "measured_at_ms": 1791216151000 }
json{ "t": "friend_fuzzy", "user_id": 2230, "area": { "lat": 37.7735, "lon": -122.4183, "radius_m": 3700 }, "distance_band": "about 3 km" }
json{ "t": "friend_gone", "user_id": 1007, "reason": "out_of_range" }
json{ "t": "hint", "next_update_s": 60, "reason": "crowded_area" }
The JSON above is for readability. On the wire we use a compact binary encoding of the same fields: about 100 bytes up and 120 bytes down (the sizes R2.6 uses). JSON as shown is roughly twice that; the design doesn't change, only the bandwidth line.
Privacy per friend
httpPUT /v1/me/privacy HTTP/1.1 Authorization: Bearer <access token> Content-Type: application/json If-Match: "12" { "default_mode": "fuzzy", "exceptions": [ { "friend_id": 4411, "mode": "precise" }, { "friend_id": 7730, "mode": "ghost" } ] }
- Modes: precise (exact position), fuzzy (an area of a few kilometers, step 2.5), ghost (nothing at all).
- The response carries the new privacy version (
"13").If-Matchstops two devices from overwriting each other's settings. - A global "go ghost now" switch is
default_mode: "ghost"with no exceptions.
Proximity alerts
httpPUT /v1/me/alerts HTTP/1.1 Content-Type: application/json { "notify_me_about": [1007, 5120], "radius_m": 1000 }
An alert about Alice needs Alice's permission too: she must be sharing precisely with you. Each user can watch at most 20 friends (a product limit that also bounds the engine's work).
R2.4 Design Evolution: From Polling to a Pruned Live Fan-Out
Step 2.1: Live Updates for 10M Users
The problem: 10M phones (15M at peak) want every nearby friend's move within a second. Polling every second would be 10M requests a second, nearly all empty. What would you do?
Primitive: WebSocket, SSE and Long Polling
Step 2.2: The Fan-Out Would Be 133M Messages a Second
The problem: every update could go to all 400 friends: 333,333 × 400 ≈ 133M messages a second on average, 200M at peak. What would you do?
Step 2.3: How Does a Gateway Learn Which Updates to Deliver?
The problem: Alice's update arrives at gateway 17. Bob and Carol are connected to gateways 42 and 311. Gateway 17 doesn't know that. We need a routing layer between gateways. What would you do? There are two classic designs. Compare them honestly and pick one.
Primitive: Geospatial Indexing: Geohash, Quadtree and S2
Synthesizing vector architecture diagram...
One publish, one delivery per interested gateway, each from a node in that gateway's own AZ. The viewer's gateway makes the final distance decision: Dan is on the near channel (within 15 km) but outside 5 km, so his phone gets nothing.
Step 2.4: Hexagons or Squares?
The problem: we've chosen per-friend channels, but cells still matter: to size the per-cell alternative honestly, to snap fuzzy locations to an area (step 2.5), and to count crowds (R2.8). Which grid? What would you do?
Step 2.5: Fuzzy Mode and Ghost Mode
The problem: Alice shares precisely with Bob, approximately with her coworkers, and nothing with her ex. The exact position must never reach a phone (or, ideally, a server process) that only deserves the approximate one. And "go ghost" must work instantly, even with updates already in flight. What would you do?
Step 2.6: A Friend Entered My 1 km Zone
The problem: Bob asked to be alerted when Alice comes within 1 km. Recomputing every watched pair every second would be wasted work, and GPS jitter at the 1 km line would fire an alert every few minutes. Bob's phone may also be in his pocket, not connected. What would you do?
Step 2.7: An AZ Failed and 3.3M Phones Reconnected
The problem: one AZ goes dark. A third of the gateways vanish, and 3.3M phones (5M at peak) lose their sockets at the same instant. Each app retries right away. What would you do? Why is an instant retry so destructive?
Drill: The Gateway Restart That DDOSed the Chat Fleet (why an instant reconnect crushes authentication and pub/sub is answered at the top of this step; why WebSocket and not SSE over HTTP/2 is answered in step 2.1)
Round 2 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | Live updates for 10M | WebSockets to Fargate gateways behind an NLB (TCP), heartbeats | A stateful fleet |
| 2.2 | 133M messages a second | Online-only (subscriptions) and nearby-only (viewer's gateway) filters: 667K/s to phones | Two labeled assumptions |
| 2.3 | Routing between gateways | Per-friend channels on sharded pub/sub, near/far/fuzzy tiers, presence-driven subscriptions | Subscription churn at connect |
| 2.4 | Which cells | H3; k-ring coverage checked; resolution 6 for fuzzy, 7 for crowds | Cells vary 2× in size |
| 2.5 | Fuzzy and ghost | Mode channels, deterministic fuzzy cell with hysteresis, privacy version | A control round per change |
| 2.6 | 1 km alerts | Watched pairs in the sharer's pipeline, hysteresis, exactly-once script, SNS push | Pair state |
| 2.7 | Reconnect storm | Full jitter, admission before TLS, session resumption, planned drains | A few minutes' gap after an AZ loss |
R2.5 Architecture v2
Synthesizing vector architecture diagram...
Phones hold a socket to a gateway. Each gateway runs the location script on the state cluster and publishes on the pub/sub cluster; deliveries come back from the pub/sub replica in the gateway's own AZ. Settings changes flow from DynamoDB into the cache and out as control messages, twice if needed. The social app's existing friends service owns the friend graph; we cache it.
Key layout on the state cluster
| Key | Type | Contents | Expiry |
|---|---|---|---|
live:{u} | Hash | lat, lon, acc, ts, gw, far_lat, far_lon, fz_cell, fz_since | 600 s |
set:{u} | Hash | default_mode, ex:<friend> per exception, pv | 1 day, refilled if missing |
friends:{u} | Set of 64-bit IDs | friend list, from the friends service | 1 hour, versioned by fver:{u} |
watchers:{u} | Set | viewers with alerts on this user | none |
ap:{a|b} | Hash | inside, last_alert_ts | 1 day after last change |
gwalive:{gw} | String | gateway heartbeat | 15 s |
Channels on the pub/sub cluster: n:{u}, f:{u}, z:{u} per user; g:{gw} per gateway for control messages and alerts.
Tracing a moving user: Alice walks 145 m, and two nearby friends see it
Alice moves from (37.7793, −122.4193) to (37.7801, −122.4180). Her friends online: Bob and Carol (near channel, precise), Dan in Oakland (near channel: 13.2 km is within 15 km), Frank in San Jose (far channel: 67.9 km), Erin (fuzzy).
Synthesizing vector architecture diagram...
Alice moved only 145 m, so her far anchor and her fuzzy cell (86283082fffffff) are unchanged: Frank and Erin get nothing. One publish, two gateway deliveries, two phone frames.
Tracing a ghost switch with an update in flight
- 20:00:00.000: gateway 17 runs Alice's location script; it returns
pv 12. - 20:00:00.001: Alice taps "ghost". The settings API writes DynamoDB (
pv12 → 13, condition "pvis still 12"), thenset:{u1007}, then publisheshide, pv 13on her three channels at 20:00:00.004. - 20:00:00.005: gateway 17's publish of the update with
pv 12lands, after thehide. - Gateway 42 has already recorded
pv 13for Alice from thehide, so it drops the late update and removes Alice's pin from Bob's map. Her next update hits the script, which seesghostand publishes nothing.
Tracing a reconnect
- Gateway 42's task is replaced; Bob's socket closes.
- Bob's app waits a random 1.7 s (attempt 1: uniform between 0 and 2 s), resolves the NLB, and reaches gateway 205, which is under its 80-a-second admission budget.
- TLS resumes from the session ticket; the token is verified locally.
- Gateway 205 reads
friends:{u4411}(hit), then one pipeline of presence and settings for 400 friends: 41 online. - It subscribes to 41 channels (35 were already subscribed for other users on this gateway, so 6 new
SSUBSCRIBEs), and publishes "4411 online" to the control channels of the 37 gateways holding his online friends. - It sends Bob a snapshot: Alice at 1,274 m and one more friend within 5 km. His own next update refreshes
live:{u4411}within 30 s.
R2.6 Numbers and Cost
Traffic
| Item | Math | Result |
|---|---|---|
| Connected on average | 100M DAU × 10% | 10M |
| Connected at peak | busiest weekend evenings, 1.5× | 15M |
| Updates, average | 10M ÷ 30 s | 333,333/s |
| Updates, peak | 15M ÷ 30 s | 500,000/s |
| Frames to phones | × 2 nearby friends | 667K/s average, 1.0M/s peak |
| Internal deliveries | 0.219 per user per second (step 2.3) | 2.19M/s average, 3.28M/s peak |
| Publishes | about 1.05 per update (near, plus the occasional far or fuzzy) | 350K/s average, 525K/s peak |
| Connects | we assume a 30-minute average session: 10M ÷ 1,800 s | 5,600/s average, 8,300/s peak |
Bandwidth
| Direction | Payload math | Payload | On the wire |
|---|---|---|---|
| In (peak) | 500,000/s × 100 B = 50 MB/s | 400 Mbps | about 200 B each with WebSocket, TLS and TCP/IP headers and acks: about 800 Mbps |
| Out (peak) | 1,000,000/s × 120 B = 120 MB/s | 960 Mbps | about 220 B each: about 1.76 Gbps |
| Out (average) | 667K/s × 220 B + pings (at most 10M ÷ 60 s × 60 B ≈ 10 MB/s) | ≈ 157 MB/s: 412 TB a month (× 2,626,560 s) |
Memory, honestly
| Item | Math | Result |
|---|---|---|
| Live position (key 32 B, lat, lon, cell, ts: about 64 B of data, about 180 B with hash overhead) | 10M × 180 B | ≈ 1.8 GB |
Live state with our extra fields (gw, far anchor, fuzzy cell) | 10M × about 260 B; at peak 15M | 2.6 GB; 3.9 GB at peak |
| Friend lists, as often quoted | the often-quoted 64 GB assumes 16-byte IDs: 10M × 400 × 16 B | ≈ 64 GB |
| Friend lists as we store them | ours are 64-bit IDs, in Valkey's packed integer-set encoding (we raise set-max-intset-entries from 512 to 5,000 in the parameter group): 10M × 400 × 8 B; at peak 15M | 32 GB; 48 GB at peak. As 16-byte strings in a regular set, the per-member overhead would make it several times 64 GB. |
| Settings cache | 15M × about 100 B | 1.5 GB |
| State cluster at peak | 3.9 + 48 + 1.5 | ≈ 53 GB |
| Subscriptions (pub/sub cluster) | 15M users × about 38 distinct gateways per user's channels at 450 gateways, ≈ 575M pairs × about 150 B (an assumption to measure) | ≈ 86 GB |
Gateways. 50K connections per task (assumption) × 200 tasks = 10M: the original figure. It leaves out two things. The peak is 15M, so 300 tasks; and if one AZ is lost, the other two must hold everyone, so each AZ needs half the peak: 7.5M ÷ 50K = 150 tasks per AZ, 450 at peak. On an average evening, the same rule gives 10M ÷ 2 ÷ 50K = 100 per AZ, 300 tasks, which is what we pay for on average (autoscaled on connection count).
State cluster. Planning figures (assumptions to load-test on cache.r7g.xlarge, 4 vCPU, 26.32 GiB): 100,000 script calls a second per primary, and about 1M pipelined key reads a second per node.
- Scripts: 500,000 a second at peak. With 6 shards, 83,000 per primary, under 100,000. An AZ loss promotes replicas but keeps 6 primaries.
- Reads at connect: 8,300 connects/s × 400 ≈ 3.3M a second, over 18 nodes ≈ 185,000 each. During a storm, admission allows 24,000 connects/s: 9.6M reads/s over the 12 surviving nodes ≈ 800,000 each, under the ceiling.
- Memory: ElastiCache reserves 25% by default, and we fill the rest to 80%: 28.3 GB × 0.75 × 0.8 ≈ 17 GB per shard. 53 GB ÷ 6 ≈ 8.9 GB per shard. 6 shards × 3 nodes (one per AZ) = 18 nodes.
Pub/sub cluster. Planning figure: 300,000 operations a second per cache.m7g.xlarge node (4 vCPU, 12.93 GiB), counting each delivery, each publish received by a node, and each subscribe (an assumption).
| Load at peak | Math | Ops/s |
|---|---|---|
| Deliveries | from step 2.3 | 3.28M |
| Publishes received | 525K × 3 nodes per shard | 1.58M |
| Subscribes and unsubscribes | 8,300 connects/s × 76 × 2 | 1.26M |
| Total | 6.12M |
With 12 shards × 3 = 36 nodes, that's 170,000 per node. After losing an AZ, during the reconnect storm: 3.28M deliveries + 525K × 2 + 24,000 × 76 ≈ 1.82M subscribes + normal disconnect unsubscribes from surviving users (8,300 × 76 ≈ 0.63M) = 6.78M over 24 nodes ≈ 282,000 each, still under 300,000. Subscription memory: 86 GB ÷ 36 ≈ 2.4 GB per node.
Latency budget, delivery P95 (from Alice's send to Bob's screen; dependent steps add):
| Step | P95 |
|---|---|
| Phone uplink over cellular | 300 ms (rough) |
| NLB, gateway read and parse | 2 ms |
| Location script on the state cluster | 2 ms |
| Publish to the primary, replicate to the replica in Bob's gateway's AZ, deliver | 3 ms |
Bob's gateway: pv check, distance, encode | 1 ms |
| Gateway send queue under load | 20 ms |
| Downlink to Bob's phone | 300 ms (rough) |
| Total | ≈ 628 ms, under 1 s |
The two radio legs dominate and are outside our control; a phone whose radio has gone idle can take longer to wake. So we alarm on the server-side part (NLB in to socket write out, about 30 ms) and track the end-to-end number from app telemetry.
Availability. 99.99% of a 30.4-day month is 43,776 min × 0.0001 ≈ 4.4 minutes. Not 99.999% (5.26 minutes a year): one AZ loss alone costs the affected third of users a few minutes of reconnecting (step 2.7).
Monthly cost (us-east-1 on-demand list prices, 730 hours a month):
| Item | Math | Monthly |
|---|---|---|
| Gateways (Fargate ARM) | 300 average tasks × (4 × $0.03238 + 8 × $0.00356)/h × 730 h = 300 × $115.34 | ≈ $34.6K |
| Background HTTPS path | 5% of updates (an assumption) ≈ 16,700/s, mostly new TLS connections from backgrounded apps: ÷ 25 new connections per ALB capacity unit ≈ 667 units × $0.008 × 730 ≈ $3,900; $16 fixed; 27 tasks (9 per AZ) of 1 vCPU/2 GB × $28.84 ≈ $780 | ≈ $4.7K |
| NLB | processed bytes dominate: in 333K × 200 B + out 157 MB/s + pongs ≈ 233 MB/s ≈ 840 GB/h → 840 capacity units × $0.006 × 730 ≈ $3,680; plus $16 | ≈ $3.7K |
| Data transfer out | 412 TB: 10 TB × $0.09 + 40 × $0.085 + 100 × $0.07 + 262 × $0.05 | ≈ $24.4K |
| State cluster | 18 × cache.r7g.xlarge Valkey × $0.3496/h × 730 h | ≈ $4.6K |
| Pub/sub cluster | 36 × cache.m7g.xlarge Valkey × $0.252/h × 730 h | ≈ $6.6K |
| Cross-AZ traffic | publishes and scripts from gateways to primaries in other AZs: about 2/3 of ≈ 120 MB/s ≈ 210 TB × $0.02/GB ($0.01 out plus $0.01 in); deliveries stay in-AZ | ≈ $4.2K |
| DynamoDB | settings for 200M accounts × 1 KB = 200 GB ≈ $50; 870M writes ≈ $540; cache-miss reads ≈ $1.8K | ≈ $2.4K |
| SNS mobile push | about 5M alerts a day × 30.4 × ($0.50 + $0.50) per million | ≈ $0.15K |
| CloudWatch, logs, Route 53, VPC endpoints | an estimate | ≈ $3.3K |
| Total | ≈ $88.7K/month |
About $0.89 per thousand daily users a month. The biggest lines are the gateways (39%) and data transfer out (28%): the cost of holding 10M sockets and talking over them.
What it would cost to persist every ping. Writing each update to DynamoDB on-demand: 333,333 × 2,626,560 s ≈ 875B writes × $0.625 per million ≈ $547K a month; provisioned for the 500K peak, 500,000 WCU × $0.00065 × 730 ≈ $237K. The often-quoted "$100K+ a month" is true but low; either way it's several times this whole design, for data that's obsolete in 30 seconds.
R2.7 Trade-Offs
Per-friend vs per-cell channels (details in step 2.3)
| Per-friend (chosen) | Per-cell | |
|---|---|---|
| Routing | By friendship | By place |
| Deliveries per update | About 6 near + rare far (tiers) | Every gateway with a user in range: up to all 450 in a city |
| Privacy | Gateways see friends only, in their allowed mode | Gateways see strangers' exact positions |
| Churn | At connect and disconnect | Every 2.4 km of movement, 7 to 9 cells |
| Crowds | Spread over users' channels | One hot channel per crowded cell |
H3 vs geohash (for our cell uses)
| H3 (chosen) | Geohash | |
|---|---|---|
| Shape | Hexagons, 6 equal-distance neighbors | Rectangles; 8 neighbors at two distances |
| Size variation | About 2× within a resolution | East-west width shrinks with cos(latitude) |
| Rings | grid_disk(k): 7, 19, 37, 61 cells | 3×3 blocks, 5×5 blocks |
| String prefixes | No simple prefix property | Prefix = containing area (useful for key-value range scans) |
Push vs periodic polling (rough figures)
| Push over WebSockets (chosen) | Poll every 5 s | Poll every 30 s | |
|---|---|---|---|
| Requests to serve | 333K updates/s in, 667K frames/s out | 2M requests/s, most empty, plus 333K updates | 333K requests/s plus 333K updates |
| Freshness on screen | ≈ 0.6 s after the send | up to 5 s | up to 30 s |
| Server state | Sockets, subscriptions | None | None |
| Battery | One socket, woken by real changes | A request every 5 s even when nothing moves | Acceptable |
Our gateways vs API Gateway WebSocket APIs. From step 2.1: about $38K a month against about $2.2M, because API Gateway bills every message and our fan-out produces 667K frames a second. The managed option wins only at small scale, or where there's no team to run the fleet.
R2.8 Failure Modes
| Trigger | What you'd see | How the design responds |
|---|---|---|
| Boundary hopping | Friends flip between near and far channels, or a fuzzy area flips between two cells | Hysteresis everywhere: near at ≤ 15 km, far at > 25 km; the 5 km display edge enters at 5.0 km and leaves at 5.2 km; the fuzzy cell changes only after 10 minutes or 500 m inside. (A per-cell design would churn 7 to 9 subscriptions per user every 2.4 km.) |
| Ghost locations after a sudden disconnect | Alice drives into a garage; her pin freezes on Bob's map | Her gateway misses the pong within 120 s, clears gw, and publishes offline; Bob's app dims her pin as "last seen 2 min ago". If her gateway itself died, gwalive expires in 15 s and presence readers treat her as offline; live:{u} expires after 600 s. |
| Festival crowds | 80,000 users in one park; many friends near each other; cellular congestion | Each gateway counts its connected users per resolution-7 cell and every 10 s writes its count into crowd:{cell}:{window} as its own field (an overwrite, not an increment, so retries can't double-count). Above 5,000 users in a cell, gateways send hint frames (60 s interval) and batch each viewer's friend updates into one frame every 2 s. |
| GPS spoofing | A position that jumps across the world | The location script compares with the stored position: San Francisco to New York (4,129 km) in 30 s is about 495,000 km/h. Anything faster than about 1,200 km/h (above any airliner's ground speed) is rejected and flagged. Device-integrity checks at login (App Attest, Play Integrity) raise the bar; spoofing can't be fully prevented, so safety features never rely on a position being true. |
| Reconnect storm | New-connection spike after an AZ loss or a bad deploy | Step 2.7: jitter, admission before TLS, resumption, planned drains. |
| A pub/sub primary fails | Publishes to its slots fail for a few seconds | ElastiCache promotes a replica; gateways reconnect and re-subscribe. Pub/sub is fire-and-forget, so a few updates are lost; the next arrives within 30 s. |
| A state primary fails | A few seconds of script errors; asynchronous replication may lose the last writes | Positions refill within 30 s. Settings can't be trusted after a loss (a Lambda stream consumer can't restart at a chosen time, only at the oldest record or the newest): so after any state failover we flush every set:{u} key, and each refills from DynamoDB on its next read, with the refill guarded by pv. Gateways re-read settings for every sharer with a pv jump. |
R2.9 Production Gotchas
| Gotcha | Why it hurts | What we do |
|---|---|---|
| Fan-out to all friends | 133M messages a second, 95% of them pointless | Online-only by subscription, nearby-only at the viewer's gateway, near/far tiers. |
| Persisting ephemeral pings | $237K to $547K a month in DynamoDB writes, and a location history nobody asked for | Live state in memory with a TTL; history is a separate opt-in (Round 3). |
| Synchronized client timers | Apps that send at :00 and :30 turn 333K/s into spikes of millions | ±3 s jitter per interval; the server's hint spreads load further. |
| Leaked connections from sleeping phones | Sockets that never close fill file descriptors and memory | Ping after 60 s of silence, close after 60 s more; explicit open-file limit in the task definition. |
Classic PUBLISH in cluster mode | Every message goes to every node; adding shards adds no throughput | Sharded pub/sub (SPUBLISH/SSUBSCRIBE), subscribers on in-AZ replicas. |
| The NLB's 55,000-per-target limit | Port allocation errors at 50K connections per task | Client IP preservation on the target group. |
R2.10 Pillar Check
| Pillar | What Round 2 covers |
|---|---|
| Reliability | Gateways sized so two AZs hold the peak; replicas in every AZ; jittered backoff and admission before TLS; gateway liveness keys as a fallback for stale presence REL 5 · REL 10 · REL 11 |
| Performance Efficiency | Pruned fan-out (200× fewer frames); near/far tiers cut internal deliveries from 12.5M to 2.19M a second; in-AZ subscriptions; P95 about 628 ms PERF 1 · PERF 4 |
| Security | Privacy decided before fan-out, by channel; gateways never see strangers; pv ordering makes ghost mode instant; TLS on every socket SEC 7 · SEC 9 |
| Cost Optimization | Self-run gateways instead of API Gateway ($38K vs $2.2M); TCP instead of TLS listeners on the NLB; ephemeral state instead of durable writes COST 5 · COST 8 |
| Operational Excellence | Planned drains for deploys; alarms on connections, admission rejects, deliveries per update and server-side delivery latency OPS 6 · OPS 8 |
| Sustainability | Light this round: Graviton throughout; the gateway fleet scales with the evening curve SUS 2 |
R2.11 Round 2 Rubric and Follow-Ups
What a strong senior (L6) answer shows
- Proves the fan-out explosion with numbers and prunes it, labeling each assumption.
- Says where each filter runs and why (online by subscription; nearby at the viewer's gateway, which already knows the viewer's position).
- Compares per-friend and per-cell channels on fan-out, privacy and churn, and picks one.
- Knows classic pub/sub doesn't scale in cluster mode, and uses sharded pub/sub.
- Verifies cell sizes instead of repeating "k = 2 covers 5 km".
- Enforces privacy on the server before fan-out, and handles the ghost-mode race with a version.
- Sizes the gateway fleet for peak and AZ loss, and designs the reconnect storm away.
Follow-up questions
-
"Why not have Alice's side compute distances to her online friends and send only to the nearby ones?" Answer: her side doesn't know where they are. It would have to read 40 friends' positions per update, 13.3M reads a second. The viewer's gateway already knows the viewer's position for free, because the viewer's own updates pass through it. The near/far tiers get most of the benefit of sender-side pruning without the reads.
-
"A user has 5,000 friends. What changes?" Answer: about 500 are online, so connecting costs 5,000 presence reads and up to 500 subscriptions, and their friend set exceeds our raised intset limit only above 5,000. The fan-out per update is still bounded by near-tier subscribers. We cap friends at 5,000 as a product rule, and very large accounts (public figures) don't get location sharing with all followers anyway.
-
"How do you know the 5% 'nearby' assumption is right?" Answer: we measure frames per update and internal deliveries per update at the gateways. If frames per update drifts from 2 toward 4, egress and gateway CPU double; the alarm is on the ratio, not just the totals.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Poll faster" | Cost grows with the polling rate, not with change. |
| "Send to all friends, the app filters" | We pay for 133M messages a second, and far friends learn your position. |
| "Cell channels are the obvious design" | They route strangers' positions through every gateway and churn as users move. |
| "H3 resolution 7, k = 2 covers 5 km" | The average edge is 1.41 km; k = 2 covers only about 3.7 km, k = 3 fails in Oslo. |
| "Fuzz with fresh random noise" | 120 samples average back to within about 91 m. |
| "Five nines" | Five nines is 5.26 minutes a year; one AZ reconnect storm takes minutes. |
Round 3 · Architect · "Global, Private by Design, and Battery-Friendly"
~45 min · Principal (L7) · 4 regions · 50M connected at the sum of regional peaks · 783K updates/s at those peaks · delivery P95 < 1 s · 99.99% per region · privacy by default · a battery budget
R3.0 Where We Left Off
Round 2 in 60 seconds. "We serve 100M daily users from one region: 10M phones connected on average, 15M at peak, each sending every 30 seconds, so 333K updates a second, 500K at peak. Phones hold WebSockets to Fargate gateways behind an NLB with a TCP listener, up to 50K sockets per task and 450 tasks at peak so two AZs can carry everyone. Naive fan-out is 133M messages a second; we prune to online friends by subscription and to friends within 5 km at the viewer's gateway, which already knows the viewer's position: 667K frames a second. Routing uses per-friend channels on Valkey sharded pub/sub, with near, far and fuzzy channels per user, so the far-away majority of friends costs almost nothing, and gateways subscribe on the replica in their own AZ. We compared per-cell channels and rejected them: strangers' positions on every gateway and churn as people move; and k = 2 H3 rings don't cover 5 km anyway. Privacy is decided before fan-out, by channel; fuzzy is a resolution-6 cell with hysteresis; a privacy version makes ghost mode win every race. Alerts run in the sharer's pipeline with hysteresis and fire exactly once. Reconnect storms meet full jitter and admission before TLS. About $88.7K a month. Open costs: one region; friends on other continents; no consent model; no history; nothing against stalking; and a fixed 30-second interval that ignores the battery."
Architecture v2, compact
Synthesizing vector architecture diagram...
Round 2 in one picture: gateways write through one script and route through per-friend channels; settings flow in from DynamoDB.
Round 2 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | Live for 10M | WebSocket gateways, NLB TCP | Stateful fleet |
| 2.2 | 133M msgs/s | Online by subscription, nearby at viewer's gateway | Assumptions to watch |
| 2.3 | Routing | Per-friend sharded pub/sub, near/far/fuzzy | Churn at connect |
| 2.4 | Cells | H3, rings verified | Size varies 2× |
| 2.5 | Fuzzy, ghost | Mode channels, deterministic cell, pv | Control round per change |
| 2.6 | Alerts | Sharer-side, hysteresis, exactly once | Pair state |
| 2.7 | Reconnect storm | Jitter, admission before TLS | Minutes of gap |
Open costs: a single region; cross-continent friends; no consent or history; no stalking defenses; a fixed interval.
R3.1 The Scope Raise
Interviewer: "We're global now: about a third of a billion daily users on every continent, 50 million connected at peak. People travel, and their friends live everywhere. Regulators in many countries treat precise location as sensitive personal data. Users keep asking for a trip history. Our safety team is worried about one scenario above all: an abusive partner who gets added as a friend to track someone. And the battery team has handed us a hard budget."
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Where are the users? | Americas 30%, Europe, Africa and the Middle East 30%, East Asia 20%, South and Southeast Asia and Oceania 20%. | Four regions; a home region per user (step 3.1). |
| Do people use it while traveling? | Yes; about 3% of connected users are away from their home region at any moment. | A traveler's updates are forwarded to their home region (step 3.1). |
| What's the privacy rule? | Keep only what the feature needs; users must consent; no precise location in analytics. | Data minimization, consent records, retention (step 3.2). |
| History? | Opt-in only, deletable any time, with a retention limit. | A separate encrypted history path (step 3.3). |
| What does safety need? | Sharing that doesn't last forever by accident, and warning signs when someone is watching a person too closely. | Expiring shares, review prompts, viewing alerts (step 3.4). |
| The battery budget? | A fixed share of battery per hour of sharing, measured by the battery team; the details are theirs. | Update frequency tied to motion and to whether anyone is watching (step 3.5). |
| A region fails? | Users anywhere must keep seeing friends within minutes. | Fallback homes, no failover of live state (step 3.6). |
Scope change
| Round 2 | Round 3 | |
|---|---|---|
| Users | 100M DAU; 15M connected at peak | About 333M DAU; 50M connected at the sum of regional peaks |
| Regions | 1 | 4, home region per user, fallback home per user |
| Update interval | Fixed 30 s | By motion state and by whether anyone is watching: one update per 64 s on average |
| Privacy | Modes per friend | + consent records, minimization, expiring shares, safety signals |
| History | None | Opt-in, encrypted per user, 90-day retention, delete on request |
| Targets | P95 < 1 s; 99.99% | Same, per region; privacy by default |
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 Singapore pays a round trip across the Pacific on every update; a region failure takes everyone down. |
| Live state keyed by user in one cluster | Friends span continents. Which region holds Alice's channels when she's in Tokyo and her friends are in Paris? |
| Settings, no consent | We can't show when and to what someone agreed, or prove we stopped when they withdrew. |
| No history path | Users ask for trips; bolting history onto live state would keep a trail of everyone. |
| Sharing lasts until someone turns it off | A share set up once, under pressure, can run silently for years. |
| Fixed 30 s interval | Spends battery sending positions nobody is looking at. |
R3.3 New Requirements and API Additions
Tokens carry routing. The access token names the user's home region and fallback home, so any gateway can route without a lookup:
json{ "sub": "u1007", "home": "us-east-1", "fallback": "ap-northeast-1", "data_region": "us-east-1", "exp": 1791219751 }
Consent records (append-only; one record per decision):
httpPOST /v1/me/consents HTTP/1.1 Content-Type: application/json { "purpose": "location_sharing", "granted": true, "text_version": "2026-09", "surface": "onboarding_step_3" }
json{ "consent_id": "c_8f2a", "purpose": "location_sharing", "granted": true, "recorded_at": "2026-10-05T14:10:02Z" }
Share with a friend, for a while:
httpPOST /v1/me/shares HTTP/1.1 Content-Type: application/json { "friend_id": 4411, "mode": "precise", "duration": "until_end_of_day" }
json{ "share_id": "s_19c4", "friend_id": 4411, "mode": "precise", "expires_at": "2026-10-05T23:59:59-07:00", "pv": 31 }
Durations: 1h, until_end_of_day, 24h, until_i_stop. The last one needs an explicit extra confirmation.
Location history:
httpPUT /v1/me/history HTTP/1.1 Content-Type: application/json { "enabled": true, "retention_days": 90 }
httpDELETE /v1/me/history HTTP/1.1
json{ "status": "deleted", "readable_until": null, "unrecoverable_after": "2026-10-12T14:00:00Z", "bytes_removed_by": "2026-10-06T14:00:00Z" }
Who has been looking:
httpGET /v1/me/viewers?since=2026-10-01 HTTP/1.1
json{ "viewers": [ { "friend_id": 7730, "view_sessions": 23, "first_shared": "2026-10-03" } ] }
"Locate now" (asks a friend's phone for a fresh fix): POST /v1/friends/1007/locate, at most 3 an hour per pair, and the friend is told each time.
R3.4 Design Evolution: Global, Private and Battery-Aware
Step 3.1: Friends in Different Regions
The problem: Alice lives in New York, and today she's in Paris. Some of her friends are in Paris, most are in New York, a few in Tokyo. Round 2 assumed everyone's live state and channels were in one cluster. What would you do?
Primitive: Cloud Disaster Recovery and Multi-Region Active-Active
Synthesizing vector architecture diagram...
Alice's data stays with her home region even when she doesn't. Her updates cross the Atlantic and come back to Pierre's gateway; Pierre's updates reach her locally.
Step 3.2: Precise Location Is Regulated Data
The problem: in many countries, precise location tied to a person is treated as sensitive personal data. Legal asks: what do we keep, where, for how long, and can we show that each user agreed? What would you do?
Step 3.3: Opt-In Trip History
The problem: some users want to look back at last weekend's trip. Everyone else must never have a trail. What would you do?
Step 3.4: Someone Uses the Feature to Stalk a Person
The problem: an abusive partner takes the victim's phone for a minute, adds himself as a friend, turns on precise sharing, and then watches her all day. Every step used features exactly as designed. What would you do?
Step 3.5: The Battery Budget
The problem: the battery team measures our feature's drain and says we're over budget. Product wants friends' dots to stay fresh. What would you do?
Synthesizing vector architecture diagram...
The phone's motion states. Only Walking and Driving poll GPS on a timer; Stationary waits for the OS to report a move, plus a 5-minute heartbeat. Every transition waits 3 minutes, so a red light doesn't count as parking.
Step 3.6: A Region Is Down
The problem: us-east-1 fails at 09:00 UTC. Its home users (10M connected on average) lose their sockets, their live state and their channels. Their friends everywhere lose their subscriptions to those channels. What would you do?
Round 3 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 3.1 | Friends across regions | Home region per user; travelers forwarded; cross-region subscriptions | Travel latency; cross-region presence reads |
| 3.2 | Regulated data | Ephemeral state, no positions in logs or analytics, consent records, purpose limit, retention | Coarse analytics |
| 3.3 | Opt-in history | Kinesis → Flink → DynamoDB per user-hour; per-user-month keys; crypto retention | A second path; a precious key table |
| 3.4 | Stalking | Expiring shares on our clock, review prompts, viewing alerts, visible "locate now" | Friction |
| 3.5 | Battery | Motion-state machine; watchers-based slowdown within the far-channel proof | Stationary dots up to 5 min old |
| 3.6 | Region down | Fallback home per user, region-status document, rebuild live state | Minutes of gap; sized headroom |
R3.5 Global Architecture
Synthesizing vector architecture diagram...
One regional stack. Everything about a user lives in their home region's clusters, except settings and consents, which every region needs and which replicate through the global table. Dotted lines leave the region: forwarding for travelers and cross-region friends, and settings replication. History never leaves its data region.
Tracing a traveler's update: Alice (home us-east-1) in Paris, and Pierre sees her
Synthesizing vector architecture diagram...
About 180 ms more than a local update. Alice's position never touches an EU data store; it passes through EU gateways in memory only.
Tracing a stalking-risk alert
- Sam became Maya's friend 2 days ago; Maya shared precisely "until I stop" (after the confirmation).
- Between 01:00 and 03:40, Sam opens the map 23 times. Each time, Sam's gateway adds a new session ID to
views:{u5120}:{7730}:20261005. - The share is younger than 7 days and it's night, so the thresholds are 5, 10 and 20. The 5th add returns 1 and makes the size 5: the gateway emits one event, and the safety service notifies Maya: "Sam has checked your location 5 times tonight. Stop sharing?" The 10th and 20th adds do the same, if she hasn't acted.
- A retried add of the same session returns 0 and emits nothing, so Maya gets one notice per threshold, not a flood.
- Maya taps "stop sharing with Sam": her settings
pvgoes up, thehidegoes out, and Sam's gateway drops Maya within a second.
Tracing a region failover: us-east-1 fails at 09:00 UTC
Synthesizing vector architecture diagram...
No data was restored. The user's state was rebuilt at a home every gateway agrees on, even though latency routing sent the phone to Frankfurt.
R3.6 Numbers and Cost
Traffic per region
| Region | Peak connected | Average connected (2/3 of peak, as in Round 2) | Updates/s at peak (× 0.01567) | Frames/s at peak (× 2) | Internal deliveries/s at peak |
|---|---|---|---|---|---|
| us-east-1 | 15M | 10M | 235K | 470K | 1.69M |
| eu-central-1 | 15M | 10M | 235K | 470K | 1.69M |
| ap-northeast-1 | 10M | 6.67M | 157K | 313K | 1.13M |
| ap-southeast-1 | 10M | 6.67M | 157K | 313K | 1.13M |
| Sum | 50M | 33.3M | 783K | 1.57M | 5.64M |
Internal deliveries per user per second: 6 near subscribers × 0.01567 + 34 far or fuzzy ÷ 1,800 ≈ 0.113 (Round 2's assumptions, with the new update rate). The 50M is the sum of each region's own peak; the peaks come at different hours, so fewer are connected worldwide at any one moment. Daily users: 33.3M ÷ 10% ≈ 333M.
What each region must absorb. If a region fails, its users spread over the other three by fallback home. The worst case is a 15M region failing at its peak: +5M for each survivor. Each region is sized for the larger of (a) its own peak with one AZ lost and (b) its own peak plus 5M with all AZs up. We don't plan for both at once.
Gateways (50K per task; one AZ lost means two AZs carry the peak):
| Region | (a) own peak with an AZ lost | (b) own peak + 5M | Tasks at peak | Average tasks |
|---|---|---|---|---|
| us-east-1 | 15M ÷ 2 ÷ 50K = 150 per AZ → 450 | 20M ÷ 50K = 400 | 450 | 300 |
| eu-central-1 | 450 | 400 | 450 | 300 |
| ap-northeast-1 | 10M ÷ 2 ÷ 50K = 100 per AZ → 300 | 15M ÷ 50K = 300 | 300 | 200 |
| ap-southeast-1 | 300 | 300 | 300 | 200 |
For Tokyo and Singapore, (b) lands exactly at 50K per task, so a failover at their peak also triggers a scale-out; admission control holds the line while tasks start.
State clusters (cache.r7g.xlarge; 100K scripts/s per primary; 17 GB usable per shard):
| Region | Users to hold (b) | Scripts/s | Memory (live 300 B + friends 3.3 KB + settings 100 B per user) | Shards × 3 nodes |
|---|---|---|---|---|
| us-east-1 | 20M | 313K → 63K per primary | 20M × 3.7 KB ≈ 74 GB → 14.8 GB per shard | 5 × 3 = 15 |
| eu-central-1 | 20M | 313K | ≈ 74 GB | 5 × 3 = 15 |
| ap-northeast-1 | 15M | 235K → 59K per primary | ≈ 55.5 GB → 13.9 GB per shard | 4 × 3 = 12 |
| ap-southeast-1 | 15M | 235K | ≈ 55.5 GB | 4 × 3 = 12 |
Pub/sub clusters (cache.m7g.xlarge, 300K ops/s per node; ops = deliveries + publishes received by each node + subscribes and unsubscribes at 76 × 2 per connect, with 30-minute sessions):
| Region | (a) own peak, one AZ lost | (b) own peak + 5M, all AZs | Shards × 3 nodes |
|---|---|---|---|
| us-east-1 | 1.69M + 235K × 1.05 × 2 + 8,300 × 152 ≈ 3.44M ÷ 300K = 11.5 surviving nodes → 17.3 in total | 2.26M + 313K × 1.05 × 3 + 11,100 × 152 ≈ 4.93M ÷ 300K = 16.4 | 6 × 3 = 18 |
| eu-central-1 | same | same | 6 × 3 = 18 |
| ap-northeast-1 | 1.13M + 157K × 1.05 × 2 + 5,560 × 152 ≈ 2.30M ÷ 300K = 7.7 → 11.5 in total | 1.69M + 235K × 1.05 × 3 + 8,300 × 152 ≈ 3.69M ÷ 300K = 12.3 | 5 × 3 = 15 |
| ap-southeast-1 | same | same | 5 × 3 = 15 |
Three nodes per shard means one per AZ in every region, so the node counts are already whole per AZ.
Cross-region volume (monthly averages; 30.4-day month = 2,626,560 s):
| Flow | Math | Rate | Per month |
|---|---|---|---|
| Travelers' updates to their home | 3% of 522K average updates/s ≈ 15.7K/s × 500 B (script and publish) | 7.8 MB/s | 20.5 TB |
| Travelers' near deliveries back | 15.7K/s × 6 subscribers × 150 B | 14.1 MB/s | 37.0 TB |
| Far and fuzzy messages to friends homed elsewhere | we assume 20% of friendships cross regions: 33.3M users × 34 × 20% ÷ 1,800 s ≈ 126K/s × 150 B | 18.9 MB/s | 49.6 TB |
| Presence reads at connect for friends homed elsewhere | 18,500 connects/s × 80 friends × about 45 B per HGET live:{f} gw round trip | 66.6 MB/s | 175 TB |
| Total | 107 MB/s | ≈ 282 TB |
The surprise is the last line: presence reads at connect are 62% of cross-region bytes, more than all live traffic. The lever is longer sessions (fewer connects) or a small per-region mirror of "who is online" for users with friends elsewhere; we note it rather than add it now.
Inter-region transfer is billed at the sending region: $0.02/GB from us-east-1 and eu-central-1, $0.09/GB from ap-northeast-1 and ap-southeast-1 (list prices). Assuming 60% of the bytes leave the US and EU regions: 169 TB × $0.02 + 113 TB × $0.09 ≈ $3,380 + $10,170 ≈ $13.5K.
History storage (opted in: we assume 5% of users):
| Item | Math | Result |
|---|---|---|
| Opted-in users connected on average | 33.3M × 5% | 1.67M |
| Items written | one per user-hour: 1.67M × 730 h | 1.22B a month |
| Stored for 90 days | 3 months × 1.22B × 1 KB | ≈ 3.7 TB across the four data regions |
| Kinesis records | 5% of 522K updates/s ≈ 26K/s × 2.63M s | 68.6B a month |
Egress to phones (average frames 2 × updates, 220 B each, plus up to 60 B of pings per user per minute):
| Region | Rate | Volume | List price tiers (per GB) | Monthly |
|---|---|---|---|---|
| us-east-1 | 313K × 220 B + 10 MB/s ≈ 78.9 MB/s | 207 TB | 10 TB × $0.09 + 40 × $0.085 + 100 × $0.07 + 57 × $0.05 | ≈ $14.2K |
| eu-central-1 | same | 207 TB | same tiers as us-east-1 | ≈ $14.2K |
| ap-northeast-1 | 209K × 220 B + 6.7 MB/s ≈ 52.6 MB/s | 138 TB | 10 × $0.114 + 40 × $0.089 + 88 × $0.086 | ≈ $12.3K |
| ap-southeast-1 | same | 138 TB | 10 × $0.12 + 40 × $0.085 + 88 × $0.082 | ≈ $11.8K |
| Total | ≈ 691 TB | ≈ $52.4K |
Monthly cost (on-demand list prices per region, 730 hours a month):
| Item | Math | Monthly |
|---|---|---|
| Gateways (Fargate ARM, 4 vCPU/8 GB) | average tasks × per-task-month price: 300 × $115.34 (US) + 300 × $132.66 (Frankfurt: $0.03725 per vCPU-h, $0.00409 per GB-h) + 200 × $143.93 (Tokyo) + 200 × $143.93 (Singapore: both $0.04045, $0.00442) = $34.6K + $39.8K + $28.8K + $28.8K | ≈ $132.0K |
| Data transfer out | table above | ≈ $52.4K |
State clusters (Valkey cache.r7g.xlarge) | (15 × $0.3496 + 15 × $0.4200 + 12 × $0.4192 + 12 × $0.4200)/h × 730 h | ≈ $15.8K |
Pub/sub clusters (Valkey cache.m7g.xlarge) | (18 × $0.2520 + 18 × $0.3000 + 15 × $0.3240 + 15 × $0.3136)/h × 730 h | ≈ $14.2K |
| NLBs | processed bytes dominate: US and EU ≈ 433 GB/h each, Tokyo and Singapore ≈ 289 GB/h each: 1,444 capacity units × $0.006 × 730, plus hourly fees ($0.0225 to $0.027) | ≈ $6.4K |
| Cross-region transfer | above | ≈ $13.5K |
| Cross-AZ traffic | Round 2's $4.2K × (522K ÷ 333K updates), at $0.02/GB effective ($0.01 out plus $0.01 in) | ≈ $6.6K |
| Background HTTPS path | Round 2's $4.7K × 1.57, plus regional price differences (rough) | ≈ $8.1K |
| DynamoDB settings and consents (global table, 4 replicas) | we assume 600M accounts × 1 KB × ($0.25 + $0.306 + $0.285 + $0.285) per GB ≈ $680; about 1.45B settings writes, each applied in 4 regions at about $0.70 per million ≈ $4.1K; cache-miss reads ≈ $6.6K; consent items ≈ $0.2K | ≈ $11.6K |
| History path | Kinesis 68.6B PUT units × $0.014 per million ≈ $960 + about 36 shards ≈ $400; Flink ≈ $1.6K; DynamoDB writes ≈ $0.8K and 3.7 TB storage ≈ $1.0K; KMS ≈ $0.1K | ≈ $4.9K |
| SNS mobile push | alerts and safety notices | ≈ $0.8K |
| Route 53 | latency-based queries, depending on resolver caching; health checks (rough) | ≈ $1.7K |
| CloudWatch, logs, AppConfig, VPC endpoints | about $3K per region (an estimate) | ≈ $12.0K |
| Safety jobs | the safety service and its rules (an estimate) | ≈ $3.0K |
| Total | ≈ $283K/month |
About $0.85 per thousand daily users a month, a little below Round 2's $0.89: the battery work halved the update rate per user, which paid for the extra regions. The gateways are 47% of the bill, and egress 19%.
Latency, delivery P95
| Path | P95 |
|---|---|
| Local (sharer and viewer homed where they are) | ≈ 628 ms, as in Round 2 |
| Traveler (forwarded to the home region and back) | ≈ 628 + 180 ≈ 810 ms (rough) |
R3.7 Trade-Offs
Home region vs local processing
| Home region per user (chosen) | Process wherever the phone connects | |
|---|---|---|
| Where Alice's channels live | Always in her home region (or fallback) | In whatever region she's connected to right now |
| How friends find them | From her token and the region status: no lookup | They'd need a global "where is Alice connected now" lookup, updated as she travels |
| Travelers | ≈ 180 ms more per update | Local |
| Data location | Her live state stays in her home region | Follows her around the world |
| Failover | Deterministic fallback home | Whatever region she lands in |
We pick the home region: only 3% of users travel at a time, and a deterministic location is what makes both routing and failover simple.
Default privacy modes vs engagement
| Default for a new friend | Safety | Engagement |
|---|---|---|
| Nothing until chosen (ours for new friends) | Best: no silent sharing | Every friend needs a decision |
| Fuzzy | Good: area only | People who want to meet must upgrade |
| Precise | Worst: a coerced add shares everything at once | Highest |
We recommend "nothing until chosen, precise only by explicit choice, and shares expire by default", and name it as a product decision the safety team owns.
Update frequency vs battery
| Fixed 30 s (Round 2) | Motion-based (Round 3) | Motion-based + unwatched slowdown | |
|---|---|---|---|
| Average interval | 30 s | 64 s | longer (not counted) |
| Stationary friend's dot | ≤ 30 s old | ≤ 5 min old, and correct | same |
| Radio wakes per hour, stationary user | 120 | 12 | 12 |
| Server cost | 1× | about 0.47× per user | lower still |
What changed from Round 1. Round 1 answered "which friends are near?" with one cache and a poll. Round 3 answers it with four regions of gateways and pub/sub that route by friendship, forget positions within minutes, and send only what someone is looking at. The core never changed: start from the friend list, keep only the latest position, and let the viewer's side decide distance.
R3.8 Failure Modes
| Trigger | What you'd see | How the design responds |
|---|---|---|
| A region outage | Its home users disconnect; friends lose those channels | Fallback homes, region status, rebuild (step 3.6); most users back within about 6 to 7 minutes. |
| The consent or settings store is unreachable | Settings cache misses fail | Fail closed: no settings, no subscription. A viewer who can't be checked sees nothing new for that sharer; existing subscriptions keep running only until their pv changes or their share expires. A sharer can always go ghost on the device, which stops sending at the source. |
| A history pipeline bug writes points for users without consent | The daily audit finds history items for users with no current history consent | "No key, no write" blocks most of it. The purge job lists users with items but no consent, deletes their keys (unreadable at once), then their items; the bug's deploy is rolled back and the audit re-run before the pipeline restarts. |
| A mass reconnect (region failover, a bad deploy, a carrier outage) | New connections spike across regions | Full jitter and admission before TLS in every region; planned drains for deploys; watch admission rejects, not only connections. |
| Global table replication lags | A settings change made in one region takes seconds longer to apply elsewhere | The home region applies changes first (settings writes go there); the pv makes late arrivals harmless. Alarm on ReplicationLatency. |
| Clock skew on a gateway | A share expires late or early on one gateway | Gateways use the Amazon Time Sync Service and refuse to start if the offset is over 100 ms; expiry checks allow no grace period. |
R3.9 Runbook and Incident Response
Golden signals, per region OPS 8 · REL 6
| Signal | Alarm | Severity | First action |
|---|---|---|---|
| Connected sockets (NLB active flows) | drops 20% in 5 min, or above 90% of fleet capacity | P1 / P2 | Check health checks, gateway errors; scale the gateway service |
| Update rate | outside 70% to 130% of the same hour last week | P2 | A client release or an OS change; check next_update_s hints |
| Fan-out per update (internal deliveries and phone frames per update) | frames per update above 3 (normally about 2) | P2 | A tiering or distance-filter bug, or a crowd; egress and CPU will follow |
| Delivery P95, server side (NLB in to socket write) | > 50 ms for 5 min | P1 | Pub/sub node CPU; gateway send queues |
| Privacy-check failures (fail-closed decisions) | > 0.1% of subscription decisions | P1 | Settings store or cache; users are seeing less, not more, but fix fast |
| Reconnect rate and admission rejects | new connections > 3× baseline, or rejects > 10% | P1 | Reconnect-storm procedure below |
| Settings stream consumer age | > 60 s | P2 | Restart or scale the Lambda consumer; pv jumps still protect ordering |
| History audit findings | any item without consent | P1 | Purge procedure (R3.8) |
Reconnect-storm procedure REL 5
- Confirm it's a storm: new connections and admission rejects up, pub/sub subscribe rate up.
- Don't raise admission limits first. Check pub/sub and state CPU; if they're under 70%, raise admission by 20% at a time.
- Scale the gateway service out; new tasks add admission capacity without raising any single task's rate.
- If a bad deploy caused it, stop the rollout; planned drains spread the rest.
- Watch reconnect success, not only connection counts: phones stuck in backoff show up as a lower update rate.
Region return procedure (restore order before traffic comes back) REL 13
- Keep the region out of DNS: its health-check endpoint reports healthy only after the steps below.
- Flush its state and pub/sub clusters: presence,
gwfields and subscriptions from before the outage are all wrong. - Check the settings global table replica has caught up (
ReplicationLatencyback to normal), then flush the region's settings caches. - Restart the change feeds that fill our caches: re-enable the Lambda event source mapping on the region's settings stream (it resumes from its checkpoint; stream records older than 24 hours are gone, which the cache flush in step 3 already covers), and resume the friend-graph event consumer.
- Flip the region-status document to
upwith a start time. Gateways move users back in waves: a user's effective home becomes the recovered region when (hash of user ID mod 20) is below the minutes since the start time, so every gateway agrees on each user, and 5% move per minute. Gateways re-subscribe; phones stay connected. - Let the health check pass. Over the next 30 minutes, gateways in other regions drain home users with planned reconnects, so latency routing brings them home.
Go deeper: CLI playbook
Plain commands an on-call engineer runs one at a time. Replace names, IDs and ARNs with real ones.
text# 1. Alarms firing in a region aws cloudwatch describe-alarms --region eu-central-1 --state-value ALARM --alarm-name-prefix nearby- # 2. Active connections on a gateway NLB, per minute aws cloudwatch get-metric-statistics --region eu-central-1 --namespace AWS/NetworkELB --metric-name ActiveFlowCount --dimensions Name=LoadBalancer,Value=net/nearby-gw/0123456789abcdef --start-time 2026-10-05T09:00:00Z --end-time 2026-10-05T09:30:00Z --period 60 --statistics Average # 3. Gateway service state and emergency scale-out aws ecs describe-services --region eu-central-1 --cluster nearby --services gateway aws ecs update-service --region eu-central-1 --cluster nearby --service gateway --desired-count 600 # 4. Pub/sub and state clusters aws elasticache describe-replication-groups --region eu-central-1 --replication-group-id nearby-pubsub aws elasticache describe-replication-groups --region eu-central-1 --replication-group-id nearby-state # 5. Health check status for a regional endpoint aws route53 get-health-check-status --health-check-id 1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d # 6. Settings global table replicas and replication lag into us-east-1 aws dynamodb describe-table --region eu-central-1 --table-name NearbySettings aws cloudwatch get-metric-statistics --region eu-central-1 --namespace AWS/DynamoDB --metric-name ReplicationLatency --dimensions Name=TableName,Value=NearbySettings Name=ReceivingRegion,Value=us-east-1 --start-time 2026-10-05T09:00:00Z --end-time 2026-10-05T10:00:00Z --period 60 --statistics Average # 7. Re-enable the settings stream consumer in a recovered region aws lambda list-event-source-mappings --region us-east-1 --function-name settings-cache-updater aws lambda update-event-source-mapping --region us-east-1 --uuid 14e0db71-5d35-4eb5-b481-8945cf9d10c2 --enabled # 8. Deploy the region-status document aws appconfig start-deployment --region eu-central-1 --application-id abc1234 --environment-id def5678 --deployment-strategy-id AppConfig.AllAtOnce --configuration-profile-id ghi9012 --configuration-version 42
R3.10 Pillar Check
| Pillar | What Round 3 adds |
|---|---|
| Reliability | Four regions; fallback homes computed identically by every gateway; live state rebuilt, not failed over; each region sized for its peak with an AZ lost or its peak plus a third of a failed neighbor; ordered return REL 10 · REL 13 |
| Security | Minimization everywhere a position could land; consent records; per-user-month history keys with crypto retention; expiring shares on our clock; viewing alerts; fail closed SEC 7 · SEC 8 |
| Performance Efficiency | Connect locally, forward to the home; far channels keep cross-region friends cheap; P95 about 628 ms local, about 810 ms for travelers PERF 1 · PERF 4 |
| Cost Optimization | About $283K a month, $0.85 per thousand daily users; per-region list prices; the cross-region presence line found and named; gateways as the lever (Compute Savings Plans apply to Fargate) COST 7 · COST 8 |
| Operational Excellence | Golden signals with first actions; reconnect-storm and region-return procedures; change feeds restarted explicitly OPS 8 · OPS 10 |
| Sustainability | Half the updates per user through motion states; slower still when nobody watches; no location stored that nobody needs SUS 3 · SUS 4 |
R3.11 Round 3 Rubric and Follow-Ups
What an architect (L7) answer adds over L6
- Places each user's live data in a home region and forwards travelers, instead of replicating live state.
- Knows latency routing doesn't send users to a chosen standby, and makes failover deterministic with a fallback home every gateway computes the same way.
- Rebuilds ephemeral state rather than failing it over, and restores order (flushes, stream catch-up) before traffic returns.
- Treats location as regulated data: minimization, consent records, purpose limits, retention that doesn't depend on TTL timing.
- Designs against abuse by a trusted friend, not only by outsiders.
- Ties update frequency to motion and to whether anyone is watching, and keeps the far-channel guarantee intact while doing it.
- Finds the surprising cost (presence reads across regions), not only the obvious ones.
Follow-up questions
-
"Why not fail live state over with ElastiCache Global Datastore?" Answer: Global Datastore copies from one primary region to secondaries, so every update of every user would cross regions, and the copy of a location that's minutes old is useless anyway. Phones rewrite their state within one update interval; rebuilding is cheaper and simpler than replicating.
-
"A user is homed in Europe but has lived in Tokyo for six months. What happens?" Answer: after 30 days of connections mostly from Tokyo, we re-home them: the next token says
home: ap-northeast-1, the gateway publishes a control message, and friends' gateways re-subscribe there. Their history stays in their data region unless they move it, which is a separate, explicit step. -
"The safety team wants to see the positions behind a stalking report." Answer: we don't have them: live positions expire in 10 minutes and there's no trail unless the victim opted into history, and history is readable only with her key. What we have is who shared with whom, when, and how often they viewed. That's a deliberate trade-off, and legal process goes through counsel.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "One global live-state cluster" | Every update crosses oceans; one region down stops everyone. |
| "Latency routing sends them to the standby" | It sends each phone to its own nearest healthy region; route to a deterministic fallback home. |
| "Encrypt at rest and we're compliant" | Minimization, consent and retention matter more than disk encryption. |
| "DynamoDB TTL enforces the 90-day retention" | TTL deletes within days; delete keys on our own clock. |
| "Shares last until turned off" | Silent, permanent sharing is exactly what an abuser sets up. |
| "Fresher is always better" | A fix nobody looks at costs battery and money. |
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, freshness, privacy, retention, friend count | Restate Round 1 in 60 seconds | Restate Round 2 in 60 seconds |
| 5–15 min | Requirements and API (measured_at, next_update_s, sharing switch) | Scope raise → what breaks | Scope raise → what breaks |
| 15–40 min | Steps 1.0–1.4: ping log → latest in memory → friend list first → airtight sharing flag → adaptive interval | Steps 2.1–2.7: WebSockets → fan-out math → per-friend channels and tiers → H3 checked → fuzzy and ghost → alerts → reconnect storm | Steps 3.1–3.6: home regions → minimization → opt-in history → anti-stalking → battery → region failover |
| 40–50 min | Freshness chain, memory, cost | Fan-out table, gateways, clusters, cost, trade-offs | Per-region sizing, cross-region bytes, cost, 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. For more on connection fleets, see the chat loop.
The Two Sentences That Matter Most
- Opening any round: "Everyone moves and every update could go to hundreds of friends, so I'll keep only each person's latest position in memory, start every question from the friend list rather than from space, and let the viewer's side decide distance."
- When scale arrives: "I'll prune the fan-out to friends who are online, by subscription, and nearby, at the viewer's gateway, route by friendship rather than by cell so strangers' positions never move, and enforce every privacy mode on the server before anything fans out."
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 happens when an AZ dies?" (REL 5) | Two AZs hold the peak; phones back off with full jitter, gateways admit 80 a second before TLS, and state refills with the next update. | 2 | Step 2.7, R2.6 |
| "What if a whole region fails?" (REL 13) | Every gateway computes the same fallback home, live state is rebuilt there within an update, and the return is ordered. | 3 | Step 3.6, R3.9 | |
| "What if the cache loses the sharing flags?" (REL 11) | We rebuild them from DynamoDB before serving again. | 1 | R1.9 | |
| Performance | "How do you avoid 133M messages a second?" (PERF 1) | Online by subscription, nearby at the viewer's gateway, and near/far channels: 667K frames a second. | 2 | Steps 2.2, 2.3 |
| "How fast is an update?" (PERF 4) | About 628 ms P95, mostly the two radio legs; about 810 ms for travelers. | 2–3 | R2.6, R3.6 | |
| Security | "Who can see my location?" (SEC 7) | Only friends you chose, in the mode you chose, until the share expires; gateways never see strangers. | 2–3 | Steps 2.3, 2.5, 3.4 |
| "What location data do you keep?" (SEC 7) | The latest position for 10 minutes; history only if you opt in, encrypted with your own keys, gone on request. | 1–3 | Steps 1.1, 3.2, 3.3 | |
| "Is it encrypted?" (SEC 9) | TLS on every socket and internal connection, plus encryption at rest; but minimization matters more. | 2–3 | Step 3.2 | |
| Cost | "What does it cost?" (COST 5) | About $616, $88.7K and $283K a month; under a dollar per thousand daily users at scale. | 1–3 | R1.7, R2.6, R3.6 |
| "Why not API Gateway WebSockets?" (COST 5) | Per-message billing: about $2.2M a month against $38K for our gateways. | 2 | Step 2.1 | |
| "Where does the money go?" (COST 8) | Gateways and egress; in-AZ subscriptions avoid cross-AZ charges; cross-region presence reads are the surprise line. | 2–3 | R2.6, R3.6 | |
| Operations | "How do you know fan-out is healthy?" (OPS 8) | Frames and internal deliveries per update, server-side delivery P95, admission rejects. | 3 | R3.9 |
| Sustainability | "How do you save battery?" (SUS 3) | Motion states and slowing down when nobody watches: half the updates of a fixed interval. | 1–3 | Steps 1.4, 3.5 |
Rubric Across Levels
| Dimension | L5 (Round 1) | L6 (Round 2) | L7 (Round 3) |
|---|---|---|---|
| Live state | Latest position in memory with a TTL | Live state with presence, anchors and a privacy version | Home region per user; rebuilt, not failed over |
| Fan-out | Friend list first, one batch read | 133M → 667K with labeled assumptions; per-friend channels, tiers, sharded pub/sub | Cross-region friends on far channels; the cross-region cost found |
| Cells | Not needed | H3 sizes and ring coverage verified; per-cell channels rejected with reasons | Coarse cells for analytics only |
| Privacy | An airtight on/off switch | Server-side modes before fan-out; deterministic fuzzing; pv ordering | Minimization, consent, crypto retention, anti-stalking |
| Connections | None (polling) | Gateways, NLB limits, heartbeats, reconnect storms | Region failover with deterministic fallback |
| Battery | Adaptive interval | Fixed interval, jitter | Motion states, watcher-based slowdown within the far-channel guarantee |
| Evolving under new scope | Builds from a ping log step by step | Opens with the fan-out math | Changes where data lives without breaking routing or privacy |