← System Design Simulator

Nearby Friends System Design Interview Question

By Rahul Kumar · Senior Software Engineer · Updated · 9 components · 3 operations ·Source: Alex Xu, System Design Interview Vol 2, Chapter 2

Problem: Design a system like Facebook Nearby Friends / Snap Map that shows you which of your friends are nearby in near-real-time.

Overview

Nearby Friends is the real-time cousin of a proximity search. Instead of 'what restaurants are near me', it asks 'which of my friends are within a mile, right now, with their consent'. Facebook shipped this feature in 2014 and Snap Map, Apple Find My, and Life360 all run variations of the same design. The product is a continuous stream of location pings, one every few seconds per active user, fanned out only to that user's explicit list of friends, filtered by a privacy mask that can flip to invisible instantly. The system therefore marries three hard problems: a high-throughput write path (millions of pings per second), a pub/sub fanout that is cheap per subscriber, and a geo-aware filter that drops pings for friends who are too far away. This SEO supplement deepens the widget with Java sketches for the WebSocket ingestion handler, a privacy-aware fanout topic, and an in-memory quadtree that a single region server uses to answer 'who is within five kilometers' in constant time.

Nearby Friends — Interactive Simulator

Runs fully client-side in your browser; no sign-up. Or open full screen →

Launch the interactive walkthrough for Nearby Friends — animated architecture diagram, step-by-step flow with real payloads, component swap, and a discrete-event stress simulator.

Summary

A location-sharing system where each client publishes its GPS coordinates every ~30 seconds over a long-lived WebSocket and receives updates for friends that have opted in. The dominant design choice is an asymmetric fan-out: writes are O(1) into Redis GEO, reads are a pub/sub fan-out driven by the friend graph, with Kafka as the durable backbone between location publishers and per-subscriber WebSocket senders. The main tradeoff is freshness vs cost — publishing every 30s is 6x cheaper than every 5s but shifts the UI toward 'approximate nearby' rather than 'live'. Sized for ~200M DAU and ~100M concurrent WebSocket connections.

Requirements

Functional

Non-functional

Capacity Assumptions

Back-of-Envelope Estimates

High-level architecture

The ingestion layer is a farm of WebSocket servers, each sticky to a user via consistent hashing on user_id. When a client pings, the handler validates the coordinate, stamps the server time, writes a 'last-seen' record to Redis with a 30 s TTL, and publishes the ping to a per-user fanout topic on a Redis Pub/Sub or Kafka cluster. Every user who subscribes to my live location is registered on the fanout topic keyed by my user_id. A small co-resident service, the Fanout Router, reads the topic, pulls my friend-graph slice for just those subscribers, applies the privacy mask (block list, invisible flag, distance limit), and hands each delivery to the WebSocket server that owns that friend's session. Each region also maintains an in-memory quadtree of currently-online user points; this index backs the 'nearby stranger' features and lets the router shortcut the distance check in micro-seconds. A Presence service feeds session up or down events to the router so vanished users get pruned. The authoritative friend graph and privacy settings live in a sharded relational database and are hot-cached per connection at WebSocket upgrade time. The whole pipeline is regional; cross-region friends flow through a mesh gateway that respects per-region privacy overrides. Back-pressure is enforced by dropping pings, never queueing, so a misbehaving client can never pile work on the server.

Architecture Components (9)

Operations Walked Through (3)

Implementation

WebSocket handler with privacy filter
@Component
public class LocationSocketHandler extends TextWebSocketHandler {
    private final PrivacyService privacy;
    private final FanoutPublisher fanout;
    private final Quadtree regionIndex;

    @Override
    public void handleTextMessage(WebSocketSession session, TextMessage msg) throws IOException {
        Ping ping = Json.read(msg.getPayload(), Ping.class);
        long userId = (long) session.getAttributes().get("userId");
        if (privacy.isInvisible(userId)) {
            return; // silently drop
        }
        if (Math.abs(ping.lat()) > 90 || Math.abs(ping.lon()) > 180) {
            session.sendMessage(new TextMessage("{\"err\":\"bad_coord\"}"));
            return;
        }
        long ts = System.currentTimeMillis();
        regionIndex.upsert(userId, ping.lat(), ping.lon(), ts);
        fanout.publish(userId, new LocationEvent(userId, ping.lat(), ping.lon(), ts));
    }
}
Fanout publisher / subscriber pair
public class FanoutPublisher {
    private final JedisCluster redis;

    public void publish(long userId, LocationEvent ev) {
        redis.publish("loc:" + userId, Json.write(ev));
    }
}

public class FanoutRouter implements MessageListener {
    private final FriendGraph graph;
    private final PrivacyService privacy;
    private final SessionRegistry sessions;

    @Override
    public void onMessage(Message m, byte[] pattern) {
        LocationEvent ev = Json.read(m.getBody(), LocationEvent.class);
        for (long friendId : graph.friendsOf(ev.userId())) {
            if (!privacy.visibleTo(ev.userId(), friendId)) continue;
            WebSocketSession s = sessions.find(friendId);
            if (s == null || !s.isOpen()) continue;
            try {
                s.sendMessage(new TextMessage(Json.write(ev)));
            } catch (IOException e) {
                sessions.drop(friendId);
            }
        }
    }
}
In-memory quadtree for a single region
public class Quadtree {
    private final Node root;

    public Quadtree(BoundingBox regionBounds) {
        this.root = new Node(regionBounds, 0);
    }

    public void upsert(long userId, double lat, double lon, long ts) {
        root.insert(new Point(userId, lat, lon, ts));
    }

    public List<Point> within(double lat, double lon, double radiusM) {
        BoundingBox box = BoundingBox.around(lat, lon, radiusM);
        List<Point> out = new ArrayList<>();
        root.query(box, out);
        return out;
    }

    static class Node {
        static final int CAPACITY = 16;
        final BoundingBox bbox;
        final int depth;
        List<Point> points = new ArrayList<>();
        Node[] children;
        Node(BoundingBox b, int d) { this.bbox = b; this.depth = d; }

        void insert(Point p) {
            if (!bbox.contains(p)) return;
            if (children == null && points.size() < CAPACITY) { points.add(p); return; }
            if (children == null) subdivide();
            for (Node c : children) c.insert(p);
        }

        void query(BoundingBox b, List<Point> out) {
            if (!bbox.intersects(b)) return;
            for (Point p : points) if (b.contains(p)) out.add(p);
            if (children != null) for (Node c : children) c.query(b, out);
        }

        private void subdivide() {
            children = bbox.split(depth + 1);
            List<Point> old = points;
            points = new ArrayList<>();
            for (Point p : old) for (Node c : children) c.insert(p);
        }
    }
}

Key design decisions & trade-offs

Interview follow-ups

Related