TL;DR

1. The problem: why does "who follows whom" need its own service?

Almost every Instagram feature asks the graph a question:

That makes graph reads one of the highest-QPS workloads in the system — every profile view, comment and feed refresh triggers several — while follows/unfollows are comparatively rare. And the data is skewed: a typical user follows a few hundred accounts, but the largest accounts have hundreds of millions of followers.

🧠 Analogy Think of every user carrying two address books: "People I follow" and "People who follow me". When Alice follows Bob, she writes Bob in her first book and Bob must write Alice in his second book. Keeping two books means either question is answered by opening one book — but the two must be kept in agreement, and a celebrity's "people who follow me" book is a library of 600 million pages that nobody can read in one go.

2. Modelling the graph

A graph is a set of nodes (vertices) connected by edges. Instagram's follow graph is directed (Alice following Bob doesn't mean Bob follows Alice — unlike Facebook friendship, which is undirected/symmetric) and edges carry a little data: creation time, state (active/pending), maybe a close-friends flag.

Alice Bob Carol Dan ⭐ Celeb600M followers followsfollows back follows Adjacency lists following(Alice) = {Bob, Carol, Celeb} followers(Alice) = {Bob} following(Bob) = {Alice, Celeb} followers(Bob) = {Alice} followers(Celeb) = {Alice, Bob, Dan, … 600M} Alice ⇄ Bob: mutual follow
A directed follow graph and its adjacency lists. Each edge appears in one user's "following" list and another's "followers" list.

2.1 Rough numbers (assumptions)

Accounts with edges        ~1.5B
Average following          ~200            → 300B directed edges
Stored in both directions  600B rows × ~25 B ≈ 15 TB  (× 3 replicas ≈ 45 TB, plus indexes)
Follow + unfollow writes   500M DAU × 0.2/day ≈ 100M/day ≈ 1,200/s avg
Graph reads                feed fan-out, profile views, permission checks, mutuals
                           → easily several hundred thousand to millions of lookups/s
Read : write               ≈ 1,000 : 1 (or more)

The data fits comfortably on a sharded cluster; the challenge is read throughput, latency and skew, which is why caching dominates graph design.

3. Storage options

3.1 One relational table with two indexes

CREATE TABLE follows (
  follower_id  BIGINT NOT NULL,
  followee_id  BIGINT NOT NULL,
  state        SMALLINT NOT NULL,            -- 1 = active, 2 = pending request
  created_at   TIMESTAMP NOT NULL,
  PRIMARY KEY (follower_id, followee_id)     -- "whom does A follow?", "does A follow B?"
);
CREATE INDEX follows_by_followee ON follows (followee_id, created_at DESC);  -- "who follows B?"

On a single database this is perfect: one row per edge, both directions indexed, follow/unfollow is one transaction, counts are a query (or a trigger-maintained column). It's how most apps start; early Instagram scaling talks describe a follow graph that began as essentially this kind of simple (source_id, target_id, status) table in PostgreSQL.

It breaks when you shard: if the table is sharded by follower_id, "whom does A follow" hits one shard, but "who follows B" must ask every shard (scatter-gather), because B's followers are spread everywhere. One of the two directions always becomes a cross-shard query.

3.2 Two tables, each sharded by its own user (dual writes)

The standard fix is to store the edge twice, each copy sharded by the user it "belongs" to:

Alice follows BobPOST /follow Graph servicevalidate, write Shard of Alice (hash(alice) % N) following: (alice, bob, created_at) following: (alice, carol, …) PK (user_id, created_at, target_id) Shard of Bob (hash(bob) % N) followers: (bob, alice, created_at) followers: (bob, dan, …) PK (user_id, created_at, source_id) ① sync ② async
Each edge is written to the follower's "following" list and the followee's "followers" list, on different shards. Both directions become single-shard reads.

Wide-column stores like Cassandra fit this naturally: partition key = user_id, clustering key = (created_at DESC, other_id), giving newest-first paged lists per user.

3.3 Graph databases

A graph database (Neo4j, Amazon Neptune, JanusGraph, TigerGraph) stores nodes and relationships natively, using index-free adjacency (each node points directly at its edges), and offers query languages like Cypher or Gremlin for multi-hop traversals:

// Cypher: accounts followed by people I follow, that I don't follow yet, ranked by overlap
MATCH (me:User {id: 1001})-[:FOLLOWS]->(f)-[:FOLLOWS]->(s)
WHERE NOT (me)-[:FOLLOWS]->(s) AND s <> me
RETURN s.id, count(f) AS mutuals ORDER BY mutuals DESC LIMIT 20

