Follow System Low-Level Design

What is a Follow System?

A follow system lets users subscribe to updates from other users or entities: follow a person (Twitter, Instagram), watch a repository (GitHub), subscribe to a topic (Reddit). Key operations: follow/unfollow, get followers, get following, check if A follows B, and recommend who to follow. At scale (hundreds of millions of users with celebrity accounts having 50M+ followers), these operations must be fast and the data structure must handle asymmetric relationships efficiently.

Requirements

  • Follow and unfollow: <10ms
  • Get followers of user X (paginated)
  • Get following list of user X (paginated)
  • Check if user A follows user B: <5ms
  • Follower count and following count displayed on profile
  • Mutual follows (friends): users who follow each other
  • 100M users, up to 50M followers per celebrity

Data Model

Follow(
    follower_id UUID NOT NULL,   -- user who is following
    followee_id UUID NOT NULL,   -- user being followed
    created_at  TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (follower_id, followee_id)
)

-- Index for both query directions:
CREATE UNIQUE INDEX idx_follow_pk ON Follow(follower_id, followee_id);
CREATE INDEX idx_follow_reverse ON Follow(followee_id, follower_id, created_at DESC);

-- Denormalized counts (updated on every follow/unfollow)
UserStats(
    user_id         UUID PRIMARY KEY,
    follower_count  INT DEFAULT 0,
    following_count INT DEFAULT 0
)

Follow / Unfollow

def follow(follower_id, followee_id):
    if follower_id == followee_id:
        raise ValidationError('Cannot follow yourself')

    try:
        db.insert(Follow(follower_id=follower_id, followee_id=followee_id,
                         created_at=now()))
        # Increment counts atomically
        db.execute('UPDATE UserStats SET following_count = following_count + 1 WHERE user_id=?', follower_id)
        db.execute('UPDATE UserStats SET follower_count = follower_count + 1 WHERE user_id=?', followee_id)
    except UniqueViolation:
        pass  # already following — idempotent

def unfollow(follower_id, followee_id):
    deleted = db.execute(
        'DELETE FROM Follow WHERE follower_id=? AND followee_id=?',
        follower_id, followee_id
    ).rowcount
    if deleted:
        db.execute('UPDATE UserStats SET following_count = following_count - 1 WHERE user_id=?', follower_id)
        db.execute('UPDATE UserStats SET follower_count = follower_count - 1 WHERE user_id=?', followee_id)

Check Follow Status (Hot Path)

# DB check: O(1) primary key lookup
def is_following(follower_id, followee_id):
    return db.exists(
        'SELECT 1 FROM Follow WHERE follower_id=? AND followee_id=?',
        follower_id, followee_id
    )

# Cache in Redis for profile page rendering (check A→B and B→A on every profile view)
def is_following_cached(follower_id, followee_id):
    key = f'follow:{follower_id}:{followee_id}'
    cached = redis.get(key)
    if cached is not None:
        return cached == '1'
    result = is_following(follower_id, followee_id)
    redis.setex(key, 300, '1' if result else '0')
    return result

# On follow/unfollow: invalidate cache
redis.delete(f'follow:{follower_id}:{followee_id}')

Paginated Follower / Following Lists

def get_followers(user_id, cursor=None, limit=50):
    # cursor encodes (created_at, follower_id) of the last seen row
    query = '''
        SELECT follower_id, created_at FROM Follow
        WHERE followee_id = :uid
    '''
    if cursor:
        decoded = decode_cursor(cursor)
        query += ' AND (created_at, follower_id) < (:ts, :fid)'
    query += ' ORDER BY created_at DESC, follower_id DESC LIMIT :limit'
    rows = db.query(query, uid=user_id, **cursor_params, limit=limit)
    return rows, make_cursor(rows[-1]) if len(rows) == limit else None

def get_mutual_follows(user_id, cursor=None, limit=50):
    # Users who follow each other
    rows = db.query('''
        SELECT f1.followee_id AS friend_id FROM Follow f1
        JOIN Follow f2 ON f1.followee_id = f2.follower_id
                      AND f2.followee_id = f1.follower_id
        WHERE f1.follower_id = :uid
        ORDER BY f1.created_at DESC
        LIMIT :limit OFFSET :offset
    ''', uid=user_id, limit=limit, offset=offset)

