- Fan-out on write (push): precompute every follower's timeline when someone posts. Reads are O(1) cache hits; writes cost one insert per follower.
- Fan-out on read (pull): merge followees' recent posts when the reader opens the app. Writes are cheap; reads are slow and expensive.
- Real systems use hybrid fan-out: push for normal accounts, pull for celebrities above a follower threshold, merged at read time.
- Cache only tweet IDs per timeline (a few hundred per active user), hydrate from a tweet cache, and skip precomputation for inactive users.
- Paginate with cursors (max_id or an opaque token), never offsets; ranked feeds need a per-session snapshot.
"Design Twitter" sounds broad, but in practice the interviewer wants one thing: the home timeline. Posting a tweet is a simple insert. Showing each of hundreds of millions of users the latest posts from the hundreds of accounts they follow, in under 200 ms, is the hard part. The same problem sits behind Instagram's feed, LinkedIn's feed and Facebook's News Feed, so this design transfers directly.
The core of the answer is a cost trade-off between doing work at write time and doing it at read time. Let us put numbers on both before choosing.
Requirements
Functional
- Post a tweet (text, media references); follow and unfollow users.
- Home timeline: posts from accounts you follow, newest first (then ranked as an extension).
- User timeline: one user's own posts.
- Infinite scroll with stable pagination.
Non-functional
- Home timeline p99 latency around 200 ms; this is the most-hit endpoint.
- Very read-heavy: timeline reads outnumber posts by 100× or more.
- Eventual consistency is acceptable: a follower seeing a new tweet 5 seconds late is fine. Your own tweet should appear in your own timeline immediately (read-your-writes).
- Highly available; a stale feed is better than an error page.
Capacity Estimation
Interview assumptions for a Twitter-scale product:
| Quantity | Assumption | Result |
|---|---|---|
| Daily active users | 300M | |
| Tweets per day | 200M | ≈ 2,300/s avg, ~10K/s peak |
| Timeline loads per user per day | 10 | 3B/day ≈ 35K/s avg, ~100K/s peak |
| Average followers per poster | 200 | 40B timeline inserts/day ≈ 460K/s |
| Followees per user | 200 (median far lower, tail far higher) |
Timeline cache size. Keep the latest 800 entries per active user, each entry a 64-bit tweet ID plus a 64-bit author ID = 16 bytes. 800 × 16 B = 12.8 KB per user. For 300M active users: 300M × 12.8 KB ≈ 3.8 TB, about 11.5 TB with 3 replicas. That is a large but very ordinary Redis or Memcached cluster (a few hundred nodes). Twitter engineers have publicly described a Redis-based home timeline cache capped at a few hundred tweets per user, which matches this shape.
Tweet storage. 200M × ~300 bytes (text plus metadata, media stored separately) ≈ 60 GB/day ≈ 22 TB/year before replication. Comfortably shardable.
API and Data Model
POST /v1/tweets { text, media_ids[] } → { tweet_id }
DELETE /v1/tweets/{tweet_id}
POST /v1/users/{id}/follow
DELETE /v1/users/{id}/follow
GET /v1/timeline/home?cursor=<opaque>&limit=20
→ { tweets:[...hydrated...], next_cursor }
GET /v1/users/{id}/tweets?max_id=<id>&limit=20
tweets (tweet_id PK, author_id, text, media_ids, created_at, deleted)
-- tweet_id is a Snowflake ID: time-sortable, so "newest first"
-- is just ORDER BY tweet_id DESC
user_tweets (author_id, tweet_id) PK (author_id, tweet_id DESC)
-- sharded by author_id: user timeline = one range scan
follows (follower_id, followee_id, created_at) -- "who do I follow"
followers (followee_id, follower_id, created_at) -- "who follows me"
-- same edge stored twice, each sharded by its first column
users (user_id PK, handle, follower_count, is_celebrity, last_active_at)
home_timeline -- Redis, key = user_id, value = list of (tweet_id, author_id)
-- capped at 800, only for recently active users
Storing the follow graph twice is the standard trick: "who follows X" is needed at fan-out time, "who does X follow" at read and merge time, and each query should hit a single shard. Time-sortable IDs from a Snowflake-style generator make every sort in this design free.
Approach 1: Fan-out on Read (Pull Model)
When Asha opens her feed: get her 300 followees, fetch the latest N tweets of each from user_tweets, k-way merge by tweet ID, return the top 20.
- Write cost: one insert per tweet. Trivial.
- Read cost: 300 partition reads per feed load, scattered across shards. At 100K loads/s that is 30M shard reads per second, and p99 latency is governed by the slowest of 300 calls.
- Good for: users who rarely log in (no wasted precomputation), and accounts with enormous follower counts (see below).
Approach 2: Fan-out on Write (Push Model)
When Rahul tweets: look up his 200 followers and prepend the tweet ID to each of their cached home timelines. When Asha opens her feed: read one Redis list, hydrate 20 tweets, done.
- Write cost: one insert per follower, done asynchronously by a fan-out worker fleet.
- Read cost: one cache read plus a batched hydration. Fast and predictable.
- Problems: wasted work for inactive followers, and catastrophic amplification for accounts with tens of millions of followers.
| Fan-out on write (push) | Fan-out on read (pull) | |
|---|---|---|
| Post latency to followers | Seconds (async fan-out) | Immediate (computed on read) |
| Read latency | Very low, one cache lookup | High, many shard reads + merge |
| Write amplification | × follower count | None |
| Storage | Timeline cache per user | No timeline storage |
| Celebrity posts | Terrible (millions of inserts) | Fine |
| Inactive users | Wasted inserts | No cost |
| Best fit | Read-heavy feeds, normal accounts | Celebrities, rarely active readers |
The Celebrity Problem and Hybrid Fan-out
Take an account with 100M followers (think a top cricketer or a Bollywood star). Push-model fan-out means 100M cache inserts for one tweet. Even at 1M inserts/sec dedicated to that tweet, delivery takes 100 seconds, and the fan-out queue backs up for everyone else. If that account tweets during an India–Pakistan match, along with many other big accounts, the fan-out system melts.
The fix is hybrid fan-out:
- Classify accounts. Mark users above a follower threshold (say 100K, tuned empirically) as
is_celebrity. The flag can also consider posting frequency. - Push for normal accounts. Their tweets fan out to followers' cached timelines as before.
- Pull for celebrities. Their tweets are only written to
user_tweets(and a hot cache of each celebrity's recent tweets). - Merge at read time. When Asha reads, fetch her precomputed timeline, fetch recent tweets of the (usually few) celebrities she follows from the hot cache, and merge.
import heapq
from itertools import islice
def home_timeline(user_id, limit, max_id, timeline_cache, celeb_cache, graph):
"""Return up to `limit` tweet ids older than max_id, newest first.
Tweet ids are Snowflake ids, so larger id == newer tweet."""
# 1. precomputed (pushed) part, already sorted newest-first
pushed = [t for t in timeline_cache.get(user_id, []) if t < max_id]
# 2. pulled part: recent tweets of each celebrity this user follows
streams = [pushed]
for celeb in graph.celebrities_followed_by(user_id):
streams.append([t for t in celeb_cache.get(celeb, []) if t < max_id])
# 3. k-way merge of descending lists (heapq.merge needs reverse=True)
merged = heapq.merge(*streams, reverse=True)
return list(islice(merged, limit))
# Example
timeline_cache = {"asha": [905, 870, 860, 700]}
celeb_cache = {"virat": [900, 880, 650], "srk": [890]}
class G:
def celebrities_followed_by(self, u): return ["virat", "srk"]
print(home_timeline("asha", 5, float("inf"), timeline_cache, celeb_cache, G()))
# [905, 900, 890, 880, 870]
Dry run: the four sorted streams are [905, 870, 860, 700], [900, 880, 650] and [890]. The heap repeatedly pops the largest head: 905 (pushed), 900 (virat), 890 (srk), 880 (virat), 870 (pushed). The merge costs O(limit × log k) where k is the number of streams, and because most users follow only a handful of celebrities, k stays small.
High-Level Architecture
The fan-out workers are the throughput engine. They consume tweet.created, page through the author's followers in batches of a few thousand, and pipeline Redis writes. Partition Kafka by author ID so one author's tweets fan out in order. Make every insert idempotent (the same tweet ID inserted twice is deduplicated at read time) so worker retries are harmless. See distributed message queue design for why Kafka partitioning gives you this ordering.
Deep Dive: Caching Timelines
# fan-out worker, for each follower (pipelined in batches)
LPUSH tl:{follower_id} "1790000000000000123:42" # tweet_id:author_id
LTRIM tl:{follower_id} 0 799 # keep newest 800
# read path
LRANGE tl:{user_id} 0 199 # fetch, then filter + cursor
- IDs, not tweets. A viral tweet in 10M timelines is stored once in the tweet cache; the timelines hold 16 bytes each. Deletes and edits are visible everywhere immediately.
- Cap length. Nobody scrolls 5,000 tweets deep. Past the cap, fall back to a pull query against
user_tweets. - Replicate for reads. Hot timelines get served from replicas; losing a node is survivable because timelines can be rebuilt from the source of truth with fan-out on read.
- Hydration cache. Tweet objects, user objects and engagement counts live in separate caches with batched multi-get. A timeline read is roughly: 1 list read + 1 multi-get of 20 tweets + 1 multi-get of authors.
Lists vs sorted sets: a Redis list is simplest for strictly chronological insertion. A sorted set (score = tweet ID or rank score) makes cursor queries trivial with ZREVRANGEBYSCORE and tolerates out-of-order inserts, at somewhat more memory. Either is defensible; say why you chose. More on structures in the Redis deep dive, and on eviction and replication in distributed cache design.
Deep Dive: Ranking the Feed
Modern feeds are ranked, not chronological. The fan-out machinery does not go away; it becomes candidate generation. The pipeline looks like a funnel:
Candidate sourcing
In-network: the hybrid timeline above (hundreds of IDs). Out-of-network: posts liked by people you follow, topic and embedding-based recommendations.
Light ranker
A cheap model (logistic regression or small GBDT) prunes thousands of candidates to a few hundred using precomputed features.
Heavy ranker
A neural model predicts probabilities of like, reply, repost, dwell, and negative feedback; combines them into one score.
Re-rank and mix
Author diversity, dedupe, freshness boosts, safety filters, then insert ads and modules.
Twitter's open-sourced recommendation code (2023) follows this broad shape: candidate sources from in-network and out-of-network, a neural ranking model, then heuristic filters. You do not need ML detail in an HLD round; you need to show where ranking sits, what it costs, and that it runs on a latency budget (for example, 50 ms for feature fetch plus scoring). The ranking pipeline overlaps heavily with recommendation system design.
Deep Dive: Cursor Pagination for Feeds
Offset pagination (?page=3) breaks on a live feed. While you read page 1, ten new tweets arrive; page 2 at offset 20 now starts ten items earlier and you see duplicates. Offsets are also slow in SQL because the database still scans the skipped rows.
Chronological feeds: ID cursors
Twitter's classic API used max_id and since_id. Scroll down: request tweets with ID less than the last one you saw. Pull to refresh: request tweets with ID greater than the newest you have. Because Snowflake IDs are unique and time-ordered, there are no ties and no skips.
SELECT tweet_id, text, created_at
FROM user_tweets
WHERE author_id = :author AND tweet_id < :max_id
ORDER BY tweet_id DESC
LIMIT 20; -- uses the (author_id, tweet_id) index; no OFFSET scan
Ranked feeds: session snapshots
Scores change between page loads, so "score less than X" can skip or repeat items. The usual fix: when a feed session starts, compute the ranked list of a few hundred IDs, store it under a session key with a short TTL, and make the cursor an opaque, signed token like base64({session_id, position: 40}). Pages are slices of the snapshot. When the snapshot is exhausted or expires, the next request starts a fresh ranking that excludes already-seen IDs.
Failures, Spikes and Back-pressure
Feeds have extremely bursty write traffic. A wicket in the last over of a World Cup final or an election result triggers a wave of tweets from accounts of every size within the same minute. The design has to bend without breaking:
- Fan-out lag is acceptable; fan-out loss is not. Kafka absorbs the burst. Workers autoscale on consumer lag. Followers see tweets a little later during the spike, which nobody notices, instead of the posting API timing out, which everybody notices.
- Prioritise active readers. Fan-out workers can process followers who are online right now (from a presence service) before followers who were last active days ago.
- Timeline cache node dies. Reads for affected users fall back to fan-out on read against
user_tweets(slower but correct) while a replica is promoted or the timeline is rebuilt in the background. - Graph service slow. Fan-out reads follower lists in pages and checkpoints its progress per tweet, so a crash resumes from the last page rather than restarting the 2M-follower list from scratch.
- Hot tweet hydration. A viral tweet ID is requested millions of times per minute from the tweet cache. Replicate hot keys or keep a small in-process cache in the timeline service, and serve engagement counts that are a few seconds stale.
- Degrade ranking before availability. If the ranker exceeds its budget, return the merged chronological candidates. A slightly worse feed beats a spinner.
Edge Cases Interviewers Love
- Your own tweet. Fan-out is async, so write the tweet into the author's own timeline synchronously (or merge "my recent tweets" at read) to guarantee read-your-writes.
- Deletes. Do not chase 10M timelines to remove an ID. Mark the tweet deleted; hydration drops it. Background cleanup is optional.
- Unfollow. Filter at read time by checking author IDs against the current follow set (this is why entries store author_id), and lazily purge.
- New follow. Backfill: inject the followee's last N tweets into the follower's timeline so the feed is not empty.
- Blocks and mutes. Read-time filter, because they must take effect instantly.
- Threshold flapping. A user crossing 100K followers switches from push to pull; tweets already fanned out stay, new ones are pulled. Use hysteresis so accounts do not flap.
- Trending topics and search are separate systems; if asked, point to top-K trending hashtags.
How to Present This in an Interview
- Frame the trade-off first. "Feed design is a choice between paying at write time or read time. Let me estimate both."
- Do the math. Show 100K reads/s vs 460K fan-out inserts/s and the 3.8 TB cache. Numbers make the push decision obvious for normal users.
- Break it with a celebrity. Introduce the 100M-follower account yourself before the interviewer does, then present hybrid fan-out.
- Draw both paths. Write path through Kafka and fan-out workers; read path through merge, filter, hydrate.
- Close with pagination and edge cases. Cursors, deletes, read-your-writes. These details separate senior answers from junior ones.
Once you can explain this cleanly, the same reasoning carries to Instagram's photo feed, LinkedIn's feed and the short-video feed in Instagram Reels / YouTube Shorts, where the "following" graph matters less and recommendation-driven candidate generation matters more.
Frequently Asked Questions
Get Feed Design Interview-Ready
Prepflix's system design course covers feeds, fan-out and ranking with whiteboard walkthroughs and mock rounds modelled on product-company interviews.
Explore the Prepflix Course →