SysDesignPrep.com
System design interview question

Design a Social Graph

LinkedIn’s connections for 1 B members: invitations, follows, a 1st/2nd/3rd badge on 200 k profile views a second, and daily People You May Know.

Last updated 2026-10-26. Difficulty: hard. Patterns: graph, sharding, in-memory, consistency, batch. Reported at LinkedIn, Meta, X, Snap, Pinterest, Microsoft.

Walk through a strong candidate's answer, turn by turn.

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 LinkedIn · Meta · Snap · Pinterest, run as that company’s round is reported to go.

The short answer

How to design Social Graph in a system design interview, in five steps:

  1. Scope it. Send, accept, ignore and withdraw connection invitations, follow and unfollow, degree badge on every profile view, mutual connections, connection lists and counts, people You May Know (PYMK), privacy: hidden connection lists and blocking.
  2. Set the targets. Scale: 1 B members · 250 B connections; Degree check latency: p99 < 20 ms at 200 k/s; Bounded work per check: ≤ 2 list reads · ≤ 30 k ids; Read-your-writes for the two people: immediate for A and B · < 1 s for others.
  3. Do the numbers. Connections and edge rows: 250 B connections · 500 B rows; Connection writes: ~600/s avg · ~2 k/s peak; Graph held in memory: ~4 TB raw · ~5 TB per copy.
  4. Draw the design. API gateway, Connection service, Edge store, Graph query service, Graph cache, Event stream, Mirror & repair, Network cache, PYMK service.
  5. Go deep where it is hard.
    • One relationship, two rows → Two typed rows per relationship, sharded by member id.
    • Accepting across two shards → Local transaction plus outbox, idempotent write of the other side, mirror retries, daily repair.
    • Degree by intersection, not expansion → Bidirectional: membership probe for 1st, intersection of the two lists for 2nd.
    • The whole graph in memory → Full in-memory replica: sorted id arrays, 4 096 slots, 3 copies per region, fed by CDC.
    • What must be fresh, and what may lag → Tokens for the people involved, blocks checked on every read, derived data allowed to lag.
    • People You May Know every day → Daily batch over a capped graph replicated in memory on each worker, ranked, versioned, filtered at read time.

Functional requirements

  • Send, accept, ignore and withdraw connection invitations. A connection is mutual and needs consent, so it starts as a pending invitation that the recipient owns. Accepting makes both people 1st-degree. Caps: 30 k connections per member (LinkedIn’s own limit) and a weekly invitation quota against spam.
  • Follow and unfollow. One-way and without consent, for creators and companies. Connecting implies following both ways. Follows feed the news feed but never change anyone’s degree.
  • Degree badge on every profile view. 1st, 2nd or 3rd relative to the viewer, from current connections. 1st and 2nd are exact on every view. Beyond two hops we show an exact 3rd where the viewer’s second-degree set is cached and “3rd+” otherwise, which is how LinkedIn’s own search filters group everything past two hops.
  • Mutual connections. A count and three faces on the profile, and the full list paginated behind it. It is the intersection of two connection lists, computed at read time.
  • Connection lists and counts. Browse a member’s connections, newest first, if they allow it. Your own count is exact; other people see “500+” past 500. Follower counts may lag by seconds.
  • People You May Know (PYMK). Ranked suggestions with a reason (“12 mutual connections”, “also at Acme”), recomputed daily. A new member gets suggestions within minutes of signing up, before any daily run has seen them.
  • Privacy: hidden connection lists and blocking. A member can hide their list; shared connections still show, because the viewer already knows those people. A block removes any connection and invitation and hides each member from the other everywhere: profile, mutual lists, PYMK, search badges.
  • Out of scope. The feed itself (fan-out over the follow graph is Design a News Feed), search, messaging, spam and fake-account detection beyond quotas, and recruiter products. We say where each plugs in.

Non-functional requirements

  • Scale (1 B members · 250 B connections). Average 500 connections with a heavy tail to the 30 k cap. Reads dominate: about 100 degree checks for every connection written.
  • Degree check latency (p99 < 20 ms at 200 k/s). On every profile view, inside a ~200 ms page budget. This drives the hardest tradeoff in the design: a check may read at most two lists over the network, so 2nd degree must be an intersection, never a two-hop expansion, and an exact 3rd degree is only affordable where it is precomputed.
  • Bounded work per check (≤ 2 list reads · ≤ 30 k ids). Whatever the degrees involved. A supernode must cost the size of the smaller list in a check, not the larger.
  • Read-your-writes for the two people (immediate for A and B · < 1 s for others). A connection you just made must show as 1st on your next screen. Counts, second-degree caches and PYMK may lag by seconds to a day.
  • Edges converge (half-written pair healed < 1 min · proven daily). A connection is two rows on two shards. A pair with only one side written must heal on its own, and a daily scan must show that it did.
  • Privacy on every read (block effective < 1 s, on every surface). Blocks and hidden lists are checked at read time against the live graph. Precomputed lists (PYMK, second-degree sets) are copies and are never trusted for privacy.
  • Availability (99.99 % for degree checks). A profile renders without the badge rather than failing: a timeout returns no badge, never a guessed one. Invitations can live with 99.9 %.
  • PYMK freshness (daily · new members < 10 min). Never suggest someone you are connected to, have invited, dismissed or blocked, even if yesterday’s list says so.

