System Design · Interview Deep Dive

Design Twitter / News Feed: Fan-out on Write vs Read

The feed question is really one decision: do you do the work when someone posts, or when someone reads? Here is how to make that call with numbers, handle celebrities, rank, cache and paginate.

✍️ Pranjal Jain, Ex-Microsoft · IIT Kanpur 📅 10 October 2026 ⏱ 14 min read
A post fanning out as light threads to many follower phone screens, illustrating news feed fan-out
TL;DR
  • 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:

QuantityAssumptionResult
Daily active users300M
Tweets per day200M≈ 2,300/s avg, ~10K/s peak
Timeline loads per user per day103B/day ≈ 35K/s avg, ~100K/s peak
Average followers per poster20040B timeline inserts/day ≈ 460K/s
Followees per user200 (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.

~100K/s
Peak timeline reads
~460K/s
Fan-out inserts (avg)
~3.8 TB
Timeline cache (1 copy)
200 ms
p99 read target

API and Data Model

API
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
Storage
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 followersSeconds (async fan-out)Immediate (computed on read)
Read latencyVery low, one cache lookupHigh, many shard reads + merge
Write amplification× follower countNone
StorageTimeline cache per userNo timeline storage
Celebrity postsTerrible (millions of inserts)Fine
Inactive usersWasted insertsNo cost
Best fitRead-heavy feeds, normal accountsCelebrities, 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:

  1. Classify accounts. Mark users above a follower threshold (say 100K, tuned empirically) as is_celebrity. The flag can also consider posting frequency.
  2. Push for normal accounts. Their tweets fan out to followers' cached timelines as before.
  3. Pull for celebrities. Their tweets are only written to user_tweets (and a hot cache of each celebrity's recent tweets).
  4. 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.
Python: merging the pushed timeline with pulled celebrity tweets
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.

💡
Inactive users get pull tooThe same idea applies on the reader side. Do not maintain cached timelines for users who have not logged in for 30 days. When one returns, build their timeline with a one-time fan-out-on-read, cache it, and they rejoin the push path. This can cut fan-out work and cache memory dramatically, since a large fraction of registered accounts are dormant.

High-Level Architecture

WRITE PATH Client ──► API GW ──► Tweet Service ──► Tweets DB (sharded) + Tweet cache │ └──► Kafka: tweet.created │ ▼ Fan-out Workers ◄── Graph Service (followers) (skip if author is celebrity; skip followers inactive > 30d) │ LPUSH + LTRIM ▼ Timeline Cache (Redis, sharded by user_id) READ PATH Client ──► API GW ──► Timeline Service ├─► Timeline Cache: pushed ids ├─► Celebrity recent-tweets cache: pulled ids ├─► merge ──► (rank) ──► filter (blocks, deletes) └─► Hydration: Tweet cache + User cache + counts │ Media via CDN URLs

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

Redis: per-user capped timeline
# 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:

Stage 1

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.

Stage 2

Light ranker

A cheap model (logistic regression or small GBDT) prunes thousands of candidates to a few hundred using precomputed features.

Stage 3

Heavy ranker

A neural model predicts probabilities of like, reply, repost, dwell, and negative feedback; combines them into one score.

Stage 4

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.

SQL: user timeline with keyset pagination
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.

⚠️
Make cursors opaqueReturn an encoded token, not raw internals. That lets you change the cursor format (from ID-based to snapshot-based) without breaking clients, and signing it stops clients from tampering with positions.

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

  1. Frame the trade-off first. "Feed design is a choice between paying at write time or read time. Let me estimate both."
  2. 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.
  3. Break it with a celebrity. Introduce the 100M-follower account yourself before the interviewer does, then present hybrid fan-out.
  4. Draw both paths. Write path through Kafka and fan-out workers; read path through merge, filter, hydrate.
  5. Close with pagination and edge cases. Cursors, deletes, read-your-writes. These details separate senior answers from junior ones.
🚫
Common mistakesChoosing pure push with no celebrity story. Storing full tweet JSON in every timeline. Doing fan-out synchronously in the POST request. Using OFFSET pagination. Building timelines for all registered users rather than active ones. Ignoring that unfollows and blocks must be enforced at read time.

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

What is fan-out on write?▾
Fan-out on write (push model) means that when a user posts, the system immediately inserts the post ID into the precomputed home timeline of every follower. Reads become a cheap cache lookup, but writes are amplified by the follower count, which is a problem for accounts with millions of followers.
What is fan-out on read?▾
Fan-out on read (pull model) means nothing is precomputed. When a user opens their feed, the system fetches recent posts from everyone they follow and merges them. Writes are cheap, but every read touches many partitions, which makes feed loads slow and expensive for users who follow many accounts.
How does Twitter handle celebrities with millions of followers?▾
With a hybrid model. Posts from normal accounts are pushed into follower timelines at write time, while posts from very high-follower accounts are not fanned out. At read time the system pulls the recent posts of the celebrities a user follows and merges them into the precomputed timeline.
Why use cursor pagination instead of offset for feeds?▾
Feeds change constantly. With offset pagination, new posts arriving at the top shift every item down, so the next page repeats or skips items, and large offsets are slow in databases. A cursor encodes the position of the last item seen (for example its ID or score), so the next page starts exactly after it regardless of inserts.
Should the timeline cache store full tweets or only IDs?▾
Only IDs (plus a few bytes like author ID for filtering). Tweets are hydrated from a separate tweet cache at read time. Storing IDs keeps each timeline small, avoids duplicating a viral tweet across millions of timelines and means edits or deletes take effect everywhere instantly.
Which database is best for storing tweets?▾
Tweets are immutable, small and written at high volume, and are looked up by ID or by author and time, so a horizontally scalable store sharded by tweet ID or author ID works well, whether sharded MySQL, Cassandra or a similar wide-column store. Time-sortable IDs like Snowflake make author timelines a simple range scan.
How is a ranked feed different from a chronological feed in system design?▾
A chronological feed can be served by merging sorted ID lists. A ranked feed adds candidate generation, feature fetching and a machine-learned scoring step, typically in a light ranker then heavy ranker funnel, plus re-ranking for diversity. It also needs session snapshots so pagination stays stable as scores change.

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 →
Pranjal Jain
Pranjal Jain

Ex-Microsoft SDE · IIT Kanpur · Founder of Prepflix. Helps engineers crack startup and product company interviews across India.