Designing a Social Media Feed (Twitter / X / Threads)

Difficulty: Intermediate Prerequisites:Fan-Out, Caching, and Message Queues


TL;DR

A social feed shows each user a personalized timeline of posts from people they follow. The core challenge is fan-out - when a user with 10M followers tweets, how do you update 10M timelines quickly?

flowchart LR
    POSTER["User posts tweet"]:::client
    API["Tweet Service"]:::service
    K["Fan-out Service<br/>Kafka"]:::async
    CACHE[("Per-user timeline<br/>Redis")]:::data
    READER["User opens feed"]:::client
    FEED["Feed Service"]:::service

    POSTER -->|"1. POST new tweet"| API
    API -->|"2. Publish tweet event"| K
    K -->|"3. Write to follower feeds"| CACHE
    READER -->|"4. GET home timeline"| FEED
    FEED -->|"5. Read pre-built feed"| CACHE

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef async fill:#AB47BC,stroke:#4A148C,color:#fff
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0

In 3 sentences: When someone tweets, the system either pushes that tweet into every follower’s pre-built timeline cache (fan-out on write) or waits until each follower opens their feed and assembles it on-the-fly (fan-out on read). Most systems use a hybrid: push for regular users, pull for celebrities. The timeline is cached in Redis as a sorted list of tweet IDs per user.


Understanding the Problem

What is a social feed? When you open Twitter/X, Instagram, or LinkedIn, you see a stream of posts from accounts you follow (and maybe recommended content). That stream is your timeline - a personalized, ordered list assembled from thousands of content sources.

Why is it hard?

Real numbers (Twitter/X scale):

Scale Estimation (Back-of-Envelope)


Core Entities


Naive First Cut

flowchart LR
    USER["User opens feed"]:::client
    API["Feed API"]:::service
    DB[("Tweets table<br/>SELECT WHERE author IN followees ORDER BY time")]:::data

    USER --> API
    API --> DB

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0

On each feed request: SELECT * FROM tweets WHERE author_id IN (SELECT followee_id FROM follows WHERE follower_id = ?) ORDER BY created_at DESC LIMIT 50.

Why this breaks:


Prior Art We’re Drawing From


The Big Decision: Fan-out on Write vs Fan-out on Read

This is the core interview question for a feed system. There are two strategies:

Fan-out on Write (push model)

flowchart LR
    TWEET["User tweets"]:::client
    API["Tweet Service"]:::service
    FAN["Fan-out Workers"]:::service
    R1[("Redis timeline<br/>follower 1")]:::data
    R2[("Redis timeline<br/>follower 2")]:::data
    R3[("Redis timeline<br/>follower N")]:::data

    TWEET -->|"1. POST new tweet"| API
    API -->|"2. Trigger fan-out"| FAN
    FAN -->|"3. Prepend to follower 1"| R1
    FAN -->|"4. Prepend to follower 2"| R2
    FAN -->|"5. Prepend to follower N"| R3

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0

How: When user A tweets, immediately push that tweet ID into every follower’s pre-built timeline in Redis.

Pros Cons
Feed reads are instant - just read from Redis Celebrity with 50M followers = 50M Redis writes per tweet
No computation at read time Write latency proportional to follower count
Simple read path Wastes space for inactive users (push to followers who never open the app)

Fan-out on Read (pull model)

flowchart LR
    USER["User opens feed"]:::client
    FEED["Feed Service"]:::service
    FOLLOW[("Follows<br/>who do I follow?")]:::data
    TWEETS[("Tweets<br/>get recent from each")]:::data
    MERGE["Merge and rank"]:::service

    USER -->|"1. GET home timeline"| FEED
    FEED -->|"2. Read follower list"| FOLLOW
    FEED -->|"3. Fetch recent"| TWEETS
    FEED -->|"4. Get prediction"| MERGE

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0

How: When user B opens their feed, fetch recent tweets from all 500 people they follow, merge and rank in real-time.

Pros Cons
No write amplification for celebrities Slow - merging 500 sources per request
Only compute for active users Read latency scales with follow count
Always fresh (no stale cached feed) Expensive at read time under high traffic

The Answer: Hybrid (what Twitter actually does)