Back-of-envelope estimates

  • Connections and edge rows: 250 B connections · 500 B rows. 1 B members × 500 average ÷ 2 (each connection has two ends) = 250 B connections. Stored as two rows each, one per member: 500 B rows. Every size below starts from this number.
  • Connection writes: ~600/s avg · ~2 k/s peak. ~50 M invitations accepted a day (of ~150 M sent) ÷ 86 400 ≈ 580/s, about 3× that at the weekday peak. Each accept writes 2 connection rows on 2 shards plus an invitation update, ~5 k row writes/s at peak. Writes are not the hard part.
  • Graph held in memory: ~4 TB raw · ~5 TB per copy. 500 B ids × 8 B = 4 TB of sorted connection lists; headers, block sets and allocator slack bring it to ~5 TB a copy. A typical list is 500 × 8 B = 4 KB, the largest (30 k) 240 KB. Small enough to hold the whole graph in RAM, so there is no miss path at all.
  • Graph cache servers: ~25 per copy · ~75 per region. 5 TB ÷ ~200 GB usable on a 256 GB server = 25 servers per full copy. Three copies per region (one can be in maintenance, one can fail, one still serves): 75 a region, ~225 across three regions. Memory sizes this tier, not CPU: an intersection takes microseconds.
  • Cost of one degree check: ~1 k comparisons · ~8 KB moved. Intersecting two sorted lists of 500 is a merge of ~1 000 comparisons, ~2 µs of CPU. The two list reads move ~8 KB; at 200 k/s that is ~1.6 GB/s across the fleet and under half a core of CPU. A check costs a network round trip, not arithmetic.
  • Second-degree network: ~250 k ids · ~2 PB for everyone. 500 connections × 500 each = 250 k second-degree ids before overlap (fewer unique, since friends share friends). At 8 B that is 2 MB per member, and 1 B × 2 MB = 2 PB to store for everyone. So the second degree is never expanded per request nor stored for all: it is an intersection, cached only for heavy viewers.
  • Edge store on disk: ~30 TB per copy. 500 B rows × ~60 B (two ids, type, state, version, timestamp, row overhead) = 30 TB for connections; follows, invitations and blocks add ~10 TB. Spread as 4 096 logical slots over ~128 MySQL primaries (~300 GB each), each with two replicas.
  • PYMK daily batch: ~3 T paths → 150 B scored → ~1 TB served. Uncapped friends of friends is 1 B × 250 k = 250 T pairs. Capped to the 300 M members active in 30 days, walking their 100 strongest ties and 100 of each tie’s: 300 M × 100 × 100 = 3 T path increments. Keep the top 500 per member by a cheap score (150 B pairs) for the ranker and store the top 200 at ~16 B each: ~1 TB per daily version.

Components

  • Member app (web · iOS · Android): Shows the degree badge and mutual connections on profiles, invitations, connection lists and PYMK cards. Keeps the consistency token returned by a relationship change, or carried in the notification about one, for 60 s and sends it with graph reads, so the two people in a new connection see “1st” on their very next screen.
  • API gateway (auth · quotas · deadlines): Authenticates, enforces invitation quotas (a weekly cap per member, stricter for new accounts) and composes pages. The profile page calls the graph query service with a 20 ms deadline and renders without the badge if it misses: a missing badge is acceptable, a wrong one is not.
  • Connection service (invites · accept · block): Owns every relationship change. Each change commits the deciding member’s rows and an outbox entry in one local transaction on one shard, then writes the other member’s rows directly as an idempotent upsert. For invitations the recipient’s shard is the authority, so an accept and a withdraw racing each other are settled by one conditional update on one row.
  • Edge store (sharded MySQL · by member id): Typed edge rows keyed (src, type, dst) with state, created_at and a per-pair version: CONNECTED, INVITE_IN, INVITE_OUT, FOLLOWS, FOLLOWED_BY, BLOCKS, BLOCKED_BY. Every relationship is two rows, one on each member’s shard, so any one-hop question about a member reads one shard. 4 096 logical slots on ~128 primaries, ~40 TB a copy, with an outbox table on every shard.
  • Graph query service (degree · mutuals · lists): Stateless. Answers relationship(viewer, targets) with degree, mutual count and a preview, plus mutual and connection lists behind cursors. Keeps a 2 GB table of every member’s connection count from last night’s snapshot, used only to plan which list to move, and applies blocks and hidden-list rules to every answer before it leaves.
  • Graph cache (all lists in RAM · 3×/region): Every member’s connections as a sorted array of 8-byte ids, plus both block sets: ~5 TB a copy on ~25 servers, the same 4 096 slots as the edge store, three copies per region. It is a full replica, not a cache with misses: it boots from a snapshot and applies the change stream in commit order, so a cold key can never stampede the edge store. Intersections and unions run next to the data, so a 30 k list never has to cross the network.
  • Event stream (Kafka · binlog CDC + outbox): Row changes from every edge-store shard, keyed by src member so one member’s changes stay in order, plus the outbox entries for the mirror. Consumers: the graph cache, the mirror, PYMK incremental, the snapshot builder, and outside this design the feed, search and notifications. Its lag, p99 under a second, is the staleness of every read that does not carry a token.
  • Mirror & repair (outbox consumer · daily scan): Applies the other side of every relationship change from the outbox, as an upsert guarded by the pair version, retrying with backoff until it lands, so a failed second write heals in seconds. A daily scan anti-joins the edge snapshot with itself to find pairs with one side, re-checks each against the live rows and fixes it. Pairs repaired per day is how you know the dual write is healthy.
  • Network cache (2nd-degree bitmaps): Second-degree sets as compressed bitmaps for the ~2 M heaviest viewers (recruiter and sales seats, anyone past 20 profile views today): ~0.5 MB typical, up to ~20 MB for a 30 k-connection recruiter, ~2 TB in all. Built on demand by a scatter to the graph cache shards and kept 6 hours. It makes an exact 3rd-degree check a probe of the target’s ~500 ids; hours of staleness are fine because 1st and 2nd are always computed live.
  • PYMK service (serve · filter · explain): Reads the member’s daily list and incremental overlay, drops anyone now connected, invited, dismissed, blocked or deactivated, and asks the graph query service for exact mutual counts on the visible cards. Logs every impression with list version, position and reason, which becomes the ranker’s training data.
  • PYMK store (KV · versioned daily lists): Member id → top 200 candidates with score and reason code, ~3 KB each, ~1 TB per daily version. Each run is bulk-loaded as a new version and switched on by flipping one pointer after validation, with yesterday’s kept for rollback. A small overlay keyspace holds same-day suggestions from the stream until the next version supersedes them.
  • PYMK batch (daily · capped FoF + ranker): Loads a capped graph (each member’s 100 strongest ties, 32-bit ids, ~400 GB) and member features into memory on each of ~20 workers, and partitions members, not the graph, across them. Counts friends of friends with paths through high-degree members discounted, adds company, school and contact candidates, and ranks with a model trained on accepted invitations. Writes the top 200 per member as a new version.
  • PYMK incremental (stream · cold start): Consumes sign-ups and new connections. For a new member it builds a first list within minutes from their first connections’ connections, same-company and same-school cohorts and contact matches; for an existing member’s new connection it adds that person’s strongest ties as candidates. Writes the overlay, which the next daily version replaces.
  • Graph snapshots (daily edges · profiles · logs): A daily edge snapshot cut at a known stream position, compacted from the change stream so the live store is never scanned. Beside it: profile attributes (companies with dates, schools with years, region, industry), contact-import matches, and PYMK impressions with their outcomes. The PYMK batch and the repair scan both read it, so neither touches production MySQL.

