Design a News Feed (Twitter/X Timeline)
1. Problem & Real-World Context
Design a social feed: users post short updates, follow other users, and see a reverse-chronological (or ranked) timeline of posts from people they follow. This is one of the most-asked HLD problems because its core challenge — fan-out — has no clean universal answer; the right design depends entirely on the follower-count distribution, which is exactly the kind of "there is no right answer, only trade-offs" reasoning Staff interviews are built to surface.
Real numbers to anchor on: ~300M DAU, ~500M posts/day, ~28B feed reads/day — a read:write ratio around 100:1, making this an emphatically read-optimized system.
2. Requirements
Functional: post an update (text + media); follow/unfollow; view home timeline (posts from followed users, reverse-chronological); view a single user's own timeline.
Non-functional: timeline load p99 < 200ms; eventual consistency acceptable (a few seconds' delay before a new post appears is invisible to the user); high availability (>99.9%) over strict consistency — see [[CAP Theorem & Consistency Models]].
3. Capacity Estimation
- Writes: 500M posts/day ÷ 86,400s ≈ ~5.8K writes/sec (peak 2–3x ≈ 15K/sec).
- Reads: 28B feed reads/day ÷ 86,400s ≈ ~325K reads/sec (peak ~1M/sec) — this asymmetry is the whole reason the architecture is read-optimized (heavy caching, pre-computed timelines) rather than write-optimized.
- Storage: 500M posts/day × ~1KB (text + metadata, media stored separately) × 365 ≈ ~180TB/year for post metadata alone — squarely a job for a horizontally-scalable store, not a single SQL instance (see [[SQL vs NoSQL]]).
- Timeline cache: capping each user's cached timeline at ~800 post IDs (Twitter's real historical limit) × 8 bytes × 300M users ≈ ~1.9TB of Redis — a real cluster, but tractable, and only IDs are cached (post bodies are hydrated from a separate post cache/store, avoiding N-way duplication of the same post across every follower's cached list).
4. API Design
POST /v1/posts {content, media_ids} → {post_id}
POST /v1/follow/{user_id}
GET /v1/timeline?cursor=<post_id>&limit=20 → {posts: [...], next_cursor}
GET /v1/users/{user_id}/posts?cursor=...&limit=20
Cursor pagination, never offset (see [[API Design Principles]]) — a feed is a live-inserting dataset; OFFSET 20 would skip or duplicate posts as new ones arrive between page loads. Cursor = the last seen post's ID/timestamp.
5. High-Level Architecture
flowchart LR
C[Client] --> LB[Load Balancer]
LB --> PS[Post Service]
LB --> FS[Feed/Timeline Service]
PS --> PDB[(Post Store<br/>Cassandra)]
PS --> MQ[Fan-out Queue<br/>Kafka]
MQ --> FW[Fan-out Workers]
FW --> GDB[(Follow Graph)]
FW --> FC[Redis Timeline Cache<br/>sorted set of post IDs]
FS --> FC
FS -->|hydrate post bodies| PC[Post Cache]
FS -->|celebrity posts,<br/>pulled at read time| PDB
Walking a write (post): client posts → Post Service writes the post to Cassandra (partition key user_id, sort key post_id — a time-sortable ID, see §7) → publishes an event to a Kafka fan-out queue → fan-out workers look up the poster's follower list and push the new post ID into each (non-celebrity) follower's Redis timeline sorted set.
Walking a read (timeline): client requests timeline → Feed Service reads the pre-computed sorted set from Redis (near-instant) → separately fetches recent posts from any celebrities the user follows (pulled live, not pre-fanned-out — see §7) → merges both by timestamp → hydrates post bodies from a shared post cache → returns.
6. Data Model & Storage Choice
Post store: Cassandra (or DynamoDB) — partition by user_id, cluster by post_id DESC. This access pattern ("recent posts by one author") is a single-partition scan, no joins needed — exactly Cassandra's strength, and the write pattern (append-only, never updated) matches a log-structured store far better than a relational table you'd otherwise need to shard manually. See [[SQL vs NoSQL]] for the general decision framework this follows.
Timeline cache: Redis sorted set, timeline:{user_id} → {post_id: timestamp}. ZADD inserts a new post ID scored by timestamp; ZREMRANGEBYRANK timeline:{user} 0 -801 trims to the last 800 entries after each insert, bounding memory per user regardless of how prolific the people they follow are.
Follow graph: followers:{user_id} → SET of follower_ids (used for fan-out — "who do I need to push to") and following:{user_id} → SET of followed_ids (used for feed reads — "whose celebrity posts do I need to pull"). A dedicated graph DB is an option at larger scale, but a Redis SET or a sharded key-value store handles this access pattern (pure set membership + iteration) without needing genuine graph-traversal queries.
7. The Core Problem: Fan-out
When a user with 50M followers posts, how do all 50M followers see it?
Fan-out on Write (push)
flowchart LR
Post[New Post] --> FW[Fan-out Worker]
FW --> F1[Follower 1's<br/>Redis timeline]
FW --> F2[Follower 2's<br/>Redis timeline]
FW --> F3[... Follower N's<br/>Redis timeline]
On publish, workers insert the new post ID into every follower's timeline cache immediately. Timeline reads become a single Redis fetch — extremely fast. Fails for celebrities: a post from an account with 50M followers means 50M cache writes for one post — minutes of fan-out lag and a Kafka/worker-pool storm, for a single post.
Fan-out on Read (pull)
The timeline is built at request time: fetch the list of followed users, fetch each one's recent posts, merge-sort by timestamp. No write amplification — a celebrity posting once is one write, period. Cost moves to reads: following 2,000 accounts means potentially 2,000 lookups merged on every single timeline load — multiplied across 28B reads/day, this is far worse in aggregate than the celebrity write spike it avoids.
The Answer: Hybrid (what Twitter actually does)
- Regular users (below a follower threshold, tunable — e.g. ~1M): fan-out on write, as above. The vast majority of accounts and posts fall here, and pre-computed timelines make the dominant read path fast.
- Celebrity users (above the threshold): not fanned out on write at all — their posts are pulled live at read time and merged into the requester's pre-computed feed.
- Timeline read becomes a 3-step merge: (1) load the pre-computed feed from Redis, (2) fetch recent posts from any celebrities the user follows, (3) merge-sort the two lists by timestamp before returning.
def get_timeline(user_id: str, limit: int = 20) -> list[dict]:
pushed_post_ids = redis.zrevrange(f"timeline:{user_id}", 0, limit) # pre-fanned-out posts
celebrities_followed = redis.smembers(f"following:{user_id}")
celebrities_followed = [c for c in celebrities_followed if is_celebrity(c)]
pulled_posts = []
for celeb_id in celebrities_followed:
pulled_posts.extend(get_recent_posts(celeb_id, limit)) # pulled live, not pre-fanned
all_post_ids = pushed_post_ids + [p["post_id"] for p in pulled_posts]
posts = hydrate_posts(all_post_ids) # fetch bodies from post cache
posts.sort(key=lambda p: p["created_at"], reverse=True)
return posts[:limit]
Additional write savings: skip fan-out entirely to users inactive for 30+ days — rebuild their feed lazily (fall back to pull) if/when they return, rather than paying the write cost of keeping a dormant user's timeline warm indefinitely.
8. Time-Sortable IDs: Snowflake
See [[Design a Unique ID Generator]] for the full canonical treatment (bit-budget reasoning, clock-skew handling, alternatives comparison) — this section is the applied version for post IDs specifically.
Post IDs need to be globally unique and roughly time-ordered (so a lexicographic/numeric sort ≈ chronological order, letting the DB's natural clustering do the sorting work). A Snowflake-style 64-bit ID:
[ 41 bits: timestamp (ms since a custom epoch) ] [ 10 bits: machine/shard ID ] [ 12 bits: per-ms sequence ]
import time
EPOCH = 1_700_000_000_000 # custom epoch, ms since Unix epoch
_sequence = 0
_last_ts = -1
def next_id(machine_id: int) -> int:
global _sequence, _last_ts
ts = int(time.time() * 1000)
if ts == _last_ts:
_sequence = (_sequence + 1) & 0xFFF # 12 bits → wraps at 4096/ms
else:
_sequence = 0
_last_ts = ts
return ((ts - EPOCH) << 22) | (machine_id << 12) | _sequence
No central coordinator needed (each machine generates its own IDs independently using only its own clock + assigned machine ID), supports ~4096 IDs/ms per machine, and — critically — sorting by ID is sorting by creation time, so "recent posts by user X" is a natural clustered-column scan in Cassandra, not a separate index.
9. Scaling & Bottlenecks
- Like/retweet counts: don't write to the DB on every like — a viral post would thunder the write path.
INCR tweet:likes:{post_id}in Redis, batch-flush the aggregate to the durable store every ~30s. Counts shown to users are therefore approximate by design — an acceptable trade stated explicitly, not an oversight. - Media: stored in blob storage + served via CDN (see [[Blob & Object Storage]], [[CDN]]) — the post row stores only a
media_id, with the actual URL generated at read time (allows rotating CDN domains/signing URLs without touching stored post data). - Cache miss on a timeline: rebuild from the post store + follow graph on demand — expensive per-occurrence, but rare enough (only on true cold start) not to dominate average latency.
- Fan-out lag: if the worker pool falls behind during a traffic spike, feed updates lag by seconds — this is the eventual-consistency trade-off from §2 showing up concretely, and is an acceptable, explicitly-scoped trade, not a bug.
10. Deep Dives / Follow-ups
- Ranking instead of pure reverse-chronological: swap the final merge-sort in §7 for a ranking model (engagement prediction score) — architecturally this replaces one sort key with a computed score, everything else about the fan-out/pull hybrid is unchanged.
- Muting/blocking: filtering happens at read time during hydration (
hydrate_posts), not by trying to prevent fan-out to a specific follower (fan-out doesn't know about mutes, and shouldn't need to). - Edit/delete a post after it's fanned out: since only post IDs are cached per-follower (not bodies), an edit or delete only needs to update/remove the post in the shared post store — every follower's cached timeline automatically reflects it on next hydration, without needing to touch millions of per-follower cache entries.
11. Staff-Level Lens
The naive all-push design doesn't fail in a demo with a test account with 50 followers — it fails specifically when a real celebrity account posts, which means it can look correct through every round of code review and only breaks in production under a traffic pattern the design never accounted for. Surfacing the celebrity case unprompted, before being asked "what if someone has 50M followers," is the single highest-value thing to say in this interview. Second: the follower-count threshold that decides push vs. pull is itself a tunable, monitored parameter, not a hardcoded constant — worth naming that it should be revisited as the platform's follower-count distribution shifts over time.
12. Interview Drill Questions
- Why not just always fan out on read — isn't it simpler and avoids the celebrity problem entirely? — At 28B reads/day vs 500M writes/day (a ~56:1 ratio), moving all cost to the read path multiplies the already-dominant cost center; fan-out-on-write is specifically an optimization for the overwhelmingly common case (non-celebrity accounts), not premature complexity — see [[SD Interview Framework & Pitfalls]] on distinguishing genuine optimization from premature optimization.
- A user follows 5,000 celebrities. How does the read-time pull scale? — This is the actual weak point of the hybrid design — pulling recent posts from 5,000 accounts on every timeline load is itself expensive. Mitigation: cap how many celebrity accounts get pulled per read (e.g., only the most recently active or most-engaged-with celebrities), accepting a small completeness trade-off for bounded read cost.
- How would you detect that an account has crossed the celebrity threshold and needs to switch fan-out strategy? — A background job tracking follower counts, flipping a flag on the user record once a threshold is crossed; the next post uses the new strategy — no need to retroactively re-fan-out or un-fan-out already-published posts.
- Two users follow each other and both post at the exact same timestamp. How does your merge handle the tie? — Snowflake IDs remain strictly ordered even at identical millisecond timestamps (the 12-bit sequence counter breaks ties) — sort by ID, not raw timestamp, for a stable deterministic order.
- How do you avoid showing a user their own post twice if a friend retweets it back to them? — De-duplicate by
post_idduring the final merge inget_timeline— the pushed and pulled lists are merged before hydration specifically so a dedup step (a set of seenpost_ids) can run once at that point, rather than trying to prevent the duplicate at each source.
Cross-links
[[Caching & Redis]] · [[Message Queues & Kafka]] · [[Consistent Hashing]] · [[SQL vs NoSQL]] · [[Blob & Object Storage]] · [[CDN]] · [[API Design Principles]] · [[SD Interview Framework & Pitfalls]] · [[Design a Unique ID Generator]]