Design friends nearby
The interviewer asks
“Design a 'Nearby Friends' feature for a social app. Users who opt in can see which of their friends are within a few kilometres, with an approximate distance that updates every few seconds while the app is open.”
What a strong answer looks like
A strong answer notices that this is not a 'search a geo index' problem: the candidate set is already known (your friends, a few hundred people), so the hard part is pushing frequent location updates to the right friends cheaply. Clients keep a WebSocket open and send a location every ~30 seconds. The server stores the latest location in Redis with a TTL, appends it to a history store asynchronously, and publishes it on a pub/sub channel for that user. Each WebSocket server subscribes to the channels of the online friends of its connected users, computes the distance on receipt and pushes only friends inside the radius. The deep dives are the pub/sub fan-out and how to shard and resize it, why per-user channels beat a geo index here, the update interval versus battery and cost, and privacy (opt-in, coarse location, blocking, TTL so stale positions vanish).
Concepts to mention
1 Requirements
Functional
- Users opt in to share location with friends; they can turn it off at any time.
- Show the list of friends within a radius (default 5 km) with approximate distance and 'last updated' time.
- The list updates live (within ~30-60 seconds) while the screen is open.
- Friends whose location is older than ~10 minutes are hidden.
- Blocking or unfriending someone stops sharing immediately.
- Store location history for opted-in users (for features and abuse investigation), with a retention limit.
Non-functional
- Scale: 100M users have the feature; ~10M are concurrently online with it open at peak.
- End-to-end delay from a friend's move to my screen under ~5 seconds after the update is sent.
- Location data is never shown to non-friends; precision is coarsened (e.g. to ~100-500 m).
- Availability 99.9%; a lost update is acceptable because the next one arrives in 30 seconds.
- Battery friendly: no continuous GPS; updates only while the app is in foreground (plus optional low-rate background).
- Comply with data-protection law (India's DPDP Act): explicit consent, deletion on request, bounded retention.
Out of scope (say it out loud)
- Finding nearby strangers (that is a geo index problem; mention only).
- Friend graph management (assume a friends service exists).
- Turn-by-turn directions to a friend.
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
WS/v1/nearby (client → server: {lat, lng, accuracy, ts}) | Client sends its location every 30 s over a persistent WebSocket. |
WS/v1/nearby (server → client: {friend_id, distance_m, updated_at}) | Server pushes a friend's distance when it changes or the friend enters/leaves the radius. |
GET/v1/nearby/friends?radius_m=5000 | Initial snapshot when the screen opens: friends in radius from the location cache. |
PUT/v1/me/location-sharing | Opt in/out, choose audience (all friends or a list) and precision. |
4 Data model
location_cacheRedis (cluster)key loc:{user_id} → {latlngts}; TTL 10 minOnly the latest position. TTL makes inactive users disappear without cleanup jobs. Sharded by user_id; losing it is fine, it refills in 30 s.
location_historyCassandraPK (user_id, day)clustering ts DESC; latlngaccuracyAppend-only, write-heavy, time-ordered per user. TTL on rows enforces retention (e.g. 30 days).
sharing_settingsPostgres (cached in Redis)user_id (PK)enabledaudience (all|list)precision_mupdated_atSmall, read on every fan-out decision, so cached; changes invalidate cache and trigger channel unsubscribes.
friend_graphExisting friends service (graph store)user_idfriend_idstatus (friend|blocked)Read when a user comes online to know which channels to subscribe to; block events delivered via Kafka.
pubsub_channelsRedis Pub/Sub cluster (separate from cache)channel loc:{user_id}; subscribers = WebSocket serversChannels are cheap and in-memory; a hash ring maps channel → pub/sub node.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Open the Nearby screen
- 1App opens a WebSocket through the L4 load balancer to any WebSocket server and sends its current location.
- 2The server stores loc:{me} in the Redis cache with a 10-minute TTL.
- 3It asks the friends service for my friend list (~400), filtered by sharing settings (who shares with me).
- 4It batch-reads (MGET) those friends' locations from the cache, drops missing or stale ones, computes the haversine distance and returns friends within 5 km as the initial snapshot.
- 5It subscribes to the pub/sub channel loc:{friend_id} for each friend who is online (has a cache entry), using the hash ring to find the right pub/sub node.
A friend moves
- 1Friend's app sends {lat, lng, ts} every 30 s on its own WebSocket server.
- 2That server updates loc:{friend} in the cache, publishes the update on channel loc:{friend}, and emits an event to Kafka for history.
- 3Every WebSocket server that subscribed to loc:{friend} receives the message once (one message per server, not per user).
- 4For each local connection that is a friend of the sender, the server checks settings, computes the distance and pushes {friend_id, distance_m} only if inside the radius or if the friend just left it.
- 5The history writer consumes from Kafka and appends to Cassandra in batches.
Block a friend
- 1User blocks a friend; the friends service writes the change and publishes a FriendshipChanged event to Kafka.
- 2WebSocket servers holding either user consume it, unsubscribe the channel and push a 'remove' to the client.
- 3Future fan-out checks the cached settings and graph, so even a missed event is caught on the next update.
7 Deep dives
Why per-user pub/sub channels, not a geo index
The first instinct is to put everyone in a geohash or H3 index and query 'users near me', then filter to friends. That is wrong here. The candidate set is already small and known: my ~400 friends. A geo query returns thousands of strangers in a dense area like Bengaluru and throws almost all away. Instead, give every active user a pub/sub channel. When I come online, my WebSocket server subscribes to the channels of my online friends. When a friend publishes, the message reaches only servers that care, and the server does a cheap distance check per local subscriber. Cost is proportional to friendships between online users, not to population density. A geo index (geohash prefixes per cell channel) becomes the right choice only when the audience is unknown, such as 'people nearby' discovery; then you subscribe to the channels of your cell and its 8 neighbours. Saying both, and why you picked one, is the key signal.
Scaling and resizing the Redis pub/sub layer
At ~13M deliveries/s the bottleneck is CPU on the pub/sub nodes, not memory. A single Redis node can push a few hundred thousand messages/s, so plan for ~100 nodes with headroom. Channels are sharded with consistent hashing: channel loc:{user_id} → node via a hash ring stored in etcd, which every WebSocket server watches. Note that Redis Cluster's own sharded pub/sub (SPUBLISH, Redis 7) also works, but classic PUBLISH in Redis Cluster broadcasts to every node and does not scale. Resizing is the delicate part: pub/sub is fire-and-forget, so moving a channel loses messages for a moment. Do it in a quiet hour: update the ring, servers re-subscribe moved channels on the new node, and unsubscribe from the old one. Losing one or two 30-second updates is acceptable for this product, which is why stateless pub/sub is a good fit. Do not use Kafka for this fan-out: millions of tiny per-user topics are not what Kafka is for, and its per-partition ordering and retention add cost without value.
Update interval, battery and cost
Cost scales linearly with update frequency: going from 30 s to 5 s multiplies location QPS (333k to 2M/s) and fan-out (13M to 80M/s) by 6. Pick 30 s while the screen is open and stop or slow down (e.g. every 5-15 minutes using the OS significant-change API) in the background. The client can also skip sending when it has moved less than ~50 m since the last update, which removes most updates from people sitting at home or office; the server refreshes TTL with a cheap heartbeat instead. On the receive side, push only meaningful changes: a friend entering or leaving the radius, or a distance change of more than ~10%. Rounding distance ('about 2 km away') reduces both privacy risk and push volume. On Tier-2/3 networks with patchy 4G, the client should reconnect with jittered backoff and send its latest position on reconnect, not a backlog of old points.
Privacy and correctness of 'who can see me'
Location is sensitive data, and mistakes here are reputational incidents. Make sharing opt-in with an explicit audience, and enforce it on the server at fan-out time, not only when subscribing, because settings can change between subscribe and publish. Keep a small cached settings map per user (enabled, audience list version) and check it per delivery. Coarsen location before it leaves the server: snap to a grid of ~100-500 m or add bounded noise, and show 'about 2 km' instead of exact coordinates, so repeated readings cannot triangulate a home. Block and unfriend events must take effect within seconds: deliver them via Kafka to the servers holding either user and unsubscribe immediately. Location TTL means a user who closed the app vanishes after 10 minutes. History has a short retention enforced with Cassandra TTLs, and a deletion request removes the partition. Under India's DPDP Act you need clear consent and purpose limitation, so do not reuse this history for ads without separate consent.
8 Trade-offs
Push via pub/sub vs clients polling
Polling '/friends nearby' every 30 s from 10M clients means 333k reads/s, each doing an MGET of ~400 keys: 130M key reads/s, mostly unchanged data. Pub/sub moves only actual changes and keeps the cache read path for the initial snapshot. Polling is acceptable for an MVP with a few thousand users; at scale, push wins on cost and latency.
Redis pub/sub vs Kafka vs a custom router
Redis pub/sub: in-memory, tiny channels, no persistence, which matches 'latest position only' semantics. Kafka: durable and replayable, but per-user topics are impossible and per-partition routing would make every server read everyone's updates. A custom routing tier (like Facebook's internal systems) can be more efficient but is a big build. Choose Redis pub/sub and say it is acceptable to lose an update.
Exact vs approximate distance
Exact coordinates enable a map view but increase privacy risk and push volume. Approximate distance bands ('<1 km', '1-3 km', '3-5 km') hide precise location and need pushes only on band changes. Default to approximate; offer a precise live share only as an explicit, time-limited action.
9 Bottlenecks
Celebrity or very social users
A user with 5,000 friends online makes one publish fan out to thousands of servers' subscribers. Since one message goes to each subscribing server, not each user, the cost is bounded by the number of WebSocket servers (~200), which is fine. Cap the feature to a friend-count limit or show only close friends for very large graphs.
Pub/sub CPU at 10x
At 100M concurrent users, fan-out reaches ~130M deliveries/s. Reduce work before adding nodes: skip unchanged locations on the client, push only band changes, raise the interval to 60 s in the background, and route with sharded pub/sub so each node handles only its channels. Then scale the ring horizontally.
Reconnect storms
After a WebSocket server deploy or crash, 100k clients reconnect at once and each triggers friend-list reads and ~40 subscribes. Use jittered client backoff, drain servers slowly during deploys, and cache friend lists for online users so reconnects do not hammer the friends service.
10 Failure handling
A pub/sub node dies
Channels on that node stop delivering. WebSocket servers detect the dropped connection, the ring in etcd is updated to a replacement node, and servers re-subscribe their affected channels. Users see friends' positions freeze for up to a minute; the 10-minute TTL in the cache keeps the snapshot valid meanwhile.
Location cache node lost
Positions on that shard are gone, so some friends temporarily disappear from snapshots. Within 30 s every active user sends a fresh update and the cache refills. No replication is strictly needed, though a replica makes failover quieter.
History pipeline slow
Kafka buffers events; the live path does not depend on history writes, so users notice nothing. Alert on consumer lag and scale the history writers or Cassandra.
11 What impresses
- Explains why a geo index is wrong when the audience is your friend list, and when it becomes right (nearby strangers).
- Computes fan-out (updates/s × online friends) and identifies pub/sub CPU as the bottleneck.
- Knows classic Redis Cluster PUBLISH broadcasts to all nodes, and uses sharded pub/sub or an external hash ring.
- Treats losing an update as acceptable and designs for it rather than paying for durable delivery.
- Reduces work at the source: skip small moves, push only radius or band changes.
- Enforces privacy at fan-out time and coarsens coordinates to prevent triangulation.
12 Mistakes
- Using a geo query over all users and then filtering to friends.
- Storing every location update in a relational database on the hot path.
- Kafka topic per user, or one Kafka topic that every server consumes fully.
- Ignoring that settings and blocks can change after a subscription is created.
- No TTL, so people who closed the app hours ago still appear nearby.
- Updating every second without computing what that costs.
13 Follow-ups
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.