User flows

  1. Send and accept an invitation. The write path. One relationship becomes rows on two shards; the design makes the second write certain rather than atomic, and makes the result visible to both people on their next screen.
    1. A invites B; the connection service writes the pending invitation on B’s shard, which owns it. B’s shard is the authority because only B can accept or ignore, and a withdraw by A is a conditional update on the same row, so one row settles every race. The same transaction checks the 30 k cap and that neither has blocked the other (B’s shard holds both BLOCKS and BLOCKED_BY for B), and adds an outbox entry for A’s INVITE_OUT row.
    2. B accepts; one local transaction on B’s shard flips the invitation and inserts B’s connection row with an outbox entry. The state = "pending" guard makes accept, ignore and withdraw mutually exclusive: zero rows updated means someone else got there first, and an accept retried after a timeout finds its own row already accepted and returns success. The new pair version v2 goes on both rows so later changes to the pair can be ordered.
    3. The connection service writes A’s side directly, as an idempotent upsert, and returns. Normally both sides land inside the request, about 10 ms in all. If this write fails the request still succeeds, because the outbox entry from the previous step guarantees the mirror will apply it (see the failure flow). The version guard makes the direct write and the mirror’s write the same operation, so doing it twice is harmless.
    4. Both shards’ changes flow through the event stream into every graph cache replica, which inserts each id into the other’s sorted list. Each replica applies a shard group’s changes in commit order and records the last position applied, which is what tokens are compared with. Inserting into a sorted array of 500 ids moves at most 4 KB; the largest lists are kept in chunks of ~1 k ids so an insert never moves more than 8 KB. Lag is tens of milliseconds normally, p99 under a second.
    5. Both members’ next reads carry the token, so “1st” shows immediately even on a replica that is a moment behind. The query service compares the token with the replica’s applied position for that shard group; almost always the replica is already past it. The token expires after 60 s, by which time every replica has applied the change anyway. Everyone else sees the connection after the stream lag, which nobody can notice.
    6. Downstream consumers react to the same events: PYMK incremental adds B’s strongest ties as candidates for A, and A’s for B. These may lag. Counts need no consumer at all: a member’s connection count is the length of their list in the graph cache, so it can never disagree with the list they can open. Feed, search and notifications consume the same stream outside this design.
  2. View a profile: degree and mutual connections. The read path, 200 k times a second. Two list reads in parallel, a block check, a membership probe and one intersection: bounded work, about a millisecond, whatever the degrees involved.
    1. A opens B’s profile; the page asks the graph query service for the relationship with a 20 ms deadline. One internal call returns the badge, the mutual count and three preview faces. Search results and comment threads use the same call with up to 50 targets at once. If it misses the deadline the page renders without a badge rather than guessing.
    2. The query service looks up both connection counts in memory, then reads both lists and block sets in parallel. The counts table decides the plan before any network call. Both lists are under ~2 k ids, the common case, so it reads both from any replica in the region: ~8 KB in one parallel round trip of ~0.5 ms. If either side were large it would ship the smaller list to the larger list’s shard instead (the supernode flow).
    3. Blocks are checked first: if either has blocked the other, the answer is “not visible” and nothing else is computed. Each member’s shard holds both BLOCKS and BLOCKED_BY, so one read answers both directions. Deciding privacy before computing anything means a blocked member can never leak through a count or a preview face. Mutuals who block A, or whom A has blocked, are removed before the count in the intersection step.
    4. First degree: is B in A’s list, or A in B’s? Two binary searches, about 10 comparisons each. Checking both directions makes the badge right as soon as either side of a new connection has been written, which hides the seconds, or in a failure the minute, when the second write is still in flight.
    5. Second degree: intersect the two sorted lists; a non-empty result means 2nd degree, and it is also the mutual count and the preview. A linear merge of 480 and 650 ids is ~1 100 comparisons, about 2 µs. When one list is ten times longer, galloping (binary-searching ahead in the long one) makes it O(small × log large). The two hops are never expanded: “is B within two hops of A” is answered as “do their neighbourhoods touch”.
    6. Third degree, only if the first two missed: probe B’s ids against A’s cached second-degree set, or answer “3rd+”. If A is a heavy viewer, the network cache holds A’s second-degree bitmap and 650 probes decide 3rd degree exactly. Otherwise the badge reads 3rd+, because computing it would mean reading the lists of all 480 of A’s connections, ~2 MB, for one profile view.
    7. A taps “23 mutual connections”; each page is recomputed from the two lists and continues after a cursor. Recomputing is cheaper than caching: two 4 KB reads and a 2 µs merge. The merge emits members in id order, so the cursor is the last id returned and a connection made mid-scroll cannot cause a duplicate. B hiding their list does not apply, since only shared connections are shown; blocked and deactivated members are filtered on every page.
  3. A recruiter with 30 k connections. The case that breaks naive designs. A 30 k list is 240 KB and a recruiter’s second degree is ~15 M ids, so every operation must cost the size of the smaller side, and expensive state must be built once and reused.
    1. Recruiter R, with 30 k connections, opens B’s profile; the counts table says R’s list is the big one. Fetching R’s list for every view would move 240 KB per check, and R views hundreds of profiles a day. So the plan flips: move the small list to the big one.
    2. The query service reads B’s 4 KB list and sends it to R’s shard, which intersects it next to R’s list. R’s shard gallops 650 ids into its 30 k sorted array: ~650 × 15 ≈ 10 k comparisons, ~10 µs. What comes back is a count and three ids, not R’s list. Two sequential round trips instead of one parallel pair, ~1 ms, bounded by the smaller list.
    3. R views another recruiter with 25 k connections: the smaller list, 200 KB, is shipped to R’s shard and merged there. About 55 k comparisons, ~50 µs, and a 200 KB transfer: the most expensive single check in the system, and the 30 k cap is what bounds it. The mutual count can run to thousands; it is exact, and the preview takes three.
    4. R passes 20 profile views today, so the query service builds R’s second-degree set once and stores it in the network cache. Pulling the lists of R’s 30 k connections to one place would move 120 MB. Instead R’s list goes to every server of one graph cache copy (25 × 240 KB) and each ORs its local lists into a partial bitmap; merged, ~15 M ids (~10 M unique) become a ~20 MB compressed bitmap in a few hundred milliseconds. Built at most once per 6 hours per viewer.
    5. From now on each of R’s views is bounded by the target: a probe for 1st, an intersection on R’s shard for 2nd, B’s ids against the bitmap for 3rd. 650 bitmap probes take ~10 µs. The bitmap can be hours old, which only affects 3rd degree: a connection made since is still found by the live intersection. A search page of 50 badges for R is 50 intersections on R’s shard and one bitmap fetch.
    6. A hot target: a CEO with 30 k connections gets 5 k profile views a second, each intersecting the viewer’s small list on the CEO’s shard. The three replicas of that slot in the region share it, ~1.7 k intersections a second each at ~10 µs: trivial CPU. The query service also keeps the lists of the few thousand hottest members in process for 10 s, so their shard sees one fetch per host per 10 s rather than one per view.
  4. The second write fails halfway. B accepts while A’s shard is failing over. B’s side commits and A’s does not. The pair has to heal on its own, neither person should notice, and something has to prove afterwards that nothing stayed one-sided.
    1. B accepts; the transaction on B’s shard commits with its outbox entry, but the write to A’s shard times out during a primary failover. A primary failover takes ~10–30 s. The connection service gives the second write a 200 ms budget and does not retry inline: its outcome is unknown (it may have committed just before the old primary died), and retrying is the mirror’s job.
    2. The service returns success to B anyway: the outbox entry is the promise that A’s side will be written. Failing the request would be wrong, because B’s side is durable and “accepted” is already true. B’s token names B’s shard, so B sees 1st immediately. A’s “accepted” notification is sent from the stream after the commit and carries the same token.
    3. The outbox entry reaches the mirror through the event stream; it retries the upsert on A’s shard with backoff until the new primary accepts it. The upsert is guarded by the pair version, so it is safe whether or not the timed-out write actually committed. Backoff runs from 100 ms to 30 s, and the pair usually heals within a minute. An entry still failing after an hour goes to a dead-letter topic and pages someone.
    4. Meanwhile A opens B’s profile: B’s list already contains A, and the 1st-degree probe checks both directions, so A sees “1st”. The one-sided window never shows on the badge. What is off for that minute: A’s own connection count is one short and B is missing from A’s connection list, both acceptable for a minute.
    5. B opens A’s profile with a token the local replica has not applied yet; the query service reads that one pair row from the edge store. The fallback is one primary-key read, not a list, and happens only when a token is newer than the replica, so it is a tiny fraction of reads, all in the seconds after writes. It has a 10 ms budget; past that the answer comes from the replica.
    6. If an outbox entry is ever lost to a bug, the daily repair scan finds the one-sided pair in the snapshot, re-checks the live rows and writes the missing side. An anti-join of the 500 B-row snapshot with itself runs in the batch cluster, not against production MySQL. Pairs that were mid-write when the snapshot was cut show up too, so every finding is re-read live before it is fixed. Pairs fixed per day should be near zero; a rise is the alarm that some code path writes one side without an outbox entry.
    7. A removes B a minute later while an old retry is still in flight; the version check stops the retry from resurrecting the connection. Remove writes tombstones with version v3 on both shards, through the same outbox path. The stale upsert carries v2, so its version guard matches nothing. Tombstones are kept 30 days, longer than any retry or repair can be late; without them a late upsert would find no row and recreate the connection.
  5. Daily PYMK, then a page of it. The background path. A daily batch turns a capped snapshot into a ranked list per member; serving filters that list against the live graph, and a stream job covers members the batch has not seen yet.
    1. At midnight UTC the snapshot builder cuts the edges at a known stream position, alongside profiles and yesterday’s PYMK outcomes. The snapshot is built by compacting the day’s changes into yesterday’s snapshot, so the live store is never scanned. Recording the position per shard group means the batch knows exactly which edges it saw; the serving filter handles everything after.
    2. The PYMK batch loads a capped graph into memory on each worker: every member’s 100 strongest ties, as 32-bit ids. 1 B × 100 × 4 B = 400 GB, plus ~200 GB of member features, fits on 1 TB-RAM machines, so ~20 workers each hold the whole capped graph and split the members instead of the graph. Strength is recency plus interaction (messages, profile views, endorsements). The cap is why a 30 k-connection member adds 100 paths to each neighbour’s count, not 30 k.
    3. For each of 300 M active members, the job counts friends of friends through those ties and adds company, school and contact candidates. Each path through a middle member adds 1 / log(that member’s degree) (Adamic–Adar), so knowing someone through a 30 k-connection recruiter counts for less than through a 50-connection colleague. 300 M × 100 × 100 = 3 T increments into per-member hash maps, ~5 k unique candidates each, minutes of work because it is all in memory. Cohorts: same company with overlapping dates, same school within two years.
    4. A cheap score keeps the top 500 per member; a ranker trained on accepted invitations orders them and keeps 200. This is the candidate-then-rank funnel of the recommendation system, run offline: 150 B pairs scored by gradient-boosted trees at ~10 µs is ~420 core-hours, about 20 minutes on the workers. Features: weighted mutuals, cohort overlap, region, industry, seniority gap, profile views either way, and how often the candidate accepts. The label is an accepted invitation, not a click, so the model is not rewarded for suggestions people ignore.
    5. The job bulk-loads the lists as a new version of the PYMK store, validates it, and flips the current-version pointer. Bulk files rather than 300 M individual writes. Checks before the flip: coverage of active members, score distribution against yesterday’s, and a sample proving no suggestion is an existing connection. Yesterday’s version stays for rollback. LinkedIn has written about serving batch-computed data such as PYMK from a key-value store built for bulk loads (Voldemort’s read-only stores).
    6. A opens My Network; the PYMK service reads A’s list and overlay, filters them against the live graph, and gets exact mutual counts for the visible cards. The list is up to a day old, so anyone A has since connected with, invited or dismissed, anyone blocked either way, and anyone who opted out is dropped here. The batch counted mutuals over capped ties; the card shows the exact count from the live intersection. Impressions go to the event stream with version and position, as tomorrow’s training data.
    7. A new member signs up and connects to three colleagues; the incremental job writes a first list within minutes. Three colleagues with ~500 connections each give ~1 500 candidates, counted and scored by a lighter model in milliseconds; same-company cohorts and contact matches fill the list before the first connection exists. The PYMK service reads the overlay alongside the daily list, and tomorrow’s version supersedes it.