Who to Follow Recommendations

Simple graph-based recommendation: “users followed by people you follow” (2nd-degree connections):

def recommend_follows(user_id, limit=10):
    return db.query('''
        SELECT followee_id, COUNT(*) AS mutual_count
        FROM Follow
        WHERE follower_id IN (
            SELECT followee_id FROM Follow WHERE follower_id=:uid
        )
        AND followee_id != :uid
        AND followee_id NOT IN (
            SELECT followee_id FROM Follow WHERE follower_id=:uid
        )
        GROUP BY followee_id
        ORDER BY mutual_count DESC
        LIMIT :limit
    ''', uid=user_id, limit=limit)

Key Design Decisions

  • Composite primary key (follower_id, followee_id) — O(1) follow existence check; unique constraint prevents duplicates
  • Reverse index (followee_id, follower_id) — enables efficient “get all followers of X” without full scan
  • Denormalized counts in UserStats — follower/following counts are shown on every profile; avoid COUNT(*) queries
  • Redis cache for is_following — profile pages check mutual follow status; cache reduces DB load dramatically
  • Idempotent follow — INSERT with unique constraint; duplicate follow is a no-op not an error

{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you efficiently check if user A follows user B?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use a composite primary key (follower_id, followee_id) on the Follow table — a primary key lookup is O(1). For profile pages that display mutual follow status (does A follow B? does B follow A?), cache both checks in Redis with TTL=5min: key=follow:{follower}:{followee}, value=1 or 0. Profile pages make two lookups; with Redis caching, both are sub-millisecond. Invalidate the cache on any follow/unfollow event for those two users.”}},{“@type”:”Question”,”name”:”How do you get the follower count without a slow COUNT(*) query?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Denormalize: maintain a follower_count column in a UserStats table, updated atomically on every follow/unfollow with UPDATE UserStats SET follower_count=follower_count+1. This makes profile page rendering O(1) — no aggregation needed. Accuracy: occasional drift is possible if the follow and count update aren’t in the same transaction. Fix with a periodic reconciliation job: UPDATE UserStats SET follower_count=(SELECT COUNT(*) FROM Follow WHERE followee_id=user_id). Run nightly for large users.”}},{“@type”:”Question”,”name”:”How do you paginate through a user’s followers list efficiently?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Create an index on (followee_id, created_at DESC, follower_id) — queries get followers of a given user sorted by recency. Use cursor pagination: encode the (created_at, follower_id) of the last seen row as the cursor. Next page: WHERE followee_id=X AND (created_at, follower_id) < (cursor_ts, cursor_id). This is a keyset scan — O(1) regardless of page depth. Without the index, every page requires a full scan of all followers for that user.”}},{“@type”:”Question”,”name”:”How do you find mutual follows (friends) efficiently?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Self-join the Follow table: SELECT f1.followee_id FROM Follow f1 JOIN Follow f2 ON f1.followee_id=f2.follower_id AND f2.followee_id=f1.follower_id WHERE f1.follower_id=:user. This works but is expensive for users with many followers. Alternative: set intersection in Redis. Maintain following set per user in a Redis set. SINTER following:{user_a} followers:{user_b} gives mutual connections. For large sets, use SSCAN instead of SINTER to avoid blocking Redis.”}},{“@type”:”Question”,”name”:”How do you recommend accounts to follow?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Friends-of-friends: find users followed by accounts you follow, who you don’t already follow, sorted by how many of your follows also follow them. In SQL: subquery to get your followees, then find who they follow (2nd degree), filter out ones you already follow, GROUP BY and ORDER BY count. Cache results per user (TTL=1h). More sophisticated: collaborative filtering (users with similar follow patterns), popularity (follower count in your network), topic affinity (users who post about topics you engage with).”}}]}

Follow system and social graph design is discussed in Meta system design interview questions.

Follow system and social graph architecture is covered in Twitter system design interview preparation.

Follow and friend system design is discussed in Snap system design interview guide.

Scroll to Top