Great for deep traversals, fraud rings and knowledge graphs. But Instagram's online queries are almost all one hop (lists, edge checks, counts), at enormous QPS; general-purpose graph databases are hard to shard across hundreds of machines (a traversal crosses partitions constantly), and the multi-hop analyses (suggestions) can run offline in batch systems instead. That's why the largest social networks built sharded key-value/relational stores with graph-shaped APIs rather than using a graph database online.

3.4 Facebook's TAO: objects and associations

TAO ("The Associations and Objects", USENIX ATC 2013) is Meta's publicly described graph store serving Facebook's social graph — billions of reads per second, overwhelmingly reads. Its model:

Leader regionFollower region Web servers Follower cachesserve reads Leader cacheper shard, writes MySQL shardsprimary Web servers Follower cachesserve reads Leader cachereads miss here MySQL replicasasync replication replication (+ cache invalidation) writes forwarded to leader region (dashed)
TAO (simplified): reads are served by follower cache tiers in every region; writes go through the shard's leader cache to the primary MySQL database, then replicate to other regions.

3.5 Comparison

OptionStrengthsWeaknessesUse when
Single follows table + 2 indexesSimple; atomic follow; easy countsOne direction is cross-shard once shardedUp to the limits of one (replicated) DB
Following + followers tables, each sharded by own userBoth directions single-shard; scales horizontallyDual writes need repair; 2× storageLarge scale, simple one-hop queries
Graph databaseExpressive multi-hop traversalsHard to shard; lower throughput for simple lookups at extreme QPSAnalytics, fraud, recommendation research, moderate scale
TAO-style association storeGraph API + massive cache + regions; typed edges for many featuresBig engineering investment; eventual consistencyPlanet-scale social products

4. The queries the graph must answer

QueryWhere it's usedHow (two-table / TAO model)Cost
Following list of A"Following" screen, feed pull for celebrities, trayRange scan on A's shard: following WHERE user=A ORDER BY created_at DESC LIMIT 50 / assoc_range1 shard, cached
Followers list of B"Followers" screen, fan-out workersRange scan on B's shard with a cursor1 shard (bucketed for celebrities)
Does A follow B?Follow button, private-account access, DM inbox vs requests, comment permissionsPoint lookup following(A, B) / assoc_get(A, FOLLOWS, {B})O(1), very hot → cache incl. negative results
Mutuals"Followed by alice, bob + 12 others" on B's profileIntersect following(viewer) with followers(B) (below)Bounded by viewer's following size
CountsProfile headerDenormalised counters / assoc_countO(1)
Suggestions"Suggested for you", People You May KnowOffline friends-of-friends + ML, served from a precomputed storeBatch compute; O(1) serve

4.1 Mutuals: "Followed by …"

When Alice views Bob's profile, Instagram shows which of the people Alice follows also follow Bob: following(Alice) ∩ followers(Bob). Bob may have 50M followers, Alice ~300 followings — so iterate the small set and probe the big one:

def followed_by(viewer, profile, limit=3):
    mine = graph.following_ids(viewer)                       # ~300 IDs, cached list
    # one batched probe on profile's shard: which of these follow `profile`?
    hits = graph.followers_contains(profile, candidates=mine) # SELECT ... WHERE user=profile AND src IN (...)
    ranked = rank_by_closeness(viewer, hits)                  # show closest friends first
    return ranked[:limit], len(hits)                          # "Followed by a, b, c + N others"

Cost: one multi-get of ~300 keys on a single shard instead of reading 50M rows. "Mutual follow" (A follows B and B follows A) is just two point lookups.

4.2 Suggestions: friends-of-friends and People You May Know

The classic signal is triadic closure: if many people you follow follow X, you probably know or would like X. Computing it online is too expensive — 300 followings × 300 followings each = 90,000 candidate edges per request — so it's done offline (Spark/batch over a graph snapshot in the data warehouse, see Analytics & ML) and served from a precomputed store:

  1. Candidate generation: friends-of-friends (2-hop), uploaded contacts (with consent), accounts that follow you but you don't follow back, co-follow similarity ("people who follow X also follow Y"), location/school signals, embeddings + approximate nearest neighbours.
  2. Scoring: number of mutual connections, weighted so that mutual friends with few connections count more — e.g. Adamic–Adar score(u, x) = Σz ∈ mutuals 1 / log(degree(z)) — plus an ML model predicting follow-back and long-term engagement.
  3. Filtering: remove already-followed, blocked, previously dismissed, private-policy violations, spam accounts.
  4. Serving: store the top ~100 per user in a key-value store; refresh daily; add real-time suggestions after a follow ("similar accounts to the one you just followed") from a co-follow index.