Deep dives

One relationship, two rows

Why store every connection twice, sharded by member, instead of once, or in a graph database?

Almost every question the product asks is about one member: who are A’s connections, is B among them, how many are there, whom has A blocked and who has blocked A. At 200 k profile views a second, each of those has to be one partition read. A connection, though, belongs to two members whose ids hash to different shards, and one row can only live on one of them.

The query mix decides it. One-hop reads are constant and hot; anything deeper is either two one-hop reads intersected, or offline over a snapshot. So the store never needs to traverse. It needs to make a member’s neighbourhood one sequential read, and the price of that is writing each relationship from both ends.

  • Two typed rows per relationship, sharded by member id chosen
  • One row per connection keyed (lower id, higher id), plus a secondary index rejected
  • A graph database (Neo4j, Neptune) for storage and traversal situational: a graph of a few billion edges where multi-hop queries are the product, such as fraud rings or permission hierarchies
  • Partition by community so neighbours share a shard rejected

The answer: Every relationship is a pair of typed rows (src, type, dst) → (state, created_at, version), one on each member’s shard: CONNECTED both ways, INVITE_IN on the recipient and INVITE_OUT on the sender, FOLLOWS and FOLLOWED_BY, BLOCKS and BLOCKED_BY. Rows are clustered by (src, type, dst), so “A’s connections” is one range scan and “is B connected to A” is a point read. Members hash to 4 096 logical slots on ~128 MySQL primaries with two replicas each, and slots move between hosts without rehashing anyone. The only lists that outgrow a partition are big creators’ FOLLOWED_BY rows, bucketed as (member, bucket); connections are capped at 30 k, so a connection list never needs it. This is the shape of Facebook’s TAO (USENIX ATC 2013): typed associations stored on the shard of their source object in sharded MySQL, with inverse associations written from both ends.

