Social Graph Interview Questions | JiQuest

add

#

Social Graph

System design deep dive · HLD

Design a Social Graph & "People You May Know": full high-level design.

Requirements, back-of-envelope capacity estimation for billions of users and hundreds of billions of follow edges, a high-level design diagram, a deployment architecture diagram, sequence diagrams for the follow-edge write path and the offline recommendation pipeline, and an entity-relationship diagram - with the reasoning an interviewer expects behind every box and arrow.

2BTotal users
600BDirected follow edges
<5msHot edge-list read (p99)
Clientfollow(A,B) Graph APIcreate edge Hash routeruser_id → shard Edge shardwrites both directions Edge cacheinvalidate on write ack, strongly consistent

1. Clarify requirements before drawing any box

A social graph looks simple - "who follows whom" - until scale forces every read and write to be reasoned about separately. The clarifying step is deciding which model is primary and which questions must never touch a live, unbounded graph traversal.

Functional requirements

Create / remove an edgeA directed follow edge (A follows B) primary; note the undirected mutual-friend variant below.
Get direct connectionsFetch a user's followers or following list efficiently, even for accounts with tens of millions of edges.
People You May KnowSuggest 2-hop (friend-of-friend) candidates, ranked by mutual-connection count and other relevance signals.
Follow vs friend modelPrimary: asymmetric follow (Twitter/Instagram). Variant: symmetric mutual-friend (Facebook) via request/accept.

Non-functional requirements

Graph-scale storageBillions of users and hundreds of billions of edges - no single machine holds this; it must be partitioned.
Low-latency hot readsReading a follow list must return in single-digit milliseconds, even for celebrity nodes with tens of millions of edges.
Bounded PYMK compute2-hop traversal is expensive at scale, so it runs as a capped, sampled, mostly-offline job - never a live full traversal.
Mixed consistencyDirect edges (did I follow this person) are strongly consistent; follower counts and PYMK scores are eventually consistent.
Explicitly out of scope Full graph-analytics queries such as shortest path between two users or community detection computed at request time, and messaging or notification delivery triggered by graph changes, are treated as separate downstream systems that consume graph events rather than core requirements of the graph service itself.

2. Back-of-the-envelope capacity estimation

The two numbers that drive every decision below are the total edge count (it decides partitioning) and the read:write ratio (it decides how aggressively hot nodes must be cached).

MetricAssumptionResulting estimate
Total users2 billionBase population for the graph
Average out-degree~300 average following count, heavily skewed - celebrity accounts have tens of millions of followers2B × 300 ≈ 600 billion directed edges
Storage per edgefollower_id + followee_id (8B each) + created_at (8B) + edge_type/flags (~1B, padded) ≈ 40 bytes600B × 40B ≈ 24TB raw
Total graph storageBoth directions stored (followers-of + following-of) × 3-way replication24TB ×2 ×3 ≈ ~144TB across the cluster
Edge write rate~500 million follow/unfollow actions per day globally≈ 5,800 writes/sec average, ≈ 20,000/sec peak (viral moments)
Edge read rateProfile views + feed generation checking follow-status or fetching following-lists≈ 2,000,000 reads/sec average - roughly 300:1 read:write
Offline PYMK batch cost2B users × capped 200 sampled 1-hop neighbors × capped 100 sampled 2nd-hop neighbors each≈ 40 trillion bounded edge-touches/night, vs an uncapped traversal from one 10M-follower hub alone approaching 10^14
Why this matters A ~300:1 read:write ratio justifies a dedicated hot-node cache tier in front of the edge store, and the batch-cost row is the number that rules out live 2-hop traversal outright: without a per-hop sampling cap, a single celebrity node in the traversal path makes the query cost explode combinatorially.

3. High-level design (HLD)

The HLD separates the strongly-consistent direct-edge path from the eventually-consistent recommendation path - they have different latency budgets, different consistency requirements, and almost never share a code path at request time.

