Low Level Design: User Follow / Subscribe Service

What Is a User Follow Service?

A user follow (or subscribe) service manages directed relationships between users: when Alice follows Bob, Alice sees Bob’s content in her feed, and Bob’s follower count increments. This component sits at the center of every social network, creator platform, and notification system.

Requirements Clarification

Functional

  • Follow and unfollow a user.
  • Check whether user A follows user B (is-following query).
  • Retrieve the list of users that a given user follows (following list).
  • Retrieve the list of users that follow a given user (follower list).
  • Return accurate follower and following counts.
  • Detect mutual follows (friends).
  • Optionally support follow requests (private accounts must approve before the follow is active).
  • Provide follow recommendations.

Non-Functional

  • 10,000 follow/unfollow writes per second.
  • 100,000 is-following reads per second.
  • Follower/following lists may be paginated; support up to millions of followers per celebrity account.
  • Eventual consistency acceptable for counts; strong consistency required for is-following checks.

Data Model

Table: follows
  follower_id   BIGINT NOT NULL
  followee_id   BIGINT NOT NULL
  created_at    DATETIME NOT NULL
  status        ENUM('active','pending','blocked') NOT NULL DEFAULT 'active'
  PRIMARY KEY (follower_id, followee_id)
  INDEX (followee_id, follower_id)   -- for follower list lookups
  INDEX (follower_id, created_at)    -- for following list sorted by time

The composite primary key (follower_id, followee_id) enforces uniqueness and enables O(1) is-following lookups. The secondary index on (followee_id, follower_id) is the inverse direction needed for follower-list queries.

Core Operations

Follow

  1. Validate that neither user is blocked by the other.
  2. For public accounts: INSERT INTO follows (follower_id, followee_id, status) VALUES (A, B, 'active') ON DUPLICATE KEY UPDATE status='active'.
  3. For private accounts: insert with status='pending'; send a follow-request notification to the followee.
  4. Increment follower count for B and following count for A in a counters table (or Redis).
  5. Emit a follow event to the event bus for downstream consumers (feed service, notification service, recommendation engine).

Unfollow

  1. DELETE FROM follows WHERE follower_id=A AND followee_id=B.
  2. Decrement counters atomically.
  3. Emit an unfollow event.

Is-Following

SELECT 1 FROM follows WHERE follower_id=A AND followee_id=B AND status='active' LIMIT 1. This hits the primary key index and is extremely fast. For even lower latency, cache the result in Redis with a short TTL (e.g., 10 seconds) or use a Bloom filter to eliminate negative lookups.

Follower and Following Counts

Counting rows on every request is too slow for high-traffic accounts. Use a dedicated counters table:

Table: user_follow_counts
  user_id         BIGINT PRIMARY KEY
  follower_count  BIGINT NOT NULL DEFAULT 0
  following_count BIGINT NOT NULL DEFAULT 0
  updated_at      DATETIME

Update atomically using UPDATE user_follow_counts SET follower_count = follower_count + 1 WHERE user_id = B. For celebrity accounts receiving thousands of follows per second, batch these increments: buffer in Redis with INCR and flush to the DB periodically.

Mutual Follow Detection

A mutual follow (friendship) exists when both (A follows B) and (B follows A) are active rows.

  • On-the-fly: query is-following in both directions. Two indexed point lookups; acceptable for most cases.
  • Materialized: maintain a separate friends table with the canonical pair stored as (min(A,B), max(A,B)). Insert a row when both directions exist; delete when either direction is removed. Enables efficient friend-list queries without a self-join.

For the materialized approach, use a transaction or an event-driven reconciler: on each follow write, check if the reverse follow exists and update the friends table accordingly.

Celebrity / High-Fan-Out Handling