Why MySQL rather than Cassandra?

Either works for this layout; what decides it is per-shard transactions and conditional updates. Accepting flips one row and inserts another on the same shard, and the state = pending guard is what settles accept against withdraw. That is an ordinary local transaction in MySQL and a lightweight transaction, a Paxos round, in Cassandra. At ~5 k row writes a second neither is stretched, so I would pick whichever the company already runs well.

What exactly is in a row, and how big is it?

Two 8-byte ids and a 1-byte type in the key; state (pending, accepted, ignored, withdrawn, removed), created_at and a version in the value. With InnoDB row and index overhead that is ~60 B on disk, so 500 B connection rows are ~30 TB a copy. The version belongs to the pair, and both rows carry the same one, which is what lets the two sides be reconciled.

How do you rebalance when a host fills up?

Members hash to 4 096 slots and a small table maps slots to hosts, so moving load means moving whole slots, never rehashing members. A slot is copied to its new host from a snapshot plus the binlog, writes to it pause for about a second at cutover, and the map is updated. The graph cache uses the same slots, so its shard map moves in step.

Where do follows live, and why not in the graph cache?

In the same store, as FOLLOWS and FOLLOWED_BY rows written through the same dual-write path. They are not part of degree, and the reads that need them, feed fan-out and follower pages, scan lists page by page rather than intersect them. Keeping them out holds the cache at ~5 TB a copy instead of roughly double, and keeps 30 M-entry follower lists out of a tier built for small lists.

A member deletes their account. What happens to their edges?

Their own shard’s rows go in one pass; the rows on other members’ shards are removed through the mirror, one outbox entry per relationship, paced so a member with 30 k connections is not a burst on 128 shards at once. Until each lands, reads filter the member as deactivated, so nothing shows them in the meantime. The daily repair scan catches any pair left behind.

Accepting across two shards

Accepting writes one row on B’s shard and one on A’s. Why not a distributed transaction, and what happens when the second write fails?

The two rows of a connection land on different shard groups 127 times in 128. Writing them independently means a crash or a failover between the writes leaves a pair with one side: B lists A as a connection, A does not list B. With ~2 k accepts a second at peak and primary failovers every week somewhere among 128 groups, that is a daily event, not a theoretical one.

Two properties make it easier than it looks. The second write is idempotent and ordered: an upsert of a known row with a known version, safe to apply any number of times, late or out of order. And nothing about the second row is decided by its shard: it is fully determined by the first. So the problem is not agreement, which needs a transaction, but delivery, which needs a durable promise and retries.

  • Local transaction plus outbox, idempotent write of the other side, mirror retries, daily repair chosen
  • Two-phase commit across the two shards rejected
  • A distributed SQL store (Spanner, CockroachDB class) doing it for you situational: the edge store is already distributed SQL and the team accepts its commit latency
  • Write one side only and derive the other from the stream rejected

The answer: On accept, the connection service runs one local transaction on B’s shard: flip the invitation row (only if still pending), insert (B, CONNECTED, A) with a new pair version, and insert an outbox entry describing A’s side. It then writes A’s side directly as an upsert that applies only if the stored version is older, with a 200 ms budget, and returns success whether or not that write landed. The outbox entry reaches the mirror through the event stream, and the mirror applies the same upsert with backoff until it succeeds, usually within a minute, dead-lettering after an hour. Removes write tombstones with a higher version that are kept 30 days, so a late upsert can never resurrect a removed connection. A daily anti-join of the snapshot finds any pair still one-sided, re-checks it live and fixes it, and that count should be near zero. Facebook’s TAO paper describes the same decision for inverse associations: the two writes are not atomic, and an asynchronous job repairs the hanging ones.