flowchart TD
    TWEET["New tweet posted"]:::client
    CHECK["Check: celebrity?<br/>followers > 10K?"]:::service
    PUSH["Fan-out on WRITE<br/>push to follower timelines"]:::service
    PULL["Fan-out on READ<br/>fetch at read time"]:::service

    TWEET -->|"1. Check followers"| CHECK
    CHECK -->|"2. No: regular user"| PUSH
    CHECK -->|"3. Yes: celebrity"| PULL

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0


💡 The hybrid approach: Regular users (< 10K followers) → fan-out on write. Celebrities (> 10K followers) → fan-out on read. At timeline load, merge the pre-built cache with a small number of celebrity tweet fetches.

This is what Twitter, Instagram, and LinkedIn actually use.


High-Level Architecture

Let’s build this incrementally, adding components as each requirement demands them.

FR1: User Posts a Tweet

The first interaction: a user types a tweet and hits Post. We need to store it durably and announce to the system that a new tweet exists.

New components:

  1. API Gateway - authenticates, rate-limits, routes. Entry point for all client requests.
  2. Tweet Service - handles tweet creation: validates content, stores the tweet, uploads media references.
    💡 This service doesn’t deliver tweets to followers - it just writes the tweet and announces “hey, a new tweet exists.”
  3. Tweet Store (Cassandra) - permanent storage for all tweets. Optimized for high write throughput.
  4. Kafka - event bus. Tweet Service publishes TweetCreated events for downstream consumers. Decouples posting from delivery.
flowchart LR
    POSTER["User"]:::client
    GW["API Gateway"]:::edge
    TS["Tweet Service"]:::service
    TDB[("Tweet Store<br/>Cassandra")]:::data
    K["Kafka"]:::async

    POSTER -->|"1. POST new tweet"| GW
    GW -->|"2. Forward to tweet svc"| TS
    TS -->|"3. Persist tweet"| TDB
    TS -->|"4. Publish tweet event"| K

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef edge fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
    classDef async fill:#AB47BC,stroke:#4A148C,color:#fff

Step-by-step:

  1. User taps Post → request hits API Gateway
  2. Gateway authenticates (JWT) and forwards to Tweet Service
  3. Tweet Service stores the tweet in Cassandra (permanent record)
  4. Tweet Service publishes TweetCreated event to Kafka (includes tweetId, authorId, timestamp)
  5. Responds to user: “Tweet posted!” in ~50ms

Why Kafka? The poster shouldn’t wait while we update millions of follower timelines. Kafka decouples creation from delivery - the user gets instant confirmation, and fan-out happens asynchronously.


FR2: User Opens Their Feed - Pre-Built Timelines

Now users want to see their feed. The naive approach (query all followees’ tweets on every request) is too slow at scale. Instead, we pre-build each user’s timeline so reading it is just a cache lookup.

New components:

  1. Fan-out Service - consumes TweetCreated events from Kafka and pushes tweet IDs into every follower’s pre-built timeline.
  2. Social Graph - stores who-follows-whom. Queried during fan-out: “give me all 5000 followers of this user.”
  3. Timeline Cache (Redis sorted set) - each user’s feed stored as a sorted set of tweet IDs scored by timestamp. Reading the feed = ZREVRANGE - instant.
    💡 A Redis sorted set keeps elements ordered by score. “Get latest 50 tweets” is a single O(log N + 50) command.
  4. Feed Service - handles “show me my feed” requests. Reads from cache, hydrates tweet IDs into full objects, applies ranking.
flowchart LR
    K["Kafka"]:::async
    FANOUT["Fan-out Service"]:::service
    GRAPH[("Social Graph<br/>followers")]:::data
    CACHE[("Timeline Cache<br/>Redis per user")]:::data
    READER["User"]:::client
    GW["API Gateway"]:::edge
    FEED["Feed Service"]:::service
    TDB[("Tweet Store")]:::data

    K -->|"1. Process tweet event"| FANOUT
    FANOUT -->|"2. Lookup followers"| GRAPH
    FANOUT -->|"3. Prepend to follower feeds"| CACHE
    READER -->|"4. GET home timeline"| GW
    GW -->|"5. Forward to feed svc"| FEED
    FEED -->|"6. Read pre-built feed"| CACHE
    FEED -->|"7. Hydrate tweet details"| TDB

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef edge fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
    classDef async fill:#AB47BC,stroke:#4A148C,color:#fff