5. Celebrity accounts

The largest Instagram accounts have over 600 million followers. Everything that's trivial for a normal user becomes an engineering problem:

6. Follower count consistency

Counting followers(B) on each profile view would scan millions of rows, so counts are denormalised: follower_count and following_count stored per user and updated on follow/unfollow. Problems and fixes:

7. Private accounts, follow requests, blocking and close friends

7.1 Follow requests

For a private account, following is a two-step handshake. Model the request as its own edge type with a small state machine:

 STATE        EVENT                                  NEXT STATE   EDGES WRITTEN
 ──────────   ────────────────────────────────────   ──────────   ─────────────────────────────────────
 NONE         follow (public account)                FOLLOWING    + FOLLOWS(A→B), FOLLOWED_BY(B←A)
 NONE         follow (private account)               REQUESTED    + FOLLOW_REQUEST(A→B), INCOMING_REQ(B←A)
 REQUESTED    owner accepts                          FOLLOWING    − request edges, + follow edges
 REQUESTED    owner declines / requester cancels     NONE         − request edges
 FOLLOWING    unfollow / owner removes follower      NONE         − follow edges
 any          block (either direction)               NONE         − all follow/request edges, + BLOCKS

7.2 Blocking

A block (A blocks B) is a strong, symmetric-in-effect relationship stored as its own edge type (BLOCKS with inverse BLOCKED_BY):

7.3 Close friends, mute and restrict

RelationshipVisible to the other person?Effect
Close friends (owner → friend)No (owner-private list; the friend just sees a green ring)Audience for close-friends stories
Mute posts/storiesNoExcluded from the muter's feed/tray; still following
RestrictNoTheir comments visible only to themselves unless approved; DMs go to requests
BlockEffectively yes (content disappears)Severs follows both ways; hides everything

All are just more typed edges in the same store — which is exactly why a TAO-style "associations with types" model is so convenient: one system, one API, many relationship kinds.

8. How fan-out workers read the graph

When a PostCreated event arrives, the feed fan-out workers must insert the post ID into every follower's timeline. The graph is their main input:

PostCreatedauthor = B
→
Fan-out coordinatorfollower count check
→pages of 1,000
followers(B)cursor scan
→batch jobs
Fan-out workersfilter + group by shard
→ZADD pipelines
Redis timelines
  1. Check size: if B's follower count exceeds a threshold (say 1M), skip push entirely — B is a "celebrity" and followers will pull B's recent posts at read time (the feed service calls following(viewer) to find which celebrities to merge).
  2. Page through followers: read followers(B) in pages of ~1,000 via cursor; each page becomes an independent job on a queue, so a 500k-follower fan-out is parallelised across many workers and resumable if one crashes.
  3. Filter: skip inactive followers (no login in 30 days — they'll get a rebuilt feed on return), muted relationships, blocks, audience restrictions.
  4. Batch writes: group follower IDs by timeline cache shard and pipeline ZADDs; trim timelines to the latest N entries. ZADD is idempotent, so retries are safe.

Rough load: 100M posts/day × ~200 followers ≈ 20B timeline inserts/day ≈ 230k/s — which is why follower reads for fan-out are served from cache or bulk-scanned efficiently, and why the celebrity cut-off matters.

9. Follow / unfollow event flow

 App (Alice)        Graph service              Alice's shard        Kafka               Consumers
   | POST /follow Bob   |                            |                  |                      |
   |------------------->| check: blocked? private?   |                  |                      |
   |                    | rate limit (anti-spam)     |                  |                      |
   |                    |-- tx: following(alice,bob) |                  |                      |
   |                    |       + outbox FollowCreated -->|             |                      |
   |<-- 200 following --|                            |-- relay ------->|                      |
   |  (optimistic UI)   |                            |                  |-- inverse writer: followers(bob, alice)
   |                    |                            |                  |-- counters: alice.following+1, bob.followers+1
   |                    |                            |                  |-- notifications: "alice started following you"
   |                    |                            |                  |-- feed backfill: bob's recent posts → alice's timeline
   |                    |                            |                  |-- PYMK / search affinity / analytics

10. Sharding and caching the graph

10.1 Sharding

10.2 Caching

WhatShapeNotes
Following ID listfollowing:{A} → ~200 IDs (≈ 1.6 KB)Small and read constantly (feed pull, mutuals); cache whole list
Edge existenceedge:{A}:{B} → yes/noNegative caching matters: most "does A follow B" answers are "no"
Countscounts:{B} → followers, followingUpdated by the aggregator; no per-follow invalidation for hot accounts
Followers list pagesFirst page(s) onlyNever cache 600M-entry lists whole
Block listblocks:{A} → small setLoaded once per request for read-time filtering

Writes use write-through or invalidate-on-write to the owner's cache entries (TAO's leader cache serialises writes per shard to keep caches coherent). Across regions, caches are eventually consistent; a user's own follow is reflected immediately on their device via optimistic UI and, server-side, by routing their reads to fresh data for a short window after a write. See Caching and Replication.