Why write B’s side first and not A’s?

Because B’s shard holds the invitation, and the invitation’s state is the decision. Flipping it to accepted and inserting B’s connection row in one local transaction makes the decision and the first side atomic, and the outbox entry for A’s side commits with them. If A’s side went first, a crash between the writes would leave A connected to someone whose invitation is still pending and could still be withdrawn.

A withdraws the invitation at the same moment B accepts it.

Both are conditional updates on the same row, the INVITE_IN row on B’s shard: one sets accepted where state is pending, the other sets withdrawn where state is pending. The shard serialises them, so exactly one matches and the other updates zero rows and gets a 409. A losing withdraw is told “already accepted”, a losing accept “no longer available”. No other row is consulted.

A and B send each other invitations at the same moment.

Each send writes INVITE_IN on the recipient’s shard, so both invitations can exist. The send path first checks for a pending invitation the other way, which sits on the sender’s own shard as an INVITE_IN row, and accepts it instead of creating a second. If the two sends race past each other, the first accept connects them, and accepting the second finds the pair already connected, closes it and does nothing else.

How do you know the dual write is not silently broken?

Three numbers. Outbox age, the oldest unapplied entry per shard group, alerting past a few minutes. Dead letters, entries failing for an hour, which should be zero. And the repair scan’s daily count of pairs fixed after a live re-check: the end-to-end proof. A jump there means some code path writes one side without an outbox entry.

Could you drop the outbox and mirror and keep only the daily repair?

Correctness would survive but the experience would not: a pair broken in a failover would stay one-sided for up to a day, so A’s list and count would be wrong until tomorrow. The outbox makes the common failure heal in seconds; the scan is the backstop for the rare one the outbox cannot cover, such as a bug that skips it. They catch different things and both are cheap.

Degree by intersection, not expansion

200 k profile views a second each need “what degree is B to me”, and a viewer’s second degree is 250 k people. How do you answer in 20 ms?

The textbook answer is a breadth-first search from the viewer: one hop is 500 members, two hops ~250 k, three the whole network. Expanding two hops per check means reading 500 lists, ~2 MB; at 200 k checks a second that is 100 M list reads and ~400 GB a second. Precomputing everyone’s second degree instead is 2 PB, rewritten by 50 M new connections a day. Neither survives the arithmetic.

The way out is to search from both ends. B is within two hops of A exactly when their neighbourhoods touch: B is in A’s list, or the two lists share a member. That is two list reads and one merge of ~1 000 sorted ids, and it yields the mutual connections the page needs anyway. Three hops needs one side expanded, so it is only exact where that expansion is paid for once and reused.

  • Bidirectional: membership probe for 1st, intersection of the two lists for 2nd chosen
  • Cached second-degree bitmap per heavy viewer, for an exact 3rd chosen
  • Breadth-first expansion from the viewer per request rejected
  • Precompute everyone’s second-degree set or a distance table rejected

The answer: The graph query service answers relationship(viewer, target) with a fixed plan. Block sets first: if either side blocks the other, stop. Then 1st degree as two binary searches, the target in the viewer’s list or the viewer in the target’s, so a half-written pair still reads as connected. Then 2nd degree as one intersection: both lists read in parallel when both are under ~2 k ids, otherwise the smaller list shipped to the larger list’s shard and galloped into it. The intersection gives the mutual count and three preview ids, after removing anyone blocked or deactivated. Only if both miss does 3rd degree come in: for viewers with a network-cache bitmap the target’s ids are probed against it, and everyone else sees 3rd+. A typical check costs ~1 ms and 8 KB, the worst ~2 ms and 240 KB, against a 20 ms deadline. LinkedIn’s 30 k connection cap and its 3rd+ grouping are product choices that are exactly what keep this bounded.

Product wants an exact 3rd degree for everyone. What does it cost?

A bitmap for every viewer who reaches a profile beyond two hops. Say 30 M members a day do: ~350 builds a second, each reading ~500 lists by scatter, is ~175 k extra list reads and ~0.7 GB a second, roughly doubling the graph cache’s read load. With a 30-minute lifetime about 1 M bitmaps are live at once, ~0.5 TB. Affordable, not free; I would ship it behind an experiment and keep it only if an exact 3rd changes what people do.

Why not a Bloom filter of the second degree instead of a bitmap?

At 1 % false positives a Bloom filter of 250 k ids is ~300 KB, not much smaller than the ~0.5 MB compressed bitmap, and every false positive is a wrong badge on someone’s profile. The bitmap is exact and supports the unions needed to build it. A 1 % error on 3rd degree might be tolerable, but the exact structure already fits the budget.

A is blocked by one of the 23 mutual connections. What does A see?

Twenty-two. Mutuals are filtered on the read: anyone who blocks A, whom A blocks, or who is deactivated is removed before the count and the preview are computed. If that blocker was the only mutual, A sees 3rd+ rather than a 2nd that the mutual list could not explain.

A search results page needs badges for 50 members at once.

One batched call. The viewer’s list is read once, the 50 targets’ lists are read in parallel grouped by server, so a handful of calls moving ~200 KB, and 50 merges cost ~100 µs. For heavy searchers with a bitmap, 3rd degree is 50 sets of probes. A page of badges is a few milliseconds of graph work, which is why search can afford one on every result.

How would you know the badges are right?

Shadow-check a sample: recompute 0.1 % of answers offline from the edge store and alert on any disagreement beyond the stream lag. Track the badge-missing rate (deadline misses), the token fallback rate, and the share of 3rd+ answers a bitmap would have resolved, which says whether the heavy-viewer threshold is right. A wrong 1st is the error people report, so it gets its own alert.

The whole graph in memory

Why hold every connection list in RAM, how many machines is that, and how is it sharded and kept current?

A degree check is two list reads. From MySQL each is an indexed range scan of ~500 rows, a millisecond or more, at ~400 k scans a second; the average might pass, but at p99 inside 20 ms the tail of a disk-backed read is the problem. An LRU cache in front helps the average and not the tail: profile views are spread across the whole member base, so misses stay frequent and every miss is a scan.

