Design Nearby Friends
Show each user which friends are within 5 miles, refreshed every 30 s, for 10 M concurrent users, without leaking anyone’s location.
Last updated 2026-10-27. Difficulty: hard. Patterns: real-time, pub-sub, fan-out, geospatial, privacy. Reported at Meta, Snap, Google, Uber, Lyft, Grab.
The interviewer asks, the candidate answers and draws, and you press Next. Skip to any stage or key moment, and ask the AI Mentor anything along the way.
Or sit it in the style of Meta · Snap · Google · Uber, run as that company’s round is reported to go.
The short answer
How to design Nearby Friends in a system design interview, in five steps:
- Scope it. Opt in to share your live location with friends, see which friends are nearby, live refresh while the app is open, pause sharing, choose who sees you, and block, reduced precision, optional location history.
- Set the targets. Scale: 10 M concurrent · ~400 friends each; Freshness: interval 30 s · server path p99 < 1 s; Battery and data: ≤ 1 radio wake per interval; Privacy enforced on the server: block and pause effective < 2 s.
- Do the numbers. Location updates in: ~333 k/s peak · ~115 k/s avg; Deliveries out of pub/sub: ~13 M/s · ~1.3 M/s reach a phone; Subscriptions held: ~4 B · ~400 GB · 90 % silent.
- Draw the design. Edge load balancer, Connection servers, Session registry, Location cache, Pub/sub tier, Tier config, Load governor, Sharing service, Sharing store.
- Go deep where it is hard.
- Friends are the filter, not geography → A channel per user; each active viewer’s server subscribes to all their sharing friends; distance checked at the subscriber.
- Sharding and resizing the pub/sub tier → Consistent-hash ring over independent Redis primaries, versioned in etcd, resized by a staged dual subscription.
- Connections, reconnects and the first screen → Stateful WebSocket servers, a session registry with epochs, subscribe-then-snapshot, and a grace period before teardown.
- Latest location versus history → Redis with a 10-minute TTL for the latest location; an append-only stream into a wide-column store for history.
- Battery, bandwidth and throttling → Adaptive interval by motion and app state, set by the server in every ack, with pushes sent in the same radio burst as the uplink.
- Privacy enforced on the server → Audience computed on the server and enforced at subscribe, settings applied at publish, a separate channel for coarse viewers, acknowledged revokes and a check on every delivery.
Functional requirements
- Opt in to share your live location with friends. Off by default. While the app is open it reports a position about every 30 s; with a second opt-in, the OS sends occasional background updates. Sharing is between friends only, never with strangers.
- See which friends are nearby. Friends within a radius the viewer chooses (5 miles by default), sorted by distance, each with the distance and how fresh it is ("updated 20 s ago").
- Live refresh while the app is open. Entries move as friends move, about every 30 s. A friend who stops reporting drops off within 10 minutes without anyone deleting anything.
- Pause sharing. One switch, effective everywhere within a couple of seconds: friends see you disappear at once, not when a timer runs out.
- Choose who sees you, and block. All friends, or a chosen list. A block hides both people from each other, in the live list and in everything the other could read.
- Reduced precision. Per friend or for everyone: they see an approximate area (a fixed ~5 km cell) and a rounded distance, never your point. The server guarantees it, not the viewer’s app.
- Optional location history. Opt-in, kept for later features such as a timeline. The user can view it, choose a retention period and delete all or part of it, and a deletion must reach every copy, backups included.
- Out of scope. Map rendering, "Alice is nearby" notifications, ETAs and routing, the friend graph itself (an existing service we read), and showing strangers nearby, which we use as the contrast where a geo index is the right tool.
Non-functional requirements
- Scale (10 M concurrent · ~400 friends each). 1 B users, 100 M use the feature daily, 10 M have it open at the evening peak. Friend lists average ~400 and are capped at 5,000.
- Freshness (interval 30 s · server path p99 < 1 s). A friend’s move appears within one reporting interval plus under a second through our servers. This is the requirement that drives the hardest trade-off: halving the interval doubles both the battery cost on every phone and the fan-out, which is already the largest stream in the system.
- Battery and data (≤ 1 radio wake per interval). People uninstall location apps that drain the battery. Uplink and downlink share one radio burst, the interval stretches when the phone is still, and the background uses the OS’s low-power location service.
- Privacy enforced on the server (block and pause effective < 2 s). Audience, blocks, pause and precision are applied by our servers at publish and at subscribe, never by trusting the viewer’s app. A coarse viewer never receives a precise coordinate, not even inside a payload the app would hide.
- Availability (99.9 % · degrade to stale). Losing a location update is harmless, because the next one arrives within 30 s; losing a privacy change is not. So location delivery is best effort, and privacy changes are acknowledged and retried.
- Ephemeral live state, durable history (latest: TTL 10 min · history: opt-in, RF 3). The latest location is a cache that can be lost and rebuilt within one interval. History is the only durable record of where someone has been, so it exists only for users who turned it on.
- Deletion (reads < 1 min · backups < 30 days). Deleted history disappears from every read path within a minute and from backups within the stated window. Per-user encryption keys make the backup copies unreadable as soon as the key is destroyed.
Back-of-envelope estimates
- Location updates in: ~333 k/s peak · ~115 k/s avg. 10 M concurrent ÷ 30 s = 333 k/s at peak, an upper bound because still phones report less often. On average ~3.5 M users have it open (100 M daily × 5 sessions × 10 min ÷ 1 440 min): ~115 k/s. At ~100 B each the ingest is only ~33 MB/s at peak. Writes are not the hard part.
- Deliveries out of pub/sub: ~13 M/s · ~1.3 M/s reach a phone. Assume ~10 % of a user’s 400 friends are active at the same time at peak (friends share time zones and habits): 40. Each update is delivered to the servers of those 40: 333 k × 40 = 13.3 M deliveries/s. About 1 in 10 of those friends is within 5 miles, so ~1.3 M/s become pushes to a phone. Fan-out is the largest stream in the system, 40 times the updates.
- Subscriptions held: ~4 B · ~400 GB · 90 % silent. Every active user’s server subscribes to the channels of all ~400 friends, online or not: 10 M × 400 = 4 B subscriptions, at ~100 B of broker bookkeeping each ~400 GB. Only the 10 % to active friends carry traffic; the other 3.6 B are channels nobody publishes on, which cost memory and nothing else.
- Pub/sub nodes: ~100 primaries (CPU-bound). 13.3 M deliveries/s ÷ ~130 k/s per Redis-class node, kept under ~50 % CPU = ~100 nodes, each with a replica. Memory per node is 400 GB ÷ 100 = 4 GB, nowhere near a limit: deliveries size this tier, not subscriptions.
- Connection servers: ~400 at 25 k sockets each. 10 M connections ÷ 25 k per server = 400 servers, budgeted low because each socket brings work: 25 k × 40 ÷ 30 s = 33 k deliveries/s in per server, as many distance checks (under a microsecond each), ~3.3 k pushes/s out and ~830 publishes/s. The local subscription map is 25 k × 400 = 10 M entries, ~600 MB.
- Latest-location cache: ~1 GB · 333 k writes/s · ~7 M key reads/s. One entry per user who reported in the last 10 minutes, ~10 M at peak × ~100 B = ~1 GB. Writes are 333 k/s. Reads come from app opens: ~17 k/s at peak (next row) × 400 friends = ~6.7 M keys/s, sent as one MGET per shard. The cache is sized by operations, not memory: about 20 shards with replicas.
- Connect churn at peak: ~17 k opens/s · ~7 M subscribes/s. Little’s law: 10 M concurrent ÷ ~600 s per session = ~17 k connects/s. Each subscribes to ~400 channels: ~6.7 M subscribes/s, and as many unsubscribes as sessions end. That is half the delivery load again, which is why a session survives a quick app switch instead of being torn down.
- History volume: ~10 B points/day · ~500 GB/day. 100 M daily users × 5 sessions × 10 min × 2 updates/min = 10 B points/day, the same as 115 k/s × 86 400. At ~50 B per stored point that is ~500 GB/day, ~180 TB a year before replication, if every user turned history on. The opt-in, downsampling of still periods and retention limits make the real number smaller.
Components
- Mobile app (WebSocket · OS location APIs): Reads the position from the OS and reports it about every 30 s while open, stretching the interval when the phone is still and using the OS’s significant-change service in the background. Holds one WebSocket and renders the nearby list from pushes, computing "updated 20 s ago" from server timestamps. It enforces nothing: it only ever receives what this viewer is allowed to see.
- Edge load balancer (TLS · least connections): Terminates TLS and places new WebSocket connections by least connections, because sessions last minutes and round robin would leave newly added servers empty. Honours a resume hint so a quick reconnect lands back on the server that still holds the session. Routes the REST settings and history calls to the sharing service.
- Connection servers (stateful · 25 k sockets each): ~400 servers, each holding one WebSocket per active user plus that session’s state: the viewer’s latest position, their audience, and the last position of each active friend. On an update they apply the sharer’s settings, write the cache, publish once and append history; on each delivery from a friend’s channel they check permission and distance for the local viewer and push only what changed. Broker subscriptions are reference-counted per server, so two local viewers with a common friend cost one.
- Session registry (Redis · user → server, epoch): Maps a user to (server, connection, epoch), written on connect with an epoch from INCR, so a reconnect always supersedes the session before it. Instead of 10 M per-user TTLs refreshed by heartbeats, each server refreshes one liveness key every 5 s, and an entry that points at a dead server reads as absent. Read only for revokes and duplicate-session kicks, never on the location path.
- Location cache (Redis · TTL 10 min): The latest location per sharing user: loc:{user} holds the point, the coarse cell and the server timestamp, rewritten with EX 600 on every update, so users who stop reporting fall out without a cleanup job. ~1 GB in ~20 shards with replicas, sized by the ~7 M key reads a second that app opens cause at peak. Losing it costs one reporting interval, because every active user rewrites their entry within 30 s.
- Pub/sub tier (Redis pub/sub · ~100 primaries): One channel per sharing user, loc:{user}, plus loc:{user}:c carrying only the coarse cell. ~100 independent Redis primaries with replicas, placed on a consistent-hash ring by channel, so an update is one PUBLISH on one node delivered to the ~40 servers subscribed. Fire and forget on purpose: a lost delivery is replaced within 30 s, and anything that must not be lost travels another way.
- Tier config (etcd · ring vN · interval): The versioned pub/sub ring (primaries and their virtual nodes) and the fleet-wide reporting interval, watched by every connection server. A resize is a new ring version moved through stages (dual subscription, publisher switch, retirement), never an edit in place. Small, rarely changed and needing ordered, confirmed transitions across 400 servers, which is what a consistent store with watches is for.
- Load governor (control loop · every 10 s): Watches pub/sub CPU, delivery lag and connection-server queues every 10 s and raises the reporting interval in tier config when they cross a threshold, 30 s to 60 s for moving phones. Steps back down slowly with hysteresis so the fleet does not oscillate. The interval is the one lever that cuts updates, deliveries and battery use together.
- Sharing service (audience · settings · revokes): Decides who may see whom. A viewer’s audience is the friends whose sharing is on and not paused, whose audience includes the viewer, with no block either way, plus the precision each allows, cached per viewer with a version. On any change it writes the store first, then sends acknowledged revokes to the affected sessions found in the registry. Also serves the settings and history-deletion APIs.
- Sharing store (MySQL · sharded by user): The source of truth for privacy: sharing on or paused, default precision, audience mode, per-friend grants and blocks, each change versioned. A few hundred bytes per user and strongly consistent, because a lost or reordered block is an incident. Read mostly through the sharing service’s cache.
- Friend graph (existing service · cached): The company’s existing social graph: who is friends with whom, capped at 5,000 friends a person. We read it to build each viewer’s audience and invalidate cached audiences on its friendship-change events. It is the filter of this whole design: we never search by geography, only by this list.
- Update stream (Kafka · key = user · 3 days): Updates from users with history on, appended by the connection server after the publish, keyed by user id so one user’s points stay ordered on one partition. ~20 MB/s at peak. Three days of retention lets the history writer catch up after an outage and also bounds how long a deleted point can linger here.
- History writer (batched per user-day): Consumes the update stream and writes points in batches per user and day, minutes behind real time, which nobody notices. Drops points within ~20 m of the previous one while a user is still, encrypts each point under that user’s history key, and skips anything older than a user’s deletion watermark. Idempotent on (user, server timestamp), so a replay after a crash writes nothing twice.
- Location history (Cassandra · (user, day)): Append-only points partitioned by (user_id, day) and clustered by time: a day of one user is one partition, a range delete is a few partition deletes, and retention is a TTL. ~500 GB a day at the upper bound before replication factor 3. Points are encrypted under per-user keys held in a key service, so destroying a key makes every copy, backups included, unreadable at once.
User flows
- Open the app: first screen, then the stream. The start of a session. Alice must see her nearby friends within a second, although they will not report again for up to 30 s, and an update that lands while her list is being built must not be lost.
- The app opens a WebSocket through the load balancer and sends a hello with its first location fix. Least-connections placement, because sessions last minutes and a freshly added server would otherwise stay empty. The hello carries the first fix so distances can be computed at once instead of after the next 30 s cycle. If it carries a resume token for a server that still holds the session, the load balancer routes it there and the session reattaches without steps 2 to 5.
- The connection server registers the session under a new epoch. INCR gives an epoch that only grows. If Alice still has an older session on another server, that server is sent a kick and closes its copy instead of both publishing her location. Entries carry no per-user TTL: the owning server’s liveness key, refreshed every 5 s, decides whether an entry is live, which saves ~330 k TTL refreshes a second.
- The server asks the sharing service for Alice’s audience: the friends who share with her, and at what precision. The subscribe-side privacy check. A friend who paused, who shares with a list Alice is not on, or who is blocked in either direction is simply absent from the answer, so her server never subscribes to them or reads their location. The answer is cached per viewer with a version and rebuilt only when a friendship or a friend’s settings change.
- The server subscribes to the channel of every friend in the audience, before reading anything. Subscribe first, read second: an update that lands while the snapshot is being read is then in the snapshot or on the channel, never lost in between, and the newer timestamp wins. Channels are grouped by owning node from the ring in tier config, so ~400 channels over ~100 nodes is ~100 small SUBSCRIBE commands sent in parallel. Offline friends are subscribed too; their channels are silent and cost only memory.
- The server batch-reads those friends’ latest locations from the cache. One MGET per cache shard, ~20 in parallel, a few milliseconds. About 40 of the 400 keys exist, the friends who reported in the last 10 minutes; the rest have expired, which is how the TTL answers "who is active" without anyone tracking it. For a coarse friend the server keeps only the cell from the entry and discards the point.
- The server computes distances, keeps the friends inside Alice’s radius and sends the first screen. Forty distance checks take microseconds, so the list appears about 100 ms after the hello rather than after the next cycle. The server keeps those 40 positions in memory, so when Alice herself moves, her list is recomputed locally with no further reads.
- From now on, deliveries on the subscribed channels keep the list live. A friend who comes online later needs no new subscription: Alice’s server is already listening on their silent channel, so their first update simply arrives. Alice’s own updates write the cache and publish on her channel like everyone else’s.
- Bob moves: one publish, forty deliveries, four pushes. The core path, 333 k times a second at peak. An update is written once and published once, and the work that depends on the viewer (permission, distance, radius) runs on each viewer’s own server.
- Bob’s app sends a location update over its WebSocket. About 100 bytes every 30 s while he moves. The server timestamps it on receipt and rejects impossible jumps (faster than an airliner since the last fix), which stops cheap spoofing. The update doubles as the heartbeat, so a moving phone sends nothing else.
- Bob’s server applies his settings: paused stops here, and if anyone sees him coarsely it also computes his cell. Publish-side enforcement. Coarse means the fixed ~5 km geohash cell containing Bob, not his point plus random noise, because noise averages away over a hundred updates and a fixed cell says the same thing every time. His settings live in his session, loaded at connect and changed by revokes, so there is no lookup per update.
- The server overwrites Bob’s entry in the location cache with a 10-minute TTL. An overwrite, not an append: only the latest point matters here. The TTL is twice the longest interval a still phone uses (5 minutes), so someone who is merely sitting still never falls out, and someone who closed the app is gone within 10 minutes without a cleanup job.
- The server publishes the update once on Bob’s channel, and once more on his coarse channel if he has coarse viewers. The ring maps loc:{bob} to one of ~100 nodes, which delivers to every connection server subscribed: the servers of Bob’s ~40 active friends. Bob’s server neither knows nor cares who they are. A second channel for coarse viewers is the price of never sending his point to a server that only needs his cell.
- If Bob has history on, the server appends the point to the update stream. After the publish, never in front of it: the live list must not wait for history. Users with history off never reach this step, so no record of their movements exists beyond a cache entry that expires in 10 minutes.
- Each subscribed server checks, for its local viewers of Bob, that they may still see him, and computes the distance. The viewer’s latest position is already in memory, so this is one distance calculation, under a microsecond; the publisher would need 40 cache reads to do the same. Each viewer has their own radius and precision, which only their own server knows. A delivery not newer than the last one seen from Bob is dropped, so a late message cannot move him backwards.
- Only viewers for whom Bob is inside the radius, or has just left it, get a push. About 4 of the 40 deliveries become pushes. Hysteresis stops a friend on the boundary from flickering: enter at 5 miles, leave beyond 5.5. A viewer’s pushes are held for a few seconds and sent together, preferably right after that viewer’s own update, so their radio wakes once per interval.
- Minutes later, the history writer batches Bob’s points into his partition for the day. Batches per partition make the write cheap, and nobody reads history in real time, so minutes of lag cost nothing. Points within ~20 m of the previous one while Bob is still are dropped, which removes most of a night or a day at a desk.
- New Year’s Eve: concurrency doubles. Around midnight twice the usual peak opens the app at once. Deliveries are updates times active friends, and both double, so fan-out grows four times. The defence is the reporting interval, which the server controls.
- Concurrency doubles to ~20 M within the hour, and opens spike as everyone checks who is nearby. Updates double, and each user now has ~80 active friends instead of 40, so deliveries reach ~53 M/s at the upper bound against a pub/sub tier that does ~26 M/s at full CPU. Fan-out grows with the square of concurrency, which makes a busy night more dangerous than a year of growth. The connection tier was scaled out the week before, because the date is on the calendar.
- The load governor sees pub/sub CPU and delivery lag cross their thresholds. Lag is measured, not guessed: every delivery carries its publish time, and connection servers report its age on arrival. The thresholds sit below the point where broker output buffers grow, because a broker that falls behind disconnects slow subscribers, and that turns overload into a reconnect storm.
- The governor raises the reporting interval in tier config. Moving phones go from 30 s to 60 s; still phones are already on 5 minutes. Updates and deliveries both scale with 1 / interval, so this one change halves the load within a cycle, to ~27 M/s at the upper bound and well under it in practice. Users barely notice: a friend’s position is up to a minute old instead of 30 s.
- Connection servers pick up the change and hand each phone the new interval in its next ack. No push to 20 M phones is needed: every phone is about to send an update and reads next_in_s from the ack, so the whole fleet is on the new interval within 30 s. Old app versions that ignore the field are throttled on the server: an update that arrives early is acknowledged and dropped.
- Servers coalesce pushes harder and drop the subscriptions of viewers who sent the app to the background. A backgrounded app shows nothing, so after the 2-minute grace its server releases that viewer’s reference counts and stops checking distances for them. Foreground viewers get at most one frame every 10 s instead of every few seconds. Both cut work without making any visible list worse.
- As load falls, the governor steps the interval back down, one step at a time. Hysteresis: relax only after 15 minutes below a lower threshold, and by one step, so the fleet does not flip between 30 s and 60 s every minute. Applied per region, the same lever protects one region’s midnight without slowing the rest of the world.
- A connection server dies with 25 k users. The stateful tier’s worst ordinary day. 25 k phones lose their socket at once, the server’s subscriptions must disappear, and every user must get their list back without a thundering herd on the registry, the sharing service, the cache and the broker.
- ws-217 crashes; its broker connections close and its liveness key expires. Redis subscriptions belong to the connection that made them, so when the server’s ~100 broker connections close, its ~10 M subscriptions vanish by themselves: no cleanup job and nothing leaked. Its 25 k registry entries point at a server whose liveness key is gone within 15 s, so they read as absent.
- Phones notice the dead socket and reconnect with exponential backoff and jitter. A missing ack within ~10 s triggers the reconnect. Jitter spreads 25 k reconnects over ~30 s instead of one second; for a planned drain, the server sends a close frame with a suggested delay so the spread is chosen rather than random. The load balancer places them on the least-loaded servers.
- The new server writes the session with the next epoch. If ws-217 was only partitioned and comes back, it finds epoch 43 in the registry, newer than its own 42, and closes its copy instead of publishing Alice’s location twice. Epochs turn "which session is real" into a comparison of two numbers.
- It rebuilds the session: audience, subscriptions, then a snapshot from the cache. The same path as an app open, ~400 subscribes and ~400 key reads per user, so 25 k users cost ~10 M of each spread over 30 s, ~330 k/s, a few percent on top of the normal peak. Nothing is replayed: locations are state, not a log, so the latest entry replaces everything Alice missed.
- Alice’s friends notice nothing; her next update publishes from the new server. Publishers do not know where subscribers are and subscribers do not know where publishers are: both only know the channel. While Alice was away her cache entry stayed valid, so friends kept seeing her last position with an honest "updated 40 s ago".
- A revoke that was in flight to ws-217 is re-sent to wherever Alice is now. A block Bob issued during the outage was sent to ws-217 and never acknowledged, so the sharing service re-reads the registry and retries against ws-090. Losing it would also have been harmless here: the audience ws-090 just fetched already excludes Bob, because the store was written before any revoke was sent.
- Block, pause, and delete my history. The guarantees users actually care about. Each change is written to the store first and then pushed, with acknowledgement, to the servers that enforce it, because unlike a location update, a lost privacy change is an incident.
- Bob blocks Alice; the sharing service writes the block and bumps both audience versions. The store is written first, so any session built after this moment excludes Bob, whatever happens to the messages that follow. A block is symmetric: Alice disappears from Bob’s list as well as Bob from hers.
- It finds both live sessions in the registry and sends each server a revoke. Alice’s server releases its references to Bob’s channels, forgets his position and pushes "left"; Bob’s server does the mirror image. Revokes are RPCs with acknowledgement and retries, not pub/sub messages, because pub/sub may lose things and this must not be lost. The target is under 2 s end to end.
- Until the unsubscribe lands, Alice’s server drops anything from Bob by checking her audience version. Every delivery is checked against the viewer’s audience before the distance check, so a message already in flight on the old subscription is discarded. Enforcement never depends on the app: Alice’s phone receives nothing about Bob, rather than receiving it and hiding it.
- Carol pauses sharing; the sharing service records it and tells her server. Same order as a block: store first, so new sessions anywhere exclude her, then an acknowledged message to her live session. Pausing is per user rather than per pair, so it changes what her own server publishes instead of what each viewer’s server accepts.
- Her server stops publishing, deletes her cache entry and publishes a tombstone. Without the tombstone her friends would keep seeing her last position until the 10-minute TTL ran out, which is not what pause means. The deleted entry is the guarantee, because a friend who opens the app now reads nothing; the tombstone is best effort and simply makes the live lists drop her at once.
- Dave deletes his whole history; the service deletes his partitions and destroys his history key. A deletion watermark makes the history writer skip his points still in the 3-day stream. Partition deletes for each (user, day) remove the data from every read within seconds and compaction removes the bytes within days. Destroying his per-user key makes backup copies unreadable at once, and the backups themselves expire within 30 days, which is exactly what he is told.
Deep dives
Friends are the filter, not geography
Uber and Yelp put locations in a geo index and query a radius. Why not do that here, or simply have each phone ask for its friends’ locations every 30 seconds?
It looks like a proximity search, but the candidates are not "everyone near me", they are "my ~400 friends", of whom ~40 are active and ~4 are within 5 miles. Pulling means each of 10 M viewers reads 400 keys every 30 s: 133 M reads a second, nine in ten for friends who are not online. A radius query returns everyone nearby, tens of thousands of active strangers within 5 miles in a dense city, to be intersected with 400 friend ids, for each of 333 k updates a second.
The observation that shapes the answer: the friend list is small, changes slowly and is known before any location arrives, so it can be turned into standing subscriptions once per session. Geography changes every 30 s and differs for every viewer, so it belongs in the last step, a distance check on a handful of candidates, run where the viewer’s own position already is.
- A channel per user; each active viewer’s server subscribes to all their sharing friends; distance checked at the subscriber chosen
- Pull: read every friend’s latest location every 30 s situational: for the first screen of every session, and for the whole feature below ~100 k concurrent users, where it is the simplest thing that works
- Geo index of everyone (geohash cells), queried per update and intersected with friends situational: a "people nearby" feature for opted-in strangers, built as a channel per geohash cell that viewers subscribe to for the cells covering their radius
- Publisher pushes directly: look up each friend’s session in the registry and call their server rejected
The answer: One channel per sharing user, loc:{user}, and a second, loc:{user}:c, that carries only the fixed cell for coarse viewers. When a viewer connects, their connection server subscribes to the channel of every friend in their audience, online or not, and keeps a map from channel to local viewers with a reference count, so two local viewers with a common friend cost one broker subscription. An update is one PUBLISH; the broker delivers it to the ~40 servers with an active viewer; each server checks permission and distance for its own viewers and pushes only changes, with hysteresis at the radius. Pull survives in one place: the batch read of the cache that builds the first screen. The same shape, Redis pub/sub with a channel per user, is the design in Alex Xu and Sahn Lam’s System Design Interview, Volume 2, so expect an interviewer to know it and to probe the numbers rather than the idea.
Isn’t subscribing to 400 friends who are mostly offline wasteful?
It costs memory and nothing else. A channel nobody publishes on uses no CPU and no network, and the 3.6 B idle subscriptions are ~360 GB spread over 100 nodes, a few GB each. The alternative, subscribing only to friends who are online, needs a "friend came online" signal sent to every interested server, which is a second fan-out system with its own races. Paying memory to avoid a protocol is a good trade.
Why check the distance on the subscriber’s server rather than before publishing?
Because the subscriber’s server already holds what the check needs: the viewer’s own position, their radius and their permission for this friend. The publisher would need 40 viewers’ positions per update, 13 M cache reads a second, plus every viewer’s settings. The cost of checking at the subscriber is that the broker carries deliveries for friends who turn out to be far away; at 100 bytes each inside a data centre, that is the cheap side of the trade.
When is a geo index the right answer?
When the filter is geography, as in a "people nearby" feature for opted-in strangers. The work then mirrors this design: each update is published to the channel of its geohash-5 cell, about 5 km, and each viewer subscribes to the cells covering their radius, re-subscribing as they cross cell boundaries. Hot cells such as a stadium need sampling or a cap on what each viewer receives. The connection tier and the broker are shared; only the channel naming differs.
What if only 1 % of friends were active, not 10 %?
Deliveries would fall tenfold, to ~1.3 M a second, and the pub/sub tier would shrink to about ten nodes, but the design would not change. Subscriptions and connect churn stay the same, because they depend on how many friends you have, not how many are online. At lower activity the cost shifts toward opening the app, and the grace period that keeps sessions alive across app switches matters more.
A user has 5,000 friends and is at a festival with 300 of them. What breaks?
Not much, because friend lists are capped (Facebook caps them at 5,000). Opening the app costs 5,000 subscribes and key reads, twelve times normal, and each of their updates goes to ~500 servers, still only 17 deliveries a second. The visible problem is the list: 300 friends within 5 miles means ~10 pushes a second to one phone, so the server sends the nearest 50 with a count of the rest and coalesces to one frame every 10 s. If the product ever let millions follow one person’s location, that would be a broadcast channel needing replicated channels and coarse, delayed updates.
Sharding and resizing the pub/sub tier
You have 100 pub/sub nodes holding 4 billion live subscriptions. How are channels placed, and what happens when you need 30 more nodes?
Each update is one PUBLISH on the node that owns the publisher’s channel, so publishers and subscribers must agree on that owner without asking anyone. Classic PUBLISH in Redis Cluster is broadcast to every node, which multiplies 333 k publishes a second by the node count; sharded pub/sub (Redis 7) or a ring of our own over independent nodes avoids that. Kafka is the wrong broker here: it cannot hold a topic per user, and partitions keyed by user would make every connection server read nearly every partition to find its users’ friends.
What makes this tier unusual is that its state is live subscriptions held on behalf of other machines, not data. Moving a channel to another node means every server subscribed to it must be listening on the new node at the moment the publisher starts publishing there, and the broker keeps no buffer, so any misalignment drops messages. Adding 30 % more nodes moves ~23 % of channels, about 900 M subscriptions across all 400 connection servers.
- Consistent-hash ring over independent Redis primaries, versioned in etcd, resized by a staged dual subscription chosen
- Over-provision and resize rarely chosen
- Modulo placement (hash(channel) mod N) rejected
- Redis Cluster with sharded pub/sub, letting the cluster move slots situational: a smaller deployment, or a team that prefers an off-the-shelf broker and accepts a short gap for moved channels during a reshard
The answer: About 100 Redis primaries, each with a replica, kept at or under 50 % CPU at the normal peak so a doubled night fits after the governor acts. Channels are placed on a consistent-hash ring with ~200 virtual nodes per primary, stored in etcd as ring version N and watched by every connection server. A node failure does not change the ring: the replica is promoted into the same position, servers re-subscribe there and re-read the cache for the friends on that node to cover the seconds of lost deliveries. A resize is ring N+1 rolled out in stages: (1) servers subscribe on the new owner of every moving channel as well as the old one, paced over ~10 minutes, and report ready; (2) once all are ready, publishers switch to the new owner; (3) after two intervals, servers unsubscribe from the old owner. Duplicates during stage 2 are dropped because every delivery carries the update’s server timestamp.
A pub/sub node dies at peak. What do users see?
For about a second, nothing new from the 1 % of channels it owned. The replica is promoted into the same ring position, so no channel moves. Connection servers see their connection to that node drop, re-subscribe the affected channels on the promoted replica, and do one batch read of the cache for those friends, so anything published during the gap is recovered from the latest entry. At worst a friend’s "updated" age jumps by a few seconds.
Why not publish every update to two nodes so a failover loses nothing?
It would double the most expensive stream in the system to protect data that is replaced within 30 s anyway. The cache already holds everyone’s latest location, so recovering after a failure is a cheap read, not a replication problem. Spend redundancy on what cannot be recovered: privacy changes and history.
During the switch some publishers use the old node and some the new. Is anything lost?
No, because in stage 2 every subscriber of a moving channel listens on both. A delivery can arrive twice, once from each node, and the second is dropped because it is not newer than the last one seen for that friend. The rules are only about order: no publisher moves until every subscriber reports ready, and no subscriber leaves the old node until every publisher has moved, plus a margin.
How do you know the tier needs to grow before it hurts?
Track deliveries per node at peak against the per-node budget, and the trend in active friends per user, because deliveries are updates times active friends and both rise with concurrency. Alert when the forecast peak for the next quarter crosses 70 % of capacity and plan the resize for a quiet morning. Rising delivery lag and broker output buffers are the signs you waited too long.
Why keep the ring in etcd rather than in a config file?
Because 400 servers must move through stages in a known order and confirm each one, which needs a consistent store with versions, watches and somewhere to record each server’s readiness. A file pushed by a deploy cannot say "everyone is on stage 1 now". The data is tiny and changes a few times a year, which suits etcd exactly.
Connections, reconnects and the first screen
Your connection servers are stateful. What exactly lives on them, and what happens when a phone reconnects somewhere else?
A connection server here is not a thin gateway. It holds each viewer’s position, their audience, the last position of their active friends and the broker subscriptions behind them, and that state is what makes the subscriber-side distance check cheap. It also makes a reconnect expensive: an audience lookup, ~400 subscribes and ~400 key reads. With ~17 k opens a second at peak, connect churn alone is ~7 M subscribes a second, half the delivery load again.
The saving grace is that none of this state is precious. All of it can be rebuilt from the sharing service, the cache and the broker in about a second, and locations are state rather than a log: unlike chat, there is no backlog to replay, because the latest position replaces everything missed. So the goal is to make rebuilding correct and rare, not to make the state durable.
- Stateful WebSocket servers, a session registry with epochs, subscribe-then-snapshot, and a grace period before teardown chosen
- Stateless servers that keep viewer state in Redis rejected
- Server-Sent Events for pushes plus HTTPS POST for updates situational: a web client where WebSockets are blocked; the server-side design is unchanged
- Tear down and rebuild on every disconnect rejected
The answer: Phones hold one WebSocket to one of ~400 servers at 25 k connections each. On connect the server takes a new epoch from the registry, fetches the audience, subscribes to every friend’s channel and only then batch-reads the cache and sends the first screen, so nothing that happens during setup is lost. When the socket drops, the session waits for 2 minutes: a reconnect whose resume token routes it back to the same server reattaches and gets a fresh snapshot from memory, with no new subscriptions. After the grace, reference counts are released. A reconnect to another server rebuilds from scratch, and the old server learns from the kick or from the registry epoch that its copy is stale. Deploys drain ~2 % of servers at a time with suggested reconnect delays, which keeps the rebuild burst to a few percent of the normal peak.
What if two sessions for Alice are alive at once?
The one with the lower epoch loses. The new server kicks the old one when it writes the registry, and the old one checks the registry before acting on a session whenever its own connections recover. Until it closes, a stale session could publish Alice’s location twice, which is harmless because subscribers keep the newest timestamp. What it must never do is keep receiving what her current settings no longer allow, which is why revokes name an epoch.
Why subscribe before reading the cache rather than after?
Reading first leaves a window: Bob updates after the read but before the subscription exists, and Alice sees his older position until his next update 30 s later. Subscribing first closes it, at the cost of sometimes seeing the same update twice, from the cache and the channel, which the timestamp check sorts out. It is the general snapshot-plus-stream rule: start listening, take the snapshot, apply anything newer.
A region’s load balancer flaps and 3 M phones reconnect within a minute.
That is ~1.2 B subscribes and as many key reads, far beyond any normal minute. The defences are on both sides: phones back off with jitter and honour a server-suggested delay, servers admit a fixed number of handshakes a second and tell the rest to retry, and the sharing service answers audiences from its cache. Users see "reconnecting" for a minute instead of the broker falling over and turning one flap into ten.
Why not route location updates through the session registry, as WhatsApp routes messages?
Because a message has one recipient and a location update has forty. Routing by registry means forty lookups and forty RPCs per update, 13 M a second, for data nobody needs reliably. Here the registry is for the rare things that must reach one particular session, revokes and kicks, and channels handle the common case because they let a publisher ignore where its audience is.
How would you know this tier is healthy?
Connect and reconnect rates per server (a reconnect spike means a crash or a bad deploy), p99 time from hello to first screen, the share of reconnects that reattached within the grace period, subscriptions per server and the age of deliveries on arrival. End to end, test accounts that report fixed positions every 30 s and watch each other measure real freshness per region.
Latest location versus history
Why keep the current location in a cache with a TTL instead of a database, and why is history a separate store?
Two very different kinds of data share a name. The latest location is ~10 M small entries, overwritten 333 k times a second, read ~7 M keys a second when apps open, and worthless after a few minutes. History is up to ~10 B points a day appended for the retention the user chose, read rarely and by time range, and it is the most sensitive data in the system.
The insight is that expiry is the feature, not a cleanup chore. "Is this friend active?" is exactly "does their entry exist?", so a TTL replaces a presence service, status flags and a sweeper. And because the live path never reads history, history can be written asynchronously, minutes late, by a different system, without anyone noticing.
- Redis with a 10-minute TTL for the latest location; an append-only stream into a wide-column store for history chosen
- One database row per user updated in place, with a history table beside it rejected
- Write history synchronously in the update path rejected
- History kept only on the device situational: the product promises that the company never keeps location history, which some products do as a privacy position
The answer: Latest location: loc:{user} holds { lat, lng, cell, ts } in a Redis cluster of ~20 shards with replicas, written with EX 600 on every update; 600 s is twice the longest interval a still phone uses, so stationary friends stay listed and departed ones vanish. History exists only for users who turned it on: the connection server appends to a Kafka topic keyed by user id after publishing, and a writer batches points into Cassandra or Scylla partitioned by (user_id, day) and clustered by time, encrypted under a per-user key, with the user’s retention as a TTL. That is ~500 GB a day at the upper bound. Losing the cache is recovered within 30 s because every active user rewrites their entry; losing history is not recoverable, so it gets replication factor 3 and backups.
The whole location cache is lost. What happens?
Lists keep working for everyone already connected, because their servers hold friends’ last positions in memory and deliveries keep arriving. App opens see fewer friends for up to 30 s, until every active user’s next update rewrites their entry. Nothing that matters is lost, which is why the cache needs replicas to make failures rare but no persistence.
Why 10 minutes? Why not 60 seconds, two intervals?
Because the interval is not fixed. A still phone reports every 5 minutes and the governor can stretch everyone under load, so a 60-second TTL would make sitting-still friends flicker in and out. Ten minutes is twice the longest interval in use. The cost is that a friend who closed the app can stay listed for up to 10 minutes, labelled with their real age, unless they paused, which deletes the entry at once.
How do you answer "where was I last Tuesday" from this store?
One partition read, (user_id, that day), already ordered by time, decrypted with the user’s key. A week is seven partition reads in parallel. That is why the partition is user-day and not user alone: years of one user’s history would otherwise be one enormous partition, and deleting a range would be a scan instead of a few partition deletes.
Should every update go into history?
No. The writer drops points within ~20 m of the previous one while the user is still, which removes most of a night and most of a day at a desk, and stored precision only needs to be ~10 m. That cuts the upper bound substantially with no loss to any feature built on top. The live stream keeps full resolution because it is gone in 10 minutes.
Battery, bandwidth and throttling
A location update every 30 seconds for hours on end. How do you keep this from draining the phone, and what do you do when your servers are overloaded?
On a phone the cost of location sharing is not bytes, since an update and its pushes are a few hundred bytes. It is location fixes and radio wake-ups: each transfer keeps the cellular radio in a high-power state for several seconds afterwards, so five small pushes spread over 30 s cost far more than the same bytes sent together. A client that runs GPS continuously and accepts pushes whenever they come is the app people uninstall.
The same lever serves both sides. The reporting interval sets the battery cost on the phone and, through 1 / interval, the update rate and the delivery rate on every tier. If the server owns the interval, it can save batteries when nothing needs freshness and shed load when the fleet is overloaded, without shipping a client release.
- Adaptive interval by motion and app state, set by the server in every ack, with pushes sent in the same radio burst as the uplink chosen
- Fixed 30 s with GPS, always rejected
- OS significant-change updates only situational: in the background, for users who opted in to sharing while the app is closed
- Report quickly only while some friend is watching situational: for users with few friends, as a refinement: stretch the interval to 5 minutes while nobody is subscribed to their channel
The answer: In the foreground, a moving phone reports every 30 s from network or low-power location, which is accurate enough for distances rounded to a tenth of a mile; GPS is used only if the user opens a map. When activity recognition says the phone is still, it reports only after a move of ~100 m or every 5 minutes to keep its cache entry alive. In the background, sharing continues only with the second opt-in, through the OS significant-change service, one HTTPS POST each. The server sends next_in_s in every ack and holds a viewer’s pushes until just after that viewer’s own update, so the radio wakes once per interval, and the update doubles as the heartbeat. Under load, the load governor raises the interval in tier config, which halves updates and deliveries within one cycle.
How much data does this use?
About 100 B up and a few hundred down per 30 s, plus WebSocket and TLS framing: well under 100 KB an hour. Data is not the constraint, wake-ups are. That is why coalescing and piggybacking matter more than compression, and why a moving phone sends no heartbeats beyond its updates.
Old app versions ignore next_in_s and keep sending every 30 s during an overload.
The server enforces the interval anyway: an update that arrives before the session’s current interval has elapsed is acknowledged and dropped, with no cache write and no publish. Those phones waste their own battery but not our capacity. Retiring versions that ignore the field is the long-term fix.
Why not let each phone choose its interval from its battery level?
It should, within limits: a phone below 20 % can stretch its own interval and say so in its updates. But the server must keep the authority to slow everyone, because broker load is a property of the whole fleet that no single phone can see. The effective interval is the longer of the two.
What does a friend see when someone reports only every 5 minutes?
The same position with an honest age, "updated 3 min ago", which is correct for someone sitting still. The app shows the age rather than a spinner, so a slow report reads as stillness rather than a fault. When the person starts moving, activity recognition switches them back to 30 s within a fix or two.
Privacy enforced on the server
Bob blocks Alice, or shares only an approximate location with her. How do you guarantee her phone never receives more than it should?
Location sharing is acceptable only if its controls hold exactly. A block that takes a minute, a paused user who stays visible until a TTL expires, or a precise point inside a payload the app was meant to round, are each a safety problem, not a bug. And because the viewer’s app can be modified, nothing the app does counts as enforcement: what matters is which bytes leave our servers.
Two observations shape the answer. Enforcement must happen at both ends: at publish for the sharer’s own settings (pause, precision), and at subscribe for each pair (audience, blocks), because the broker delivers to whoever subscribed and cannot filter per viewer. And reduced precision is easy to get wrong: random noise averages away over many updates, and exact distances can be trilaterated by a viewer who moves or fakes their own position, as public research on dating apps has shown.
- Audience computed on the server and enforced at subscribe, settings applied at publish, a separate channel for coarse viewers, acknowledged revokes and a check on every delivery chosen
- Send everything to the app and let it filter rejected
- Random noise added to each published point rejected
- End-to-end encrypted locations, with the server relaying ciphertext situational: a product whose promise is that the company never sees locations; distance and radius checks then move to the viewer’s phone
The answer: The sharing service alone decides who sees whom: a viewer’s audience is the friends whose sharing is on and not paused, whose audience includes the viewer, with no block in either direction, plus the precision each allows. Connection servers subscribe and read the cache only for that audience, and only the coarse channel for coarse friends. Coarse means the fixed ~5 km geohash cell, with distance computed from the cell centre and shown in buckets such as "~3 mi". Every change writes the store first, bumps a version, then sends acknowledged revokes to the live sessions found in the registry, and every delivery is checked against the viewer’s audience version before use. Pause deletes the cache entry and publishes a tombstone. History deletion removes (user, day) partitions, and destroying the user’s history key makes backup copies unreadable at once.
The revoke to Alice’s server is lost. Does Alice keep seeing Bob?
Not for long, and never in her next session. The sharing service retries until the server acknowledges or the registry shows no live session, and it alerts on revokes unacknowledged after a few seconds. Until then Alice’s server still holds the old audience, which is the one real window, and that is why revokes are acknowledged RPCs rather than pub/sub messages and the target is under 2 s. Any session built after the store write already excludes Bob.
Alice spoofs her own location to trilaterate Bob. How do you stop it?
In layers. Distances to precise friends are rounded to a tenth of a mile and floored at "< 0.5 mi", and for coarse friends they come from the cell centre, so even perfect trilateration finds only the cell. The server rejects impossible jumps in Alice’s own reports, which makes walking a fake position around a target slow, and it caps how many distinct positions a viewer can report per hour, which defeats automated probing.
Why a fixed cell rather than a blurred point?
A fixed cell leaks nothing new over time: Bob is somewhere in dr5ru, and a thousand updates say the same thing. A blurred point is a fresh random sample around the truth each time, and the average of many samples converges on the truth. The one leak a fixed cell has is the moment of crossing a boundary, which shows Bob is near that edge; delaying the cell change by a random few minutes blunts it.
A user asks to delete all their history. What actually happens?
The API sets a deletion watermark, so the history writer skips their points still in the 3-day stream. Partition deletes for every (user, day) remove the data from every read within seconds, and compaction removes the bytes within days. Destroying the user’s history key makes the copies in backups unreadable immediately, and the backups expire within 30 days. The user is told exactly that, because promising instant physical erasure would not be true.
Can employees look up where someone is?
Not through any ordinary tool. Production access to the cache and to history goes through approved, audited break-glass access, history is encrypted under per-user keys whose use is logged, and analytics see only aggregated, coarsened data. Insider access is a real threat for location data, so it is designed in rather than left to policy.
The theory behind it
- Location tracking at scale: Ingesting and serving live GPS locations for drivers, couriers and devices: update frequency and battery, the ingest path, latest-location stores versus history, geospatial indexing of moving objects, sharing a live location with riders, map matching, geofencing, privacy and retention.
- Real-time systems: WebSockets, SSE and push: Long polling vs Server-Sent Events vs WebSockets, connection servers and registries, pub/sub fan-out, presence, reconnection and resync, and scaling to millions of connections.
- Presence and managing millions of connections: Holding millions of persistent WebSocket connections and showing who is online: connection gateways, routing messages to the right server, heartbeats, presence with TTLs, fan-out of status changes, typing indicators, reconnect storms and deploys.