Clientweb / mobile Graph APIcreate/remove edge, get-edges Follow write pathdual-write, strongly consistent Routing layerconsistent hash, user_id → shard Edge cacheRedis, hot celebrity nodes Sharded edge storeadjacency lists by user_id PYMK batch (Spark)nightly, capped 2-hop sample Rec. candidate storeprecomputed PYMK results
Stateless servicesFast-path infraDurable sharded storageOffline / batch

What each box owns

Graph API (create/remove edge, get-edges)

The single stateless entry point for all edge operations. Writes are routed through the follow write path; reads are routed through the routing layer to the owning shard, checking the edge cache first. It never performs a live 2-hop traversal itself - PYMK requests are simply a read against the precomputed recommendation candidate store.

Graph partitioning / routing layer

Maps a user_id to the shard that owns that user's adjacency lists using consistent hashing, so adding or removing shards only reshuffles a small fraction of users instead of the whole ring. Because the routing key is always a known user_id, every "get all edges for user X" request resolves to exactly one shard - no scatter-gather across the cluster.

Follow write path (dual-write, strongly consistent)

A single follow action touches two adjacency lists: the follower's "following" list and the followee's "followers" list. Both writes are executed as one logically atomic operation (retried together on partial failure) so the UI can never observe a state where A appears to follow B but B's followers list doesn't yet contain A.

Sharded edge store and edge cache

The sharded edge store (a wide-column store like Cassandra, or a TAO-style graph cache layered over sharded MySQL) holds the durable adjacency lists, partitioned by owning user_id. A dedicated Redis-based edge cache sits in front of it purely to absorb the read load from celebrity/hub accounts, whose follower lists would otherwise dominate a single shard's traffic.

PYMK batch pipeline and recommendation candidate store

A nightly Spark job reads a snapshot of the edge store, performs a capped, sampled 2-hop traversal per user, scores candidates by mutual-connection count, and writes the top-N results into the recommendation candidate store. The online path never traverses the graph live - it only ever reads whatever the last batch run precomputed.

4. Detailed architecture diagram

The architecture diagram commits to specific technology and deployment shape: how many shards, how replicas are laid out, and - critically for this system - where a dedicated hot-node tier sits to absorb the wildly uneven traffic that celebrity accounts generate.

Edge / gateway layer GeoDNS / Anycast API gateway + L7 LB Rate limiterper-user follow-action throttling Sharded edge-store cluster (consistent-hash ring) Shard 0-63 (Cassandra) Each shard: 1 primary + 2 replicas Graph API pods ×40 (stateless, per-region) Hot-node cache tier (absorbs celebrity-account load) Redis cluster, 24 shards Hub-node detector a small fraction of accounts drive a disproportionate share of reads - pinned, larger TTL entries Offline PYMK batch cluster (Spark) Reads nightly graph snapshot bounded 2-hop traversal, per-nodefan-out sampling cap (avoids hub blowup) Graph snapshot store Columnar export (nightly) decouples batch reads from thelive, low-latency edge-store cluster Recommendation candidate store Top-N scored candidates per user online path only ever reads here -never computes PYMK live
DecisionChoiceReasoning
Partitioning strategyHash-based user_id sharding via consistent hashingEven distribution and simple resharding; friends often land on different shards, but "list my edges" never needs shard-mates to be co-located - only single-shard, single-key lookups matter.
Storing both edge directionsFollowers-of and following-of both persisted (redundant)Doubles write cost and storage, but keeps every read O(1) on a single shard; deriving one direction from the other at read time would require a cross-shard scan.
PYMK computationOffline, precomputed, sampled 2-hop batch jobA hub node's full 2-hop expansion is combinatorially infeasible online; bounding fan-out per hop and running nightly trades exhaustiveness for a request-time cost of a single cache read.
Partitioning alternativeCommunity-aware / edge-cut-minimizing partitioningBetter locality for genuinely social access patterns, but far harder to maintain as the graph mutates continuously - not worth it when direct-edge lookups are already single-shard by construction.

5. Sequence diagrams for the two critical flows

