Back to Notes

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. How do you avoid showing a user their own post twice if a friend retweets it back to them? — De-duplicate by post_id during the final merge in get_timeline — the pushed and pulled lists are merged before hydration specifically so a dedup step (a set of seen post_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]]