The graph is smaller than it sounds: 500 B ids at 8 B is 4 TB, ~5 TB with overhead, about 25 servers of RAM. At that size it is cheaper to hold all of it than to manage misses, and a full replica fed by the change stream has no miss path at all: no stampede on a cold key, no TTLs, no invalidation races. What is left to design is the representation, the sharding, the replication and the bootstrap.

  • Full in-memory replica: sorted id arrays, 4 096 slots, 3 copies per region, fed by CDC chosen
  • LRU cache (Memcached, Redis) in front of the edge store rejected
  • Write-through cache updated by the write path (TAO-style) situational: the cache tier is the only way to reach the store, as in TAO, so every write already passes through it
  • Compressed or 32-bit ids situational: memory is the constraint, at 10× scale or on smaller machines

The answer: The graph cache holds every member’s CONNECTED list as a sorted array of 8-byte ids, plus their BLOCKS and BLOCKED_BY sets, ~5 TB per copy. Members hash to the same 4 096 slots as the edge store; ~164 slots per 256 GB server, ~200 GB of lists, gives ~25 servers per copy. Each region runs three copies, ~75 servers, each kept under 50 % so two copies can carry the whole load. Every replica consumes the change stream for its slots in commit order and records the applied position per shard group, which is what consistency tokens are compared with. A replacement server loads its ~200 GB from the latest snapshot in object storage in 3 to 4 minutes and replays the stream from the snapshot’s position before it takes traffic. The query service calls it through Get, MultiGet, Intersect and UnionNeighbours, so set operations run where the lists already are. LinkedIn has written about serving its graph from memory, most recently with its LIquid graph database.

A graph cache server dies at peak. What happens?

Its slots are still served by the other two copies; health checks route around it within a second, and the servers holding those slots go from under 50 % to ~75 % for a few minutes. A replacement loads the snapshot for its slots, replays the stream, and joins once its applied position is within a second of the head, about four minutes. Nothing touches the edge store, which is exactly why the tier has no miss path.

A bad deploy corrupts the graph cache in a whole region.

Deploys go one copy at a time per region, canary server first, precisely so all three copies cannot break together. If it happened anyway, the region’s query service fails over to another region’s tier at ~100 ms more latency, most badges in that region miss their deadline and render empty, and the tier is rebuilt from snapshots in parallel in about ten minutes. The profile page never depends on the badge, so this is degraded, not down.

Why sorted arrays rather than hash sets or bitmaps?

A sorted array is the most compact exact form for a sparse set, 8 B per id, and it supports everything needed: binary search for membership, merge or galloping for intersection, and an ordered scan for paging by id. A hash set costs two to three times the memory. Compressed bitmaps shine on dense sets, but 500 ids scattered over a billion are so sparse that per-container overhead makes them bigger than the array; I use bitmaps only for second-degree sets, which are dense enough to compress.

The graph grows 20 % a year. How do you add capacity?

Move slots, not members: add servers to a copy, give them a share of the 4 096 slots, load those slots from snapshot plus stream, then flip the slot map, one copy at a time so two copies are always serving. A slot is ~1.2 GB, so each moves in seconds. Past ~40 servers a copy I would split every slot in two once, which needs no data movement, only a new map.

Why three copies per region and not two?

With two, a server failure during a deploy or a slot move leaves one copy carrying every read for those slots with no margin. Three lets one copy be in maintenance and one fail while the third serves, and it spreads hot members’ reads over three servers. The third copy is ~25 servers a region, a small fraction of what the services calling the graph cost.

What must be fresh, and what may lag

B accepts A’s invitation, and a minute later C blocks A. Who must see each change immediately, and how do you guarantee it without making every read strongly consistent?

Every read here is served from a replica that trails the edge store by the stream lag: tens of milliseconds normally, a few seconds during a failover or a spike. For most viewers that is invisible. For the two people in a new connection it is not: B taps accept, opens A’s profile, and sees 2nd. And for privacy it is not negotiable: once C blocks A, A must stop seeing C in PYMK, in mutual lists and on badges.

The changes split cleanly. Facts about a pair (connected, blocked) must be right for the people involved on their next read. Derived data (counts, second-degree bitmaps, PYMK lists, other members’ view of the edge) can lag by seconds to a day, as long as nothing derived is ever allowed to override a fact. So the mechanism can be targeted: make the next read of the people involved fresh, and re-check facts at read time wherever a stale copy could leak.

  • Tokens for the people involved, blocks checked on every read, derived data allowed to lag chosen
  • Strongly consistent reads from edge-store primaries rejected
  • Optimistic UI: the app shows what it just did situational: as a complement for the person who acted, never as the guarantee
  • Update every cache replica synchronously before the write returns rejected

The answer: Every relationship write returns a token: the shard group and commit position it wrote. The actor’s client keeps it for 60 s, and the other member gets it in the notification about the change. Reads carry it, and if the graph cache replica has not yet applied that position, the query service answers for the pair from the edge store with one primary-key read instead of the list. The 1st-degree probe checks both members’ lists, so a pair half-written by the dual write still reads as connected. Blocks are checked first on every relationship, mutual-list and PYMK read, from the viewer’s own BLOCKS and BLOCKED_BY sets, so a block applies on every surface within the stream lag, including for the member who was blocked. Precomputed artefacts are never trusted for privacy: PYMK lists, second-degree bitmaps and search caches are filtered at read time, the same rule the news feed and post search apply to their copies. What may lag, and by how much, is written down: other viewers by the stream lag, counts by seconds, bitmaps by hours, PYMK by a day.

A and B are homed in different regions. Does the token still work?

Yes, because it names the shard group and position, not a region. If A’s local replica of B’s shard group has applied the position, the fallback reads it there; if not, it reads the primary across the ocean, ~100 ms. That happens once, in the first seconds after the change, for the two people involved.

What does a block actually do, step by step?

One local transaction on the blocker’s shard writes (C, BLOCKS, A), marks any connection, invitation and follow between them removed, and queues the other side; the mirror writes (A, BLOCKED_BY, C) and A’s side of the removals. From the moment either side reaches the graph cache, reads treat them as blocked, because each check reads both of the viewer’s sets. C gets a token, so C’s own next screen is right immediately, PYMK drops them on read, and tomorrow’s batch excludes them as candidates.

C has hidden their connection list. What can A still see?

