← System design

Social & Feed

Design LinkedIn MutualConnection Search

On a professional network, the line "14 mutual connections" under a name is one of the strongest reasons to click. Behind it sits a set intersection between two friend lists, and the network asks for it constantly: on every profile view, on every row of every search page, in every "people you may know" card. The design question is how to intersect lists that range from ten entries to thirty thousand, millions of times per second, without precomputing a table the size of the planet.
Core: sharded adjacency lists + adaptive set intersection900M members, 45B edges25 results per search pagep99 under 40 ms per batch

01

The problem

01

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.

Profile view

One viewer, one profile: count plus three faces. A single pairwise query on the critical path of the page.

Search and lists

One viewer against 25 results at once. This batch query is the busiest and must finish in one round trip.

Full mutual list

Clicking the count opens a paginated list of shared connections, sorted by relevance to the viewer.

Degree badge

Whether someone is a 1st, 2nd or 3rd+ degree connection. A 2nd-degree badge is simply "at least one mutual exists".

Why not just precompute it?

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

02

What the service must do

Functional
  • 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.
Non-Functional
  • 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.
Out of scope

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

03

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.

QuantityWorkingResult
Undirected connections900M x 100 mean / 245B
Stored adjacency entries45B x 2 directions90B
Raw list size90B x 4 B member IDs360 GB
Compressed listsdelta plus varint encoding, about 1.6 B per entryabout 145 GB
With 3 replicas in memory145 GB x 3about 435 GB of RAM in total
Search pages per secondpeakabout 60K
Intersections from search60K x 25 results1.5M / s
Profile views and cardspeakabout 500K / s
Median intersection costmerge of 60 and 400 entrieswell under 5 microseconds
Connection changespeak writesabout 20K / s
The whole graph fits in memory

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.

The tail is the problem

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

04

API design

POST/v1/graph/mutual-countsCounts for one viewer against many targets
{ "viewer": 48120533, "targets": [90211, 771203984, 5520017], "preview": 2 // also return two faces per target } // 200 OK { "results": [ { "target": 90211, "degree": 2, "count": 14, "preview": [3304, 118802] }, { "target": 771203984, "degree": 3, "count": 0 }, { "target": 5520017, "degree": 1, "count": 211 } ], "partial": false // true if a shard timed out and some counts are missing }
GET/v1/graph/mutuals?viewer=48120533&target=90211&cursor=&limit=20Page through shared connections
{ "total": 14, "members": [3304, 118802, 640021], "next_cursor": null } // Ranked by the viewer's interaction strength with each mutual; hidden members removed
POST/internal/graph/edgesEdge change event from the connections service
{ "op": "add", "a": 48120533, "b": 640021, "version": 88120044 } // Consumed from a log; applied to both members' lists, idempotent by version

05

Architecture overview

05

Architecture overview

Read path for a search page and the edge update pathDiagram
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 --> PC

The 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.

Graph shards

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.

Mutual service

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.

Pair count cache

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.

Edge event stream

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

06

Deep dive: intersecting lists in microseconds

The heart of it

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.

# count shared members of two sorted ID arrays def mutual_count(small, big): if len(small) > len(big): small, big = big, small if len(big) > 30 * len(small): return gallop_count(small, big) return merge_count(small, big) def gallop_count(small, big): hits, lo = 0, 0 for x in small: step = 1 while lo + step < len(big) and big[lo + step] < x: step *= 2 lo = lower_bound(big, x, lo, min(lo + step, len(big))) if lo < len(big) and big[lo] == x: hits += 1 return hits
How a shard answers one batch requestDiagram
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.

A strong interview answer

"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

07

Decisions and what they cost

Chosen
Intersect at read time

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 pattern
Rejected
Precomputed pair counts

Constant-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 high
Chosen
Custom in-memory graph shards

Lists 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 scale
Alternative
Redis sets with SINTERCARD

Quick 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 point
Chosen
Send the viewer's list to target shards

One list travels, many stay put. A batch of 25 touches at most a handful of shards, each in parallel.

Least data moved
Rejected
Pull every target list to the service

Simple to write, but a page with one recruiter result pulls tens of kilobytes over the network for a single count.

Too much network
Chosen
Exact up to 1,000, then capped

Exact numbers where people read them, early termination where nobody cares about the difference between 4,210 and 4,211.

Product and cost aligned
Alternative
Estimated counts from sketches

MinHash 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 only

08

Failure modes

08

Failure modes

🤖
A scraper walks the graph through the API

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 recruiter appears on every search page

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.
🧱
One shard is slow during a batch

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 and partial: true rather than blocking.
🔒
A hidden member leaks in a mutual list

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.
🕰️
Edge events fall behind

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 restarts and must reload

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

09

How to handle the interview

1

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.

2

Reject precomputation with numbers. Estimate the pair table and the write fan-out. It shows you considered it and why read-time intersection wins.

3

Prove the graph fits in memory. A minute of arithmetic on edges, ID width and compression changes the conversation from databases to data structures.

4

Explain merge versus gallop. Interviewers love the size-ratio switch. Give the cost of each in a sentence.

5

Move the small list to the big ones. Data locality is the core distributed idea here; say it explicitly.

6

Use the Bloom filter correctly. It proves absence, not presence. Use it for the zero case and say what its memory costs.

7

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

11

Growing the design in stages

Stage 1: a few million members
A SQL self-join

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.

Stage 2: tens of millions
Redis sets

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.

Stage 3: hundreds of millions
Dedicated graph shards

Sets are replaced by compressed sorted arrays in a purpose-built service, hash-sharded and replicated, with scatter-gather batches.

Stage 4: heavy search traffic
Adaptive algorithms and fast paths

Merge or gallop by size ratio, Bloom filters for zeros, bitmaps and caps for super-connectors. The one-algorithm-fits-all approach is retired.

Stage 5: richer graph features
A general graph query layer

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

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.