11. How Instagram uses the social graph

📸 In Instagram In the architecture diagram, the Follow/Graph Service owns the Social Graph store and publishes FollowCreated/FollowRemoved events to Kafka. Its biggest client is the Feed Fan-out Workers, which read follower lists to push post IDs into Redis timelines (with pull for celebrities); the Feed, Stories, Search and DM services call it for following lists, edge checks, blocks and close-friends audiences; the Notification Service sends "X started following you"; and Analytics & ML uses graph snapshots for suggestions and ranking. Instagram's early scaling talks describe a follow graph stored in a simple relational table on sharded PostgreSQL; at Meta, the publicly documented reference design for social-graph data is TAO — objects and typed associations in sharded MySQL behind a huge, region-aware cache. Whatever the exact store behind a given feature today, the diagram's "Social Graph store" is best understood as a TAO-like system: adjacency lists keyed by user, stored in both directions, served almost entirely from cache.

12. Trade-offs & common pitfalls

✅ Two-direction, user-sharded adjacency lists give you

  • Single-shard reads for both "following" and "followers"
  • Horizontal scaling to hundreds of billions of edges
  • Simple, cacheable one-hop queries at huge QPS
  • A uniform model for many relationship types (follow, block, close friends)

⚠️ At the cost of

  • Dual writes that can diverge and need repair
  • 2× storage
  • Eventually consistent counts and cross-region views
  • Multi-hop queries (suggestions) pushed to offline pipelines
  • Special handling for celebrity-sized lists

Common pitfalls

13. Real-world technologies

TechnologyWhen to use it
Sharded PostgreSQL / MySQLFollows tables with composite keys; strong per-shard consistency; the base under TAO
Cassandra / ScyllaDBWide-row adjacency lists partitioned by user, newest-first clustering
Facebook TAO (paper)Reference design: objects + associations + write-through, region-aware cache
Twitter FlockDB (historical, open-sourced)An earlier example of a sharded adjacency-list store for follow graphs
LinkedIn's graph services (publicly described, e.g. LIquid)In-memory graph serving for connections and degree-of-separation queries
Neo4j, Amazon Neptune, JanusGraph, TigerGraphMulti-hop traversals, fraud/abuse rings, moderate-scale graph apps
Redis / MemcachedCaching following lists, edge checks (incl. negative), counts, block lists
Spark GraphX / GraphFrames, Apache GiraphOffline graph analytics: friends-of-friends, PageRank-style scores, PYMK candidates

14. Interview corner

How would you store the follow graph for 2 billion users?

As adjacency lists stored in both directions: following(user_id, created_at, target_id) sharded by user_id, and followers(user_id, created_at, source_id) sharded by user_id. Each direction is a single-shard, newest-first range scan; "does A follow B" is a point lookup in following(A). Writes are dual: write following(A,B) plus an outbox event in one local transaction, and an idempotent consumer writes followers(B,A), counts and notifications; a repair job fixes any one-sided edges. Put a large cache in front (following lists, edge checks with negative caching, counts) — essentially the TAO design.

Why not just use a graph database like Neo4j?

Online queries are almost all one hop (lists, edge checks, counts) at millions of QPS with strict latency; that's a sharded key-value/relational access pattern, best served by partitioned adjacency lists plus caching. General graph databases shine at deep traversals but are hard to partition at hundreds of billions of edges, since traversals constantly cross partitions. Multi-hop work like friends-of-friends runs offline in Spark over graph snapshots instead. A graph DB is still a fine choice for analytics or abuse investigations.

An account has 600 million followers. What changes?