A cannot browse C’s connections, and C’s count shows only to C. Shared connections still show on C’s profile, because A already knows those people: hiding protects who C knows that A does not. C still counts for degree badges and as a path in PYMK. Those rules live in one filter in the graph query service, so every surface applies them the same way.

Why is it fine for connection counts to lag?

Your own count is the length of your own list, read with your token, so it is exact for you. Other people see “500+” past 500, so a second of lag changes nothing they can see, and follower counts for big creators are approximate anyway. What would not be fine is a count that disagrees with the list a person can open, and deriving the count from the list rules that out.

The stream falls 5 minutes behind during an incident. What breaks?

Nothing is lost: changes are durable in the edge store and queued in the stream. People who just acted still see their changes, because their tokens force fallback reads, and the climbing fallback rate is itself the alert. Everyone else sees connections and blocks up to 5 minutes late, and blocks are the urgent part, so during heavy lag the query service also reads the viewer’s block rows from the edge store, a cheap single-partition read, until lag is back under a minute.

People You May Know every day

Friends of friends for a billion members is 250 trillion pairs. How do you produce fresh, good suggestions every day, and what does someone who joined an hour ago get?

The strongest signal for “you may know this person” is a triangle about to close: several of your connections know them. Counting that for everyone is the sum over members of degree squared, ~250 T pairs a day before the heavy tail makes it worse; one member with 30 k connections alone generates 900 M. Shuffled through a distributed join that is petabytes. And a daily list is up to a day stale, while a new member has no list at all.

Three observations shape the answer. Most of the signal is in strong ties, so walking each member’s 100 strongest connections and 100 of each of theirs keeps it and cuts the work by ~80×. Capped that way, the whole graph is ~400 GB with 32-bit ids, which fits in one large machine’s memory, so the job can partition members instead of the graph and do no shuffle at all. And candidate generation plus ranking is the funnel every recommender uses, so the new parts are only the graph walk and keeping a day-old list honest against today’s graph.

  • Daily batch over a capped graph replicated in memory on each worker, ranked, versioned, filtered at read time chosen
  • Spark self-join of the capped edge table situational: the capped graph does not fit one machine’s memory, or everything already runs on Spark
  • Pregel-style message passing over the full graph rejected
  • Friends of friends computed on request situational: members with a handful of connections, such as new members, where the walk is a few thousand ids

The answer: Each night the batch reads the snapshot, keeps each member’s 100 strongest ties (recency and interaction), remaps ids to 32 bits, and loads the ~400 GB capped graph plus ~200 GB of features into memory on ~20 workers, each owning 15 M of the 300 M members active in 30 days. For each member it walks ties of ties, adding 1 / log(the middle member’s degree) per path, then adds cohort candidates (same company with overlapping dates, same school within two years) and contact-import matches, ~5 k unique candidates each. A cheap score keeps 500; a gradient-boosted ranker trained on invitations sent and accepted keeps 200, which are bulk-loaded as a new version of the PYMK store and switched on after coverage and distribution checks. Serving reads the list plus the overlay, drops anyone now connected, invited, dismissed or blocked, and shows exact mutual counts from the live graph. The stream job writes the overlay within minutes for new members (their first connections’ ties, cohorts, contacts) and for every new connection. LinkedIn has described PYMK publicly in these terms: closing triangles, plus shared companies and schools, computed offline.

What do you train the ranker on, and what does it optimise?

Impressions logged with list version, position and reason, labelled by what followed within a week: invited and accepted is positive, invited and ignored or reported as “I don’t know this person” is a strong negative, shown and skipped a weak one. Optimising accepted invitations rather than clicks or sends keeps the model from rewarding suggestions that become spam invitations. Position bias and exploration are handled the way any recommender handles them.

A member with 30 k connections: how do you stop them appearing in everyone’s list?

Two caps and a weight. As a middle member they add at most their 100 strongest ties to anyone’s count, not 30 k, and each path through them counts 1 / ln(30 000) ≈ 0.1 against ~0.26 through a 50-connection colleague. As a candidate, the ranker sees their degree and how their invitations fare, so someone who accepts everyone but whom few people actually know ranks low. Members with unusually high inbound suggestion rates can also be rate-limited as candidates.

Tonight’s batch fails. What do members see?

Yesterday’s version, because the pointer only flips after validation. Lists are two days old instead of one, the read-time filter still removes anyone connected since, and the overlay still adds suggestions from new connections. A second missed night is an incident; one is invisible.

At 10× the members the capped graph is ~4 TB even delta-encoded. Now what?

Splitting members across workers does not help by itself, because the ties of any slice of members lead almost everywhere in the graph. Machines with several TB of RAM exist, so the first option is to keep the no-shuffle design on ~20 of them, with candidate generation growing from minutes to about an hour. The second is to shard the capped graph and run the walk as a distributed join, with the shuffle grown to ~480 TB. I would keep the big machines while they exist, because the job stays simple, and treat the join as the fallback; serving does not change either way.

How do you know PYMK got better, not just different?

An A/B test randomised by member, with accepted invitations per member per week as the primary metric and guardrails on ignored invitations, “I don’t know this person” reports and members hitting their invitation quota, which catch a model that drives spam. Offline, replay yesterday’s lists against the connections people actually made today. And coverage, the share of active members with a non-empty list, is tracked per version, because a silent drop there can look like a quality win in averages.

The theory behind it

  • Storing and querying graphs: Social graphs and other graph data in system design: adjacency lists in a key-value store, graph databases, friends-of-friends and mutual friends, celebrity nodes, sharding a graph, and what Facebook's TAO does.
  • Large-scale graph processing: Running algorithms over graphs with billions of edges: the Pregel vertex-centric model, PageRank, connected components, friend-of-friend recommendations, partitioning graphs, offline batch versus online queries, incremental updates, and where graph processing appears in system design.
  • Sharding and partitioning: Horizontal partitioning strategies (range, hash, directory), consistent hashing, choosing a shard key, hot shards, cross-shard queries, and resharding without downtime.

Related

Report an issue

Something wrong with this question? A number that does not add up, a step whose answer key looks off, a typo. It goes straight to the author with the page you are on.

Watch a strong candidate answer thisTurn by turn, with the design drawn as they talkWalk through →Mock interview