We’re designing the home timeline for a social media platform like Twitter/X. When we open the app, we see a feed of recent posts from people we follow, ranked in some order. Sounds simple, but this is one of the hardest problems in system design.
The core challenge: when we follow 500 people and each posts 5 times a day, how does the system assemble our personalized feed from those 2,500 posts in under 200ms? Multiply that by 200M daily active users opening the app dozens of times a day. That’s the problem.
Step 1: Requirements
Functional Requirements
- Users can create posts (text, images, videos)
- Users can follow/unfollow other users
- Home feed shows posts from people we follow, in a ranked order
- Feed supports pagination (infinite scroll)
- Real-time updates — new posts appear without refreshing
- Like, retweet, and reply on posts
Non-Functional Requirements
- Low latency — feed should load in < 200ms
- High availability — the feed is THE product, it can’t go down
- Eventually consistent — it’s okay if a new post takes a few seconds to appear in everyone’s feed
- Scale — 500M users, 200M DAU, 1B tweets generated per day
Step 2: Estimation
Assumptions:
- 500M total users, 200M DAU
- Average user follows 200 people
- Average user posts 5 tweets/day
- Average user reads their feed 10 times/day
- Each tweet: ~300 bytes text + metadata
QPS:
Tweet creation: 200M × 5 / 86,400 ≈ ~12,000 writes/sec
Feed reads: 200M × 10 / 86,400 ≈ ~23,000 reads/sec
Peak: ~50,000 reads/sec
Storage:
Tweets/day: 200M × 5 = 1B tweets/day
Text storage: 1B × 300 bytes = 300 GB/day
Media (20% of tweets, avg 500 KB): 1B × 0.2 × 500 KB = 100 TB/day
Feed cache:
Each user's feed = 200 tweet IDs × 8 bytes = 1.6 KB
200M DAU × 1.6 KB = 320 GB (fits in a Redis cluster)
Step 3: High-Level Design
The system has two distinct paths — the write path (someone posts a tweet) and the read path (someone opens their feed). The magic (and the hard part) is in how these two paths connect. That’s where fan-out comes in.
Step 4: API Design
POST /api/v1/tweets
Body: { "content": "Hello world!", "media_ids": ["media_123"] }
Response: { "tweet_id": "tw_789", "created_at": "2026-03-30T10:00:00Z" }
GET /api/v1/feed?cursor=<last_tweet_id>&limit=20
Response: {
"tweets": [
{ "tweet_id": "tw_789", "user": {...}, "content": "...", "likes": 42, "created_at": "..." },
...
],
"next_cursor": "tw_750"
}
POST /api/v1/users/{user_id}/follow
POST /api/v1/users/{user_id}/unfollow
POST /api/v1/tweets/{tweet_id}/like
POST /api/v1/tweets/{tweet_id}/retweet
POST /api/v1/media/upload — returns media_id (pre-signed URL for upload)
Cursor-based pagination is important here. We can’t use offset-based pagination (page=1, page=2) because new tweets keep getting added. If a new tweet gets inserted while we’re scrolling, offset-based pagination would show us duplicates or skip tweets. With cursors, we say “give me the next 20 tweets after tweet_750” — that’s stable regardless of new inserts.
Step 5: Data Model
-- Users table
CREATE TABLE users (
user_id BIGINT PRIMARY KEY,
username VARCHAR(50) UNIQUE,
display_name VARCHAR(100),
avatar_url TEXT,
follower_count INT DEFAULT 0,
following_count INT DEFAULT 0,
created_at TIMESTAMP
);
-- Follow graph (who follows whom)
CREATE TABLE follows (
follower_id BIGINT, -- the person doing the following
followee_id BIGINT, -- the person being followed
created_at TIMESTAMP,
PRIMARY KEY (follower_id, followee_id),
INDEX idx_followee (followee_id) -- find all followers of a user
);
-- Tweets table
CREATE TABLE tweets (
tweet_id BIGINT PRIMARY KEY, -- Snowflake ID (ordered by time)
user_id BIGINT NOT NULL,
content TEXT,
media_urls JSON, -- array of media URLs
reply_to BIGINT, -- null if not a reply
retweet_of BIGINT, -- null if not a retweet
like_count INT DEFAULT 0,
retweet_count INT DEFAULT 0,
created_at TIMESTAMP,
INDEX idx_user_time (user_id, created_at DESC)
);
-- Feed cache (Redis sorted set per user)
-- Key: feed:{user_id}
-- Value: sorted set of tweet_ids, scored by timestamp
-- Each entry: (score=timestamp, member=tweet_id)
Why Snowflake IDs? We can’t use auto-increment IDs across distributed databases (they’d collide). Twitter invented Snowflake IDs — 64-bit IDs that encode the timestamp, machine ID, and a sequence number. They’re globally unique AND chronologically sorted. Perfect for cursoring through a feed.
Step 6: Deep Dives
Deep Dive 1: Fan-Out Strategies
This is THE question in a feed system design. When someone posts a tweet, how does it end up in their followers’ feeds?
Fan-Out on Write (Push Model)
When a user posts a tweet, we immediately push the tweet ID into every follower’s feed cache.
@Alice posts a tweet (tweet_id = 789)
Alice has 1,000 followers
Fan-out service:
→ Get Alice's follower list (1,000 user IDs)
→ For each follower: ZADD feed:{follower_id} timestamp 789
→ 1,000 Redis writes
Pros: Reading the feed is blazing fast — just ZRANGE feed:{user_id} 0 19. The feed is pre-computed.
Cons: Posting a tweet is expensive. If we have 1,000 followers, that’s 1,000 cache writes. And if a celebrity with 50M followers posts? That’s 50M writes per tweet. Not gonna work.
Fan-Out on Read (Pull Model)
We don’t pre-compute anything. When a user opens their feed, we fetch the latest tweets from everyone they follow, merge them, and sort by time.
User opens feed. They follow 200 people.
→ Get the list of 200 people they follow
→ For each: fetch their latest N tweets
→ Merge all tweets, sort by rank/time
→ Return top 20
Pros: Posting a tweet is simple — just one write. No fan-out cost. Cons: Reading the feed is slow. We’re doing 200+ queries and merging results on every feed load. For 200M users loading feeds, that’s brutal.
The Hybrid Approach (What Twitter Actually Does)
The smart move: use push for normal users and pull for celebrities.
Is the poster a "celebrity" (> 10K followers)?
→ YES: Don't fan out. Their tweets get pulled at read time.
→ NO: Fan out immediately to all followers' feed caches.
When a user reads their feed:
1. Get pre-computed feed from Redis (pushed tweets)
2. Fetch latest tweets from celebrities they follow (pulled tweets)
3. Merge, rank, and return
This gives us the best of both worlds. The 99% of users with normal follower counts get fast fan-out on write. The 1% of celebrities don’t clog the fan-out pipeline. And at read time, we only need to pull from a handful of celebrity accounts (most people follow maybe 10-20 celebrities at most).
The threshold: Twitter uses somewhere around 10K-50K followers as the cutoff. Above that, the user is treated as a celebrity and excluded from fan-out on write.
Deep Dive 2: Feed Ranking
A purely chronological feed is simple but not engaging. Modern feeds use ranking algorithms to show the most relevant content first.
Ranking signals:
| Signal | Weight | Why |
|---|---|---|
| Recency | High | Newer posts are more relevant |
| Engagement | High | Posts with many likes/retweets are probably good |
| User relationship | Medium | Posts from people we interact with often |
| Content type | Low | Images/videos might get boosted |
| Past interaction | Medium | Did we like similar posts before? |
How ranking works in practice:
- Candidate generation — gather the ~2,000 most recent tweet IDs from the feed cache
- Feature extraction — for each tweet, compute ranking features (engagement, recency, etc.)
- Scoring — a ranking model (could be a simple weighted score or an ML model) assigns each tweet a score
- Sorting — return the top N by score
Score = w1 × recency_score
+ w2 × engagement_score
+ w3 × relationship_score
+ w4 × content_type_bonus
For an interview, we don’t need to design the ML model. We just need to show we understand that ranking happens after candidate generation, and that it runs on a relatively small set of candidates (not the entire tweet database).
The “For You” vs “Following” split: Many platforms now offer both a ranked feed and a chronological feed. The chronological one is just the raw feed sorted by time. The ranked one goes through the scoring pipeline.
Deep Dive 3: Real-Time Feed Updates
When someone we follow posts a new tweet, should it appear in our feed without refreshing?
Approach: WebSocket for active users
If the user has the app open and is on the feed screen, we maintain a WebSocket connection. When a new tweet gets fanned out to their feed cache, we also send a lightweight notification through the WebSocket:
{ "type": "new_tweet", "tweet_id": "tw_999", "preview": "Just posted a new..." }
The client can then:
- Show a “New tweets” banner at the top (like Twitter does)
- Or silently prepend the tweet to the feed
We don’t push the full tweet data through the WebSocket — just the tweet ID. The client fetches the full data when the user taps “Show new tweets.”
For inactive users: No real-time update needed. When they next open the app, a regular feed fetch gets the latest content.
Rate limiting updates: If someone follows 200 very active accounts, we don’t want to send 50 WebSocket notifications per minute. We batch them: “You have 12 new tweets” instead of notifying for each one.
Step 7: Scaling
Tweet storage:
- Shard the tweets table by
tweet_id(Snowflake IDs make this easy — consistent hashing on the ID) - Recent tweets (last 7 days) in hot storage (SSD). Older tweets in cold storage (HDD).
- Media in object storage (S3) + CDN for delivery
Feed cache (Redis):
- Shard by
user_idacross a Redis Cluster - Each user’s feed is a sorted set with ~800 tweet IDs (keep the last few days)
- Total: 200M users × 1.6 KB = ~320 GB across the cluster
- Set a max size per feed (e.g., 800 entries) — old entries get evicted automatically
Fan-out service:
- This is the most CPU-intensive part. When a tweet is published, it goes to a message queue (Kafka)
- Fan-out workers consume from the queue and push to Redis
- Scale workers horizontally based on queue depth
- Priority queue: tweets from popular accounts get processed first (more people waiting)
Follow graph:
- The follows table gets hammered on both reads (who do I follow?) and writes (follow/unfollow)
- Cache the follower lists in Redis for fast fan-out
- For very large follower lists (celebrities), store them in a distributed cache with pagination
Database read replicas:
- User profiles and tweet metadata get read way more than written
- Add read replicas to handle the read load
- Write to the primary, read from replicas (eventual consistency is fine for a feed)
Global deployment:
- Deploy in multiple regions. Each region has its own feed cache and tweet replicas.
- Fan-out happens locally in each region
- Tweet replication across regions: publish to Kafka → cross-region replication → local consumers process the fan-out
- User connects to the nearest region (GeoDNS)
Handling viral tweets:
- A tweet going viral means millions of likes/retweets in minutes
- Don’t update the like_count on every single like — that’d be millions of writes to one row
- Instead, use a counter service: batch count updates in Redis, flush to DB every few seconds
- The displayed count can be slightly stale (“1.2M likes” doesn’t need to be exact)
In simple language, a social media feed is an exercise in pre-computation. We pre-build each user’s feed when tweets are created (fan-out on write) so that reading the feed is just a cache lookup. The celebrity problem breaks this — we can’t fan out to 50M followers — so we pull their tweets at read time and merge. The ranking layer decides what goes on top. And the whole thing is held together by Redis (feed cache), Kafka (async fan-out), and a lot of horizontal scaling. The insight is that we trade write cost for read speed, because reads outnumber writes by a huge margin.