The first flow is on the strongly-consistent critical path (a user must never see a stale follow state); the second is entirely offline except for the final, trivially fast read.

5.1 Create a follow edge, then read the following list

Client Graph API Router Edge store Edge cache 1. POST /follow {A,B} 2. resolve shards(A,B) 3. shard map 4. write following-of(A) + followers-of(B) 5. ack both writes 6. invalidate cached lists (async) 7. 201 Created 8. GET /A/following 9. GET cached list 10. HIT: return list 11. 200 OK {following} on MISS: read shard 655, populate cache 835, then return

Steps 4-5 are drawn as a single logical unit deliberately: both adjacency-list writes must succeed together, and on a partial failure the operation is retried rather than leaving the graph in a state where A follows B but B's followers list doesn't yet contain A. Step 6 is fire-and-forget - cache invalidation is an optimization for the next read, not a correctness requirement for the write itself. On the read side, a cache hit (step 10) collapses the whole lookup to one round trip; the miss path noted below the diagram falls back to the sharded store and repopulates the cache before returning, which is why celebrity nodes need that cache tier to almost never miss.

5.2 Offline PYMK batch computation, then online serving

PYMK batch (Spark) Edge snapshot Rec. candidate store Graph API Client 1. read nightly graph snapshot 2. snapshot data 3. per user: sample ≤200 1-hop neighbors 4. per neighbor: sample ≤100 2nd-hop, score by mutual overlap 5. drop already-connected users, rank top-N 6. write top-N scored candidates 7. ack 8. GET /pymk 9. read precomputed candidates(user) 10. top-N list 11. 200 OK

Steps 3-5 are the entire reason this system stays feasible at scale: capping fan-out to a fixed sample size at each hop turns an unbounded, per-node-dependent traversal cost into a fixed, predictable cost per user, independent of whether that user (or their neighbors) happen to be a hub with tens of millions of edges. Steps 8-11 are deliberately trivial - the online path never re-runs any of steps 3-5, it only ever reads whatever the last nightly run already computed, which is what keeps PYMK requests as fast as any other cache read.

6. Entity-relationship (ER) diagram and schema

The schema has to answer: what's the single key that makes "get all my edges" a one-shard lookup, how is a directed edge represented so both directions are O(1) to query, and how are precomputed recommendations refreshed without becoming their own bottleneck.

users PK id BIGINT username VARCHAR follower_count BIGINT following_count BIGINT created_at TIMESTAMP counters denormalized, async edges PK follower_id BIGINT PK followee_id BIGINT FK follower_id→users FK followee_id→users edge_type ENUM created_at TIMESTAMP status ENUM composite PK = followers-of and following-of index, both directions recommendation_candidates PK user_id BIGINT PK candidate_id BIGINT FK user_id→users mutual_count INT score FLOAT generated_at TIMESTAMP 1N 1N 1N users ↔ edges is many-to-many (each row is one direction of a relationship); one user has many recommendation_candidates, refreshed nightly by the batch job

Key modeling decisions

edges has a composite key, no surrogate id(follower_id, followee_id) is both the primary key and the natural index for "all edges where I am the follower" - the mirrored row with roles swapped serves the reverse lookup.
follower_count / following_count are denormalizedProfile views need them instantly; they're incremented by an async worker off the write path, not computed live with a COUNT over the edges table.
recommendation_candidates is wholesale-refreshedEach nightly run replaces a user's candidate rows rather than incrementally patching them - simpler, and stale rows just age out on the next run.
edge_type distinguishes follow vs pending-friendThe same table supports the symmetric variant (see deep dive 5) by adding a status of pending/accepted rather than a separate schema.
Storage choiceUse whenWatch out for
Wide-column, sharded by user_id (Cassandra)Access pattern is overwhelmingly "get all edges for user X", at hundreds of billions of rows.Ad-hoc queries like "who follows both A and B" need external indexing - the store is optimized for single-key access, not joins.
TAO-style graph cache over sharded MySQLYou want a proven relational engine underneath plus a purpose-built read-through cache that already understands edge semantics.More moving parts to operate than a single wide-column cluster; cache-consistency logic is bespoke, not free.