A celebrity with 50 million followers creates write amplification challenges for the feed service, not directly for the follow service. But the follow service still needs to handle:

  • Hot partition problem: all follow writes for a celebrity land on the same database shard (keyed by followee_id). Mitigate by sharding the follows table by follower_id for write distribution, maintaining a separate followee-indexed replica or secondary index cluster for read fan-out.
  • Counter contention: millions of concurrent increments to a single counter row. Use Redis INCR with periodic DB sync, or a counter sharding approach (multiple partial counter rows summed at read time).
  • List pagination: follower lists for celebrities can be billions of rows. Cursor-based pagination using (followee_id, follower_id) as the cursor avoids OFFSET scans. Store the list in a sorted set in Redis for O(log n) range queries.

Sharding Strategy

Two query patterns pull in opposite directions:

  • Following list (who does A follow?) — wants rows grouped by follower_id.
  • Follower list (who follows B?) — wants rows grouped by followee_id.

Solutions:

  1. Dual-write: write each follow to two tables, one sharded by follower_id and one by followee_id. Adds write complexity but both reads are local.
  2. Global secondary index: shard by follower_id; maintain a GSI on followee_id (as in DynamoDB or Cassandra with a separate table).
  3. Graph database: model users as nodes and follows as directed edges. Neo4j or Amazon Neptune handles both directions efficiently and enables multi-hop traversal for recommendations.

Follow Recommendations

Classic signals for who to recommend:

  • Friends of friends: users followed by people you follow, ranked by overlap count. Computable with a graph traversal or a SQL query: SELECT followee_id, COUNT(*) as mutual FROM follows WHERE follower_id IN (SELECT followee_id FROM follows WHERE follower_id=ME) GROUP BY followee_id ORDER BY mutual DESC.
  • Contact book matching: hash phone numbers / emails at upload; join against a hashed contact table.
  • Interest graph: cluster users by the topics they engage with; recommend across clusters.
  • Collaborative filtering: matrix factorization or embedding-based models trained on follow graph edges.

Recommendations are typically computed offline by a batch job or a streaming ML pipeline, stored in a recommendations table, and served read-only by the follow service API.

Private Accounts and Follow Requests

State machine for a follow relationship to a private account:

  [none] --follow--> [pending] --approve--> [active]
                         |
                         +--reject/withdraw--> [none]

  [active] --unfollow--> [none]
  [active] --block--> [blocked]

The pending state is stored in the follows table as status='pending'. The followee receives a notification and can approve or reject. Only active rows are counted in follower counts and used for feed generation.

Blocking

  • When A blocks B: remove any active follow in either direction, insert a block record, prevent future follow attempts.
  • Store blocks in a separate blocks table to keep the follows table clean.
  • All API endpoints must check the block table before returning results or processing requests.

Caching

  • Is-following: cache in Redis as a hash: HSET user:A:following B 1. Invalidate on unfollow. Use a Bloom filter for accounts with large following lists to quickly answer negative queries.
  • Follower/following lists: store as a Redis sorted set keyed by (user_id, direction), scored by follow timestamp. Supports paginated range queries. Cap at ~5000 entries per key; fall back to DB for the long tail.
  • Counts: cache in Redis with a write-through policy. Expire after 60 seconds to bound staleness.

API Design

POST   /v1/follows          body: {follower_id, followee_id}
DELETE /v1/follows          body: {follower_id, followee_id}
GET    /v1/follows/check?follower_id=A&followee_id=B
GET    /v1/users/{id}/followers?cursor=...&limit=50
GET    /v1/users/{id}/following?cursor=...&limit=50
GET    /v1/users/{id}/counts          -- returns follower_count, following_count
GET    /v1/users/{id}/recommendations
POST   /v1/follows/requests/{id}/approve
POST   /v1/follows/requests/{id}/reject

Consistency and Transactions

  • The follow insert and counter increment should be atomic. Use a DB transaction or, if counters are in Redis, use a distributed transaction pattern or accept the rare counter inconsistency and reconcile periodically.
  • Emit follow events to Kafka after the DB write commits. Use the outbox pattern to guarantee at-least-once event delivery without two-phase commit.