Storage: bucket the followers partition (time or hash buckets) to avoid a 15 GB partition. Reads: cursor pagination only, no full loads; search within followers in the UI. Feed: skip push fan-out; followers pull this account's recent posts at read time. Counters: stream-aggregated or sharded, shown rounded. Notifications: aggregate instead of one per follow. Caches: never cache the full list; cache counts and first pages. Edge checks remain cheap because they're keyed on the viewer's following list.

How do you compute "Followed by alice, bob and 12 others" on a profile?

It's following(viewer) ∩ followers(profile). Take the viewer's cached following list (bounded, ≤7,500, typically hundreds), and do one batched membership probe against followers(profile) on the profile's shard — a multi-get of a few hundred keys rather than scanning millions of followers. Rank the hits by closeness to the viewer and show the top few plus a count. Cache the result briefly per (viewer, profile).

How do you keep the following and followers copies consistent?

Choose a source of truth (following, which the actor controls). Write it and an outbox event atomically on the actor's shard; a consumer applies the inverse edge idempotently (upsert/delete) with retries, so they converge. Events are keyed by the edge pair so follow/unfollow for the same pair stay ordered. A periodic anti-entropy job scans for edges lacking their inverse (TAO calls these hanging associations) and repairs them. Reads that require authority (e.g. private-account access) consult the source-of-truth side.

How would you build "People You May Know"?

Offline candidate generation over a graph snapshot: friends-of-friends (2-hop), contacts (with consent), follow-backs, co-follow similarity and embedding neighbours. Score with features like mutual-count and Adamic–Adar, plus an ML model predicting follow and engagement; filter out existing follows, blocks, dismissed and spammy accounts. Store the top ~100 per user in a key-value store refreshed daily, and add real-time "similar accounts" suggestions after each follow from a co-follow index.

Walk through what happens when Alice blocks Bob.

The Graph service writes BLOCKS(Alice→Bob) and its inverse, deletes follow edges in both directions and any pending requests (idempotently, as a small saga), and emits events: counters decrement, timelines/trays drop each other's content, notifications and DMs are suppressed, search hides profiles. Because fan-out may already have placed content, every read path also filters with cached block lists (checked in both directions), so the block takes effect immediately regardless of async cleanup.

15. Check your understanding

If the follows table is sharded by follower_id across 256 shards, how many shards does "list B's followers" touch? How does the two-table design change that?

Up to all 256 (scatter-gather), because B's followers are spread by their own IDs. With a followers table sharded by followee (B), it's 1 shard.

1.5B accounts follow 200 accounts each on average. How many rows exist in the two-table design, and roughly how much raw storage at 25 bytes per row?

300B edges × 2 directions = 600B rows; × 25 B ≈ 15 TB raw (≈ 45 TB with 3× replication, before indexes) — modest for a sharded cluster, so the real challenge is read throughput.

Why is negative caching important for "does A follow B?"

Most such checks (profile views of strangers, comment permission checks, DM routing) return "no". Without caching negatives, every "no" misses the cache and hits the database, so the cache barely helps. Caching "no" (and invalidating on follow) keeps those lookups in memory.

A celebrity's follower count is shown as "612M" while the true number changes by thousands per minute. Is that a bug?

No. The count is a denormalised, eventually consistent value (aggregated from follow/unfollow deltas and reconciled periodically) and is displayed rounded. Exactness at that scale is neither perceivable nor worth the write contention.

Scenario: Bob unfollows Carol, but Carol's post was already fanned out into Bob's timeline. What should Bob see, and how?

Bob shouldn't see new content from Carol. The FollowRemoved consumer removes Carol's entries from Bob's timeline asynchronously, and, to be correct immediately, the feed read path filters timeline entries against Bob's current following list (and blocks) before rendering.

Compute the Adamic–Adar contribution of two mutual connections with 50 and 5,000 followers respectively (use natural log).

1/ln(50) ≈ 1/3.91 ≈ 0.256; 1/ln(5000) ≈ 1/8.52 ≈ 0.117. Total ≈ 0.373. A mutual connection with few connections is stronger evidence that you know the candidate than a mutual "hub" account that follows everyone.

Why does Instagram's cap of 7,500 followings help the system?

It bounds out-degree, so following lists stay small enough to cache whole, mutuals computations (iterate following(viewer)) stay cheap, feed pull-merges are bounded, and spam bots can't follow millions of accounts. Followers (in-degree) remain unbounded, which is where bucketing and pull fan-out come in.

🎯 Key takeaways

Related lessons