7. Deep dives interviewers actually probe

Why is naive live 2-hop friend-of-friend traversal infeasible at scale?

A full 2-hop expansion from a node with 10 million followers means visiting 10 million 1-hop neighbors and, for each of them, however many neighbors they have - a combinatorial fan-out that can reach hundreds of billions of pairs from a single request. No amount of horizontal scaling makes an unbounded, per-node-dependent cost acceptable on a request that must return in milliseconds. Capping the sample size at each hop turns the cost into a fixed constant per user, at the cost of not being exhaustive - an acceptable trade for a "may know" suggestion, which was never meant to be complete.

// bounded, sampled 2-hop traversal - cost is O(HOP1_CAP * HOP2_CAP), not O(degree^2)
const HOP1_CAP = 200, HOP2_CAP = 100;
function samplePymk(userId) {
  const hop1 = sampleNeighbors(userId, HOP1_CAP);          // capped, not full adjacency list
  const scores = new Map();
  for (const n of hop1) {
    const hop2 = sampleNeighbors(n, HOP2_CAP);              // capped again, even if n is a hub
    for (const candidate of hop2) {
      if (candidate === userId || alreadyConnected(userId, candidate)) continue;
      scores.set(candidate, (scores.get(candidate) || 0) + 1); // mutual-connection count
    }
  }
  return topN(scores, 50);
}

How do you partition hundreds of billions of edges so "get all my edges" is one shard?

Hash the owning user_id through a consistent-hashing ring to pick a shard, and store both directions of every relationship (a followers-of row on the followee's shard, a following-of row on the follower's shard). This deliberately does not try to co-locate friends' data on the same shard - that would require constant, expensive rebalancing as the social graph evolves. The trade is redundant storage and double writes, in exchange for every read being a single-shard, single-key lookup with no fan-out.

How do you handle celebrity accounts read constantly and mutated frequently?

A dedicated hot-node cache tier (Redis) sits in front of the sharded store specifically so a small number of accounts don't overwhelm one shard's IOPS; a hub-node detector flags accounts crossing a follower-count threshold so their entries get pinned with longer TTLs. Follower and following counts shown in the UI are denormalized counters on the users table, incremented asynchronously off the write path - counting live from the edges table on every profile view would turn a cheap read into a full partition scan for exactly the accounts that get the most traffic.

What consistency does "did I follow this person" need versus "how many mutual friends"?

The direct edge itself must be strongly consistent: the dual write (following-of + followers-of) is retried as one unit, and a read immediately after a successful write must reflect it - a user unfollowing someone and then seeing "Follow" still greyed out is a trust-breaking bug. Mutual-friend counts, follower totals, and PYMK scores are all derived, aggregate figures computed from a snapshot that may be up to a day old; eventual consistency there is not just acceptable, it's the entire premise that makes the offline batch pipeline affordable.

How would this change for a symmetric mutual-friend model (Facebook) instead of follow?

A symmetric model needs a pending state: a friend request creates one edge row with status=pending, and only accepting it promotes it to status=accepted and materializes both directions (A→B and B→A) so lookups from either side stay O(1) - the storage shape barely changes, only the state machine in front of the write path does. Partitioning is unaffected: the edge is still keyed by the pair of user ids and sharded the same way, since "friend" is just an edge_type variant of the same edges table rather than a different schema.

8. Summary: what a strong answer covers

Picked asymmetric follow as primary, noted the symmetric variantNamed the sharding key and why both directions are storedJustified every number with a calculation Made PYMK offline and bounded, not liveSeparated strong consistency (edges) from eventual (counts, scores)Gave celebrity nodes a dedicated cache tier
Interview tip The strongest signal in this problem is recognizing where naive graph traversal breaks - a hub node's fan-out - and answering it with sampling and an offline pipeline rather than trying to make live traversal "fast enough." Everything else (sharding, caching, the schema) follows from getting that one trade-off right.
No comments
Leave a Comment