Interview Discussion Points

  • Why not use a graph database for everything? Graph DBs excel at traversal but may struggle with write throughput at Twitter scale; most large social networks use sharded relational or key-value stores for the hot path.
  • How do you handle a user with 100M followers unfollowing someone? The unfollow itself is a single row delete; the expensive part is propagating the change to the feed service, which uses lazy fan-out for celebrities.
  • How do you prevent follow spam bots? Rate limit follow actions per user per hour, detect suspicious patterns (following thousands of accounts in minutes), and integrate with an abuse detection service.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “What is a user follow service and how is the follow relationship stored?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A user follow service manages directed edges in a social graph where a follower user subscribes to content from a followee user. The core relationship is stored as a (follower_id, followee_id, created_at) record in a follows table with unique constraint on the pair to prevent duplicates. Two access patterns dominate: given a user, list all their followees (following list) and list all their followers (follower list). These are served by indexes on both follower_id and followee_id. At scale these lists are also materialized into a graph store (adjacency list service or dedicated graph database) or a key-value store where followees and followers are stored as sorted sets keyed by user ID, enabling O(log N) insertion and O(1) membership checks. A separate denormalized counter table tracks follower_count and following_count per user for display purposes.”
}
},
{
“@type”: “Question”,
“name”: “How are follower and following counts maintained accurately at scale?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Counts are maintained with atomic increments and decrements applied to a counts table or a Redis hash at the time of each follow or unfollow event, avoiding full re-aggregation. A follow event atomically increments the followee’s follower_count and the follower’s following_count; an unfollow decrements both. Because follower counts can become very large for celebrity accounts and are read far more often than they are written, counts are cached in Redis with a short TTL and written through to the persistent store asynchronously. Periodic reconciliation jobs recompute counts from the raw follows table and correct any drift caused by partial failures. For display, approximate counts (1.2M instead of 1,234,567) are acceptable and reduce contention on the counter row since multiple in-flight requests can coalesce updates.”
}
},
{
“@type”: “Question”,
“name”: “How does mutual follow detection work?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Mutual follow (both users follow each other) is detected by checking whether the reverse edge exists in the follows table at the time a new follow is created. When user A follows user B, the service queries for (follower_id=B, followee_id=A); if that record exists the follow relationship is marked as mutual. To avoid a separate query on every follow action, a is_mutual boolean flag is maintained on each follow record and updated bidirectionally: when A follows B the service sets A→B.is_mutual = true if B→A exists, and also updates B→A.is_mutual = true. This denormalization trades a small write overhead for fast mutual-follow reads used in features like friend suggestions, DM eligibility, and close-friends lists. A cache layer stores the mutual-follow set per user as a sorted set for O(1) lookup.”
}
},
{
“@type”: “Question”,
“name”: “How does a user follow service handle celebrity accounts with millions of followers?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Celebrity accounts (high-fanout nodes) create hotspots in several parts of the system. For feed delivery the push-on-write (fanout-on-write) model becomes impractical: pushing a single post to 50 million follower inboxes in real time is too slow and expensive. The solution is a hybrid fanout model where regular users get push delivery but celebrity posts are fetched on read (pull-on-read) and merged into the feed at query time. The follow service itself must handle high write throughput when a celebrity post triggers millions of simultaneous follow checks; this is mitigated by rate-limiting follow/unfollow actions and batching count updates. The follower list for a celebrity is paginated and sharded across multiple storage nodes. Follower list reads use cursor-based pagination rather than offset pagination to remain efficient regardless of list size.”
}
}
]
}

See also: Meta Interview Guide 2026: Facebook, Instagram, WhatsApp Engineering

See also: Netflix Interview Guide 2026: Streaming Architecture, Recommendation Systems, and Engineering Excellence

See also: LinkedIn Interview Guide 2026: Social Graph Engineering, Feed Ranking, and Professional Network Scale

Scroll to Top