Social & Feed
Design LinkedIn MutualConnection Search
01
The problem
The problem
Given two members A and B, return how many first-degree connections they share and, on request, who those people are, ordered so the most relevant ones come first. The same answer is needed in several places, each with its own shape of traffic.
One viewer, one profile: count plus three faces. A single pairwise query on the critical path of the page.
One viewer against 25 results at once. This batch query is the busiest and must finish in one round trip.
Clicking the count opens a paginated list of shared connections, sorted by relevance to the viewer.
Whether someone is a 1st, 2nd or 3rd+ degree connection. A 2nd-degree badge is simply "at least one mutual exists".
Storing a count for every pair of members who share anyone would mean trillions of rows, most of them never read, and every new connection would update thousands of rows at once. The design computes intersections at read time and makes that computation very cheap, caching only the pairs that are actually asked for.
02
What the service must do
What the service must do
- Return the exact mutual count for a pair of members when it is under 1,000, and a rounded figure such as "1,000+" above that.
- Return counts for one viewer against up to 50 targets in a single call.
- Return a page of mutual connections, ranked by how close each one is to the viewer.
- Return the connection degree (1st, 2nd, 3rd+) for any pair.
- Reflect new connections and removals within a few seconds for the people involved.
- Respect privacy: members who hide their connection list do not appear in other people's mutual lists.
- Batch of 25 answered at p99 under 40 ms; a single pair under 10 ms.
- Peak load of about 2 million pairwise intersections per second.
- Counts may be stale by seconds; a missing mutual is acceptable briefly, a hidden member leaking never is.
- Very highly connected members (recruiters, influencers) must not slow down queries for everyone else.
Followers (one-directional edges), recommending new connections, and full graph search such as "show me everyone two hops away who works at Acme". Those reuse the same graph store but need different algorithms.
03
Sizing the graph
Sizing the graph
Assume 900 million members with a heavily skewed degree distribution: a median of about 60 connections, a mean near 100, and a long tail capped by the product at 30,000. Edges are undirected, so each one is stored twice.
| Quantity | Working | Result |
|---|---|---|
| Undirected connections | 900M x 100 mean / 2 | 45B |
| Stored adjacency entries | 45B x 2 directions | 90B |
| Raw list size | 90B x 4 B member IDs | 360 GB |
| Compressed lists | delta plus varint encoding, about 1.6 B per entry | about 145 GB |
| With 3 replicas in memory | 145 GB x 3 | about 435 GB of RAM in total |
| Search pages per second | peak | about 60K |
| Intersections from search | 60K x 25 results | 1.5M / s |
| Profile views and cards | peak | about 500K / s |
| Median intersection cost | merge of 60 and 400 entries | well under 5 microseconds |
| Connection changes | peak writes | about 20K / s |
Under half a terabyte of RAM holds every adjacency list three times over. That is a few dozen servers, which means the main design problem is not storage but how to route and batch intersections.
Intersecting a 60-entry list with a 30,000-entry list by plain merge walks 30,060 entries. A few such pairs per batch can cost more than all the others combined, so the algorithm must adapt to the size ratio.
04
API design
API design
05
Architecture overview
Architecture overview
flowchart LR
S["Search service"] --> MS["Mutual service"]
MS --> PC[("Pair count cache")]
MS --> R["Router: member to shard"]
R --> G1["Graph shard 1"]
R --> G2["Graph shard 2"]
R --> G3["Graph shard N"]
G1 --> MS
CS["Connections service"] --> DB[("Source of truth: edges table")]
DB -. "change log" .-> K[["Kafka: edge events"]]
K --> G1
K --> G2
K --> G3
K --> PCThe source of truth for connections is an ordinary sharded database owned by the connections service, which handles invitations, acceptance and removal. It is never queried for intersections. Its change log feeds a graph serving tier: a fleet of in-memory shards, each holding the full adjacency lists for a slice of members, partitioned by a hash of member ID and replicated three times.
The mutual service is the only entry point for reads. For a batch, it first fetches the viewer's own list, from cache or from the viewer's shard, then groups targets by the shard that owns them and sends one request per shard carrying the viewer's list and the targets on that shard. Each shard intersects locally against its own lists and returns counts. The service merges the replies and applies privacy filtering for any preview faces.
This is the key layout choice: ship the smaller side, the viewer's list, to where the many target lists already live. Shipping 25 target lists to one place would move far more bytes.
Each holds sorted, compressed adjacency arrays in memory, about 10 GB per shard. Lookups are an array index by local member ID, with no network hop inside an intersection.
Stateless. Plans each batch, scatters it to the right shards with a deadline, gathers results and returns partial answers rather than waiting on a slow shard.
A small cache of recently asked pairs with a short TTL. Search pages repeat often as people page back and forth, so even a modest hit ratio removes real work.
Every add or remove is applied to both members' lists on their shards, and invalidates cached counts involving either member.
06
Deep dive: intersecting lists in microseconds
Deep dive: intersecting lists in microseconds
Everything hinges on one function: given two sorted integer lists, count the shared elements as fast as possible. The best algorithm depends on how different the two lengths are, so the engine chooses per pair.
Choosing an intersection algorithm per pair
When the lists are similar in size, a linear merge is hard to beat: two pointers, advance the smaller value, count matches. It reads both lists once in order, which suits CPU caches and can be vectorised with SIMD to compare several values per instruction.
When one list is far larger, merging wastes time stepping through entries that cannot match. Instead, take each element of the small list and search for it in the large one. Plain binary search costs log of the large size per element. Galloping search is better: because both lists are sorted, each search starts where the last one ended and doubles its step until it overshoots, then binary searches inside that window. The cost grows with the small list times the log of the gap between matches.
A simple rule works well in practice: if the larger list is more than about 30 times the smaller, gallop; otherwise merge. For the rare pair where both sides are huge, say two recruiters with 25,000 connections each, use a bitmap representation of the larger list (compressed bitmaps such as Roaring) and test membership in constant time.
flowchart TD
IN["Viewer list plus targets on this shard"] --> LOOP["For each target"]
LOOP --> BF{"Target filter says no overlap?"}
BF -->|yes| ZERO["Count is 0, skip"]
BF -->|no| SZ{"Size ratio above 30?"}
SZ -->|yes| GAL["Gallop small list into big list"]
SZ -->|no| MER["SIMD merge of both lists"]
GAL --> CAP{"Count above 1,000?"}
MER --> CAP
CAP -->|yes| ROUND["Report 1,000 plus"]
CAP -->|no| OUT["Report exact count"]Keeping sorted lists writable
Sorted, delta-encoded arrays are perfect for reading and awkward for writing, because inserting one ID in the middle means re-encoding the block it lands in. The fix is the same one log-structured stores use. Each member's list is split into blocks of about 128 IDs, each block encoded on its own, so an insert rewrites one small block rather than the whole list. Very recent changes go first into a tiny delta buffer per member: a sorted set of additions and a set of removals. Intersections read the main blocks and the buffer together, and a background task folds the buffer into the blocks once it passes a few dozen entries.
Removals deserve care. If a removal arrives before the matching addition, because events were replayed out of order, a naive shard would resurrect the edge. Each edge event carries a version from the source database, and the shard keeps the latest version per pair long enough to ignore anything older.
A fast path for zero
On a typical search page, many results are strangers with no shared connections at all. Each shard keeps a Bloom filter per member over that member's connection IDs. Before intersecting, probe the target's filter with a handful of the viewer's entries, or for a short viewer list, all of them. If every probe says "definitely not present", the count is zero and the shard skips the intersection. A Bloom filter never gives a false negative, so a "no" is always correct; a "maybe" falls through to the real intersection.
Sizing is a trade between memory and how often the shortcut works. At about 10 bits per entry with seven hash functions, the false positive rate is under 1 percent. For 90 billion entries that is roughly 110 GB across the fleet, so it is worth applying only to members above a minimum degree, where intersections are expensive enough to justify it.
Taming highly connected members
Recruiters and public figures with tens of thousands of connections show up in many searches. Three measures keep them from hurting tail latency. Cap reported counts at 1,000, so intersections can stop early once they pass the cap. Store their lists as bitmaps as well as arrays, so membership tests are constant time. And spread their shards' load by placing extra replicas of their lists, since the hottest members are known in advance.
Ranking the mutual list
A count is cheap, but the list view needs order. Once the intersection is computed (and it is materialised only for this endpoint, not for counts), score each shared member by the viewer's interaction signals with them: recent messages, profile views, same company. Those signals come from a feature store keyed by viewer, fetched once per request. Then filter out anyone whose privacy setting hides their connections and return the top page.
"Adjacency lists are sorted, compressed integer arrays in memory, hash-sharded by member. For a search page I fetch the viewer's list once and send it to each shard that owns some targets. Each shard checks a Bloom filter for the zero case, then merges when the sizes are close and gallops when one side is 30 times bigger. Counts above 1,000 are capped, edges stream in from the connections database, and a short-lived pair cache covers repeat queries."
07
Decisions and what they cost
Decisions and what they cost
Only the pairs people actually look at cost anything, and a new connection changes two lists instead of thousands of stored counts. Each read does a few microseconds of CPU work.
Matches the access patternConstant-time reads, but the table would be enormous and writes would fan out to every pair involving both members' connections. Most of that work is never read.
Write amplification too highLists are flat sorted arrays next to the code that intersects them, with SIMD and no serialisation. You own the sharding, replication and rebuild tooling.
Fastest at scaleQuick to build and fine for millions of members. At hundreds of billions of entries, hash-based sets use several times more memory than compressed arrays, and cross-node intersection is not supported.
Good starting pointOne list travels, many stay put. A batch of 25 touches at most a handful of shards, each in parallel.
Least data movedSimple to write, but a page with one recruiter result pulls tens of kilobytes over the network for a single count.
Too much networkExact numbers where people read them, early termination where nobody cares about the difference between 4,210 and 4,211.
Product and cost alignedMinHash or HyperLogLog sketches give fast approximate overlaps, useful for recommendation scoring. A visible "14" that should be "13" looks like a bug, so they suit internal ranking, not display.
For ranking only08
Failure modes
Failure modes
An automated account calls the mutual list endpoint for millions of pairs to reconstruct who knows whom, which is exactly the data members trust you to protect.
Rate limit list requests per viewer far more tightly than counts, require the viewer to be in a plausible relationship with the target, and alert on accounts whose query patterns sweep the graph.A member with 30,000 connections lands in thousands of searches a second. Their shard spends most of its CPU on one list and p99 for every batch it serves climbs.
Gallop or use bitmaps for lopsided pairs, cap counts at 1,000, cache the hottest pair results, and add extra replicas for members whose lists are both large and popular.A garbage collection pause or a noisy host delays one shard by 200 ms. Without care, the whole search page waits for it.
Set a per-shard deadline, send a hedged request to another replica after the p95 latency, and return the page with those counts missing andpartial: true rather than blocking.
A member turns off connection visibility, but a cached preview still shows their face under someone else's profile.
Apply privacy filtering after cache reads, on every response, from a settings store with change events, and never cache preview identities longer than the settings TTL.The change log consumer lags by ten minutes. Someone accepts an invitation and still sees "3rd degree" on the other person's profile.
Alert on consumer lag, and for the two members involved in a new edge, have the connections service write directly to their shards as well, so they see the change immediately while the log catches up.A shard holding 10 GB of lists crashes. Rebuilding from the source database would take hours and hammer it.
Take periodic snapshots of each shard to object storage, load the latest snapshot on restart, and replay edge events from its offset. Replicas serve traffic meanwhile.09
How to handle the interview
How to handle the interview
Name the batch query as the main load. Profile views are easy. Twenty-five intersections per search page, at peak, are what you size for.
Reject precomputation with numbers. Estimate the pair table and the write fan-out. It shows you considered it and why read-time intersection wins.
Prove the graph fits in memory. A minute of arithmetic on edges, ID width and compression changes the conversation from databases to data structures.
Explain merge versus gallop. Interviewers love the size-ratio switch. Give the cost of each in a sentence.
Move the small list to the big ones. Data locality is the core distributed idea here; say it explicitly.
Use the Bloom filter correctly. It proves absence, not presence. Use it for the zero case and say what its memory costs.
Bring up privacy. Hidden connection lists are a real product rule, and filtering after the cache is the safe place for it.
10
Growing the design in stages
Growing the design in stages
An edges table with an index on both columns, and a query that joins it to itself for two members. Fast enough while lists are short and traffic is low.
Each member's connections become a set in Redis, and counts use SINTERCARD. The database stays the source of truth and feeds Redis through change events.
Sets are replaced by compressed sorted arrays in a purpose-built service, hash-sharded and replicated, with scatter-gather batches.
Merge or gallop by size ratio, Bloom filters for zeros, bitmaps and caps for super-connectors. The one-algorithm-fits-all approach is retired.
The same shards serve degree checks, second-degree walks for recommendations and graph-aware search filters, with mutual counts as one query type among many.
Watch and read
Further watching and reading
Try next
Free to read · better with landed
Whiteboard this with landed
landed runs it as a live system design round on the whiteboard and grades your trade-offs.