Step-by-step for fan-out (write path):

  1. Fan-out Service consumes TweetCreated from Kafka
  2. Queries Social Graph: “who follows this author?” → gets follower list
  3. For each follower: ZADD timeline:{followerId} <timestamp> <tweetId>
  4. Trims each timeline to 800 entries (older tweets fall off cache)

Step-by-step for feed read:

  1. User opens app → GET /feed
  2. Feed Service reads from Redis: ZREVRANGE timeline:{userId} 0 49 → 50 tweet IDs in ~1ms
  3. Hydrates IDs → fetches full tweet objects from Cassandra (batch multi-get)
  4. Returns ranked feed to user

But wait - what about celebrities? If a user has 50M followers, fan-out means 50M Redis writes per tweet. That takes minutes and blocks the queue. We need a different approach for them.


FR3: Handle Celebrities - The Hybrid Approach

The celebrity problem is the core design challenge. Fan-out on write breaks for mega-accounts. We need a hybrid.

The rule: Regular users (< 10K followers) → fan-out on write. Celebrities (> 10K followers) → fan-out on read.

No new infrastructure components - just different behavior at the Fan-out Service and Feed Service:

flowchart TD
    TWEET["New tweet"]:::client
    CHECK["Follower count?"]:::service
    PUSH["Fan-out on WRITE<br/>push to Redis timelines"]:::service
    PULL["Skip push<br/>fetched at read time"]:::service

    TWEET -->|"1. Check followers"| CHECK
    CHECK -->|"2. < 10K followers"| PUSH
    CHECK -->|"3. > 10K followers"| PULL

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0

At read time, the Feed Service does:

  1. Get pre-built timeline from Redis (regular users’ tweets)
  2. Get list of celebrity followees for this user
  3. Fetch recent tweets from each celebrity (5-20 accounts × latest 5 tweets = 100 tweets max)
  4. Merge both sets, rank by relevance score
  5. Return top 50

Why 10K as the threshold? It’s a trade-off. Pushing to 10K followers takes ~100ms (acceptable). Pushing to 50M takes minutes (unacceptable). The threshold can be tuned based on your SLA.


Core Flows

Flow 1: User posts a tweet

sequenceDiagram
    autonumber
    participant U as User
    participant TS as Tweet Service
    participant DB as Tweet Store
    participant K as Kafka
    participant FAN as Fan-out Workers
    participant G as Social Graph
    participant R as Redis Timelines

    U->>TS: POST tweet
    TS->>DB: store tweet
    TS->>K: publish TweetCreated event
    K->>FAN: consume
    FAN->>G: get followers of poster
    G-->>FAN: follower list
    FAN->>FAN: filter out celebrities from push
    FAN->>R: ZADD timeline:{followerId} tweetId for each follower
    FAN->>R: ZREMRANGEBYRANK trim to latest 800
  1. Tweet stored permanently in Cassandra/DynamoDB.
  2. Event published to Kafka for async fan-out.
  3. Fan-out workers get the poster’s follower list from the social graph.
  4. For each non-celebrity follower, push the tweet ID into their Redis sorted set (scored by timestamp).
  5. Trim each timeline to 800 entries (older ones fall off; user can fetch from DB if they scroll far enough).

Flow 2: User opens their feed

sequenceDiagram
    autonumber
    participant U as User
    participant F as Feed Service
    participant R as Redis
    participant DB as Tweet Store
    participant RANK as Ranking Service

    U->>F: GET /feed
    F->>R: ZREVRANGE timeline:{userId} 0 50
    R-->>F: cached tweet IDs
    F->>DB: multi-get tweet details by IDs
    DB-->>F: tweet objects
    F->>F: fetch celebrity tweets on read (merge)
    F->>RANK: rank and filter
    RANK-->>F: sorted feed
    F-->>U: feed response
  1. Read the user’s pre-built timeline from Redis (just tweet IDs, sorted by time).
  2. Hydrate: fetch full tweet objects from the tweet store.
  3. Merge in recent tweets from celebrities the user follows (fan-out on read for these).
  4. Apply ranking (relevance score, engagement signals, freshness decay).
  5. Return the ranked feed.

Deep Dives

Deep Dive 1: The Celebrity Problem (hot partition)

Problem: Elon tweets → 100M followers. If we fan-out on write, that’s 100M Redis writes. Takes minutes, and during that time the tweet is “invisible” to most followers.

In simple terms: A celebrity with 100M followers posts. If we try to push that post into 100M timelines, it takes minutes. During that time, most followers don’t see the post. We need a different strategy for mega-accounts.

Bad: Fan-out on write for everyone. Celebrities block the queue for hours.

Good: Skip fan-out for celebrities (> 10K followers). Fetch their tweets at read time.

Great: Tiered approach:


Deep Dive 2: Feed Ranking

Problem: Chronological feed is simple but engagement is lower. Users miss important tweets because they happened while asleep.

In simple terms: Showing posts purely by time means you miss important tweets that happened while you slept. We need to surface the posts you’d actually care about.

Ranking signals (simplified):

flowchart LR
    TWEET["Tweet candidate"]:::client
    FRESH["Freshness<br/>newer = higher"]:::service
    ENGAGE["Engagement<br/>likes retweets replies"]:::service
    SOCIAL["Social closeness<br/>do you interact with poster?"]:::service
    CONTENT["Content type<br/>media > text only"]:::service
    SCORE["Final score = weighted sum"]:::data

    TWEET -->|"1. Compute freshness"| FRESH
    TWEET -->|"2. Compute engagement"| ENGAGE
    TWEET -->|"3. Compute social"| SOCIAL
    TWEET -->|"4. Compute content"| CONTENT
    FRESH -->|"5. Weight"| SCORE
    ENGAGE -->|"6. Weight"| SCORE
    SOCIAL -->|"7. Weight"| SCORE
    CONTENT -->|"8. Serve content"| SCORE

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0

Score = w1 × freshness + w2 × engagement + w3 × social_closeness + w4 × content_type

Twitter uses an ML model (originally “Earlybird”) but a weighted sum is fine for interviews.


Deep Dive 3: Timeline Cache Design (Redis sorted set)

Why Redis sorted set?

Memory math:

What happens when the cache is cold (user hasn’t opened in weeks)? Fall back to fan-out on read: fetch recent tweets from all followees, build a fresh timeline, cache it. Lazy population.


Deep Dive 4: Real-time feed updates

Problem: User is looking at their feed. Someone they follow tweets. Should it appear immediately?

In simple terms: You’re scrolling your feed. A new tweet appears from someone you follow. Should it pop in immediately (disrupting your reading) or show a ‘new tweets’ banner?

Options:

What Twitter does: “New tweets available” banner at the top. User clicks to load. Not auto-injected (disrupts reading position).

Implementation: WebSocket connection subscribes to a channel. Fan-out also publishes to a pub/sub layer. Connected clients get a “3 new tweets” notification.


Final Architecture

flowchart TD
    USERS["Users"]:::client
    GW["API Gateway<br/>auth rate-limit"]:::edge
    TS["Tweet Service"]:::service
    FEED["Feed Service"]:::service
    RANK["Ranking Service<br/>ML model"]:::service
    FANOUT["Fan-out Workers"]:::service
    TDB[("Tweet Store<br/>Cassandra")]:::data
    GRAPH[("Social Graph<br/>who follows whom")]:::data
    CACHE[("Timeline Cache<br/>Redis sorted sets")]:::data
    K["Kafka<br/>tweet events"]:::async
    MEDIA[("Media<br/>S3 plus CDN")]:::data

    USERS -->|"POST or GET"| GW
    GW -->|"Forward to tweet svc"| TS
    GW -->|"Forward to feed svc"| FEED
    TS -->|"Persist tweet"| TDB
    TS -->|"Store media file"| MEDIA
    TS -->|"Publish tweet event"| K
    K -->|"Process tweet event"| FANOUT
    FANOUT -->|"Lookup followers"| GRAPH
    FANOUT -->|"Prepend to follower feeds"| CACHE
    FEED -->|"Read pre-built feed"| CACHE
    FEED -->|"Hydrate tweet details"| TDB
    FEED -->|"Rank by relevance"| RANK

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef edge fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef async fill:#AB47BC,stroke:#4A148C,color:#fff
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0

How it works end-to-end (write path — posting a tweet):

  1. User posts tweet — request hits API Gateway (auth + rate limit applied)
  2. Tweet Service persists — writes tweet to Cassandra (Tweet Store), uploads media to S3/CDN
  3. Event emitted to Kafka — tweet creation event published for async fan-out
  4. Fan-out Workers distribute — reads Social Graph for follower list, pushes tweetId into each follower’s Redis sorted set (Timeline Cache)
  5. Celebrity exception — users with >500K followers skip fan-out; their tweets merged at read time

How it works end-to-end (read path — viewing feed):

  1. User opens feed — Feed Service checks Timeline Cache (Redis sorted set) for pre-built timeline
  2. Hydration — tweet IDs fetched from cache, hydrated with full tweet content from Cassandra
  3. Ranking Service scores — ML model re-ranks by freshness, engagement, and social closeness
  4. Response returned — ranked feed served to the user in <200ms P99

Interview Cheat Sheet

Question Answer
“How do you build the feed?” Hybrid fan-out: push for regular users, pull for celebrities
“Where’s the timeline stored?” Redis sorted set per user (tweet IDs scored by timestamp)
“How do you handle celebrities?” Don’t push to 50M followers. Merge their tweets at read time.
“How do you rank?” Weighted score: freshness + engagement + social closeness
“What about real-time?” WebSocket for “new tweets available” banner, not auto-inject
“Storage for tweets?” Cassandra or DynamoDB - partition by tweetId, immutable, replicated
“Social graph storage?” Adjacency list in Redis or dedicated graph DB. followers:{userId} → Set<userId>
“What’s the read latency?” P99 < 200ms. Pre-built cache → hydrate → rank.

Key Technologies

Term What it is
Fan-out Taking one event (a tweet) and delivering it to many recipients (followers). “Fan-out on write” = push at creation time. “Fan-out on read” = pull at view time.
Redis Sorted Set A Redis data structure that stores elements with a score. Lets you get the top-N elements efficiently (perfect for “latest 50 tweets”).
Social Graph The network of who-follows-whom. Stored as adjacency lists. Queried as “give me all followers of user X.”
Kafka Event streaming platform. Tweet creation events go here for async fan-out workers to consume.
Cassandra Wide-column NoSQL database. Stores tweets durably. Good for high write volume and partition-per-user access patterns.
CDN Content Delivery Network. Serves media (images, videos) from edge servers close to users.
Hydration Converting a list of IDs into full objects. “Hydrate tweet IDs → fetch full tweet with text, likes, media URLs.”

What’s Expected at Each Level

This section helps you calibrate your depth. You don’t need to cover everything - just know what’s expected for your level.

Mid-level

Produce a working design with tweet storage and basic feed assembly. Recognize that JOIN-based feed queries don’t scale. With prompting, propose pre-computing timelines (fan-out on write) so that feed reads are a simple cache lookup rather than a complex multi-table query.

Senior

Articulate the fan-out-on-write vs fan-out-on-read tradeoff without prompting. Propose the hybrid approach for celebrities (>10K followers skip fan-out, merged at read time). Discuss Redis sorted sets or Cassandra for timeline cache. Explain how to handle the celebrity problem (50M followers) and why naive fan-out would generate 50M writes per tweet.

Staff+

Address feed ranking vs chronological ordering trade-offs and the ML pipeline needed for relevance scoring. Discuss real-time feed injection (new tweets appearing without refresh via WebSocket/SSE), tweet deletion propagation across cached timelines, and the operational cost of fan-out at Twitter scale (500M users × 400 followers = 200B cache writes/day). Cover cache eviction strategies for inactive users.


🎯 Key Takeaways



Understand the building blocks used in this design:

Discussion

Newest first
You

Free system design + DSA prep. If it helped you crack an interview, consider supporting.