How Twitter Builds Your Home Feed Fan-Out on Write vs Fan-Out on Read Fan-Out on Write User tweets Write to 400 follower timelines TL TL TL TL ... Fan-Out on Read User opens app Query 400 followed accounts Q Q Q Q ... 500M users 400 avg follows <200ms feed load

The Problem: Millisecond Reads from Billions of Writes

Twitter has ~500M users. ~100M are active daily. The average user follows ~400 accounts. When anyone tweets, every follower's home feed should update within seconds. When you open the app, your feed should appear in under 200ms.

First, look at why the simple solution fails. Then look at the design that works.

Approach 1 — Fan-Out on Read (Pull Model)

The simplest design precomputes nothing. When a user opens the feed, the server queries all the tweets from the accounts they follow.

-- Feed query: pull tweets from all followed accounts
SELECT t.tweet_id, t.content, t.created_at, t.author_id
FROM tweets t
WHERE t.author_id IN (
  SELECT followed_id
  FROM follows
  WHERE follower_id = :user_id
)
ORDER BY t.created_at DESC
LIMIT 20;
Fan-Out on Read: What Happens at Scale User follows 400 accounts, each with ~10,000 tweets Step 1: Load 400 followed_ids from follows table Step 2: 400 index lookups, merge+sort 4,000,000 candidates Step 3: Return top 20 At 100M active users: 100M x (400 lookups + sort) = 40 billion DB ops/day = 460,000 DB operations per second Impossible at this scale Verdict: works up to ~10k users. Fan-out on read breaks at platform scale.

Approach 2 — Fan-Out on Write (Push Model)

Reverse the model. The server does not query at read time. It pushes each tweet to the followers' timelines at write time. Each user has a pre-built timeline list in Redis.

Fan-Out on Write: The Data Flow User A (1,000 followers) tweets Tweet stored in tweets table (tweet_id=99999) Fan-out worker: for each of 1,000 followers LPUSH timeline:{follower_id} 99999 | LTRIM 0 799 1,000 Redis writes complete in ~10ms Follower opens feed LRANGE timeline:{user_id} 0 19 -> 20 tweet IDs instantly Fetch details by IDs -> render feed Read time: ~2ms (one LRANGE + batch GET)
// Fan-out worker (runs after every tweet)
async function fanOutTweet(tweetId, authorId) {
  // Get all followers of the author
  const followers = await db.query(
    'SELECT follower_id FROM follows WHERE followed_id = ?',
    [authorId]
  );

  // Push tweet ID to each follower's timeline list in Redis
  const pipeline = redis.pipeline();
  for (const { follower_id } of followers) {
    pipeline.lpush(`timeline:${follower_id}`, tweetId);
    pipeline.ltrim(`timeline:${follower_id}`, 0, 799); // keep 800 tweets
  }
  await pipeline.exec();
}

// Read feed (fast — just Redis)
async function getHomeFeed(userId, page = 0, pageSize = 20) {
  const start = page * pageSize;
  const end = start + pageSize - 1;

  // Step 1: Get tweet IDs from pre-built timeline
  const tweetIds = await redis.lrange(`timeline:${userId}`, start, end);

  // Step 2: Batch-fetch tweet details
  const tweets = await Promise.all(
    tweetIds.map(id => getTweetById(id))
  );

  return tweets;
}

The Celebrity Problem: Why Pure Fan-Out on Write Fails

Fan-out on write works well until a celebrity with 100M followers tweets. One tweet then needs 100 million Redis writes.

The Celebrity Problem Celebrity tweets (120M followers) At 100K Redis writes/sec: 120,000,000 / 100,000 = 1,200 sec = 20 minutes Followers wait 20 minutes for the tweet. The backlog grows. Even at 1M writes/sec: 120M writes = 120s for ONE tweet At ~6,000 tweets/sec globally, impossible. ~20,000 users have 1M+ followers. Pure fan-out on write fails for them.

The Hybrid: Twitter's Actual Architecture

Twitter uses a hybrid model. It selects one of the two approaches by follower count:

Hybrid Fan-Out: The Decision Tree User posts a tweet Celebrity? (followers > 1M) NO Fan-out on WRITE Push to follower Redis timelines YES Fan-out on READ Store in DB only, no pre-push User opens feed (read time) Load Redis timeline + fetch celebrity tweets on-demand Merge + rank -> render feed
// Hybrid fan-out: write side
const CELEBRITY_THRESHOLD = 1_000_000; // 1M followers

async function hybridFanOut(tweetId, authorId) {
  const followerCount = await getFollowerCount(authorId);

  if (followerCount < CELEBRITY_THRESHOLD) {
    // Regular user: push to all follower timelines (fan-out on write)
    await fanOutToFollowers(tweetId, authorId);
  } else {
    // Celebrity: only store tweet, skip fan-out (fan-out on read)
    // Followers will pull celebrity tweets at read time
    await storeInTweetsTable(tweetId, authorId);
  }
}

// Hybrid read: combine prebuilt timeline + celebrity tweets
async function getHomeFeed(userId) {
  // 1. Load pre-built timeline from Redis (regular accounts)
  const prebuiltIds = await redis.lrange(`timeline:${userId}`, 0, 199);

  // 2. Find celebrity accounts this user follows
  const celebFollows = await db.query(`
    SELECT f.followed_id
    FROM follows f
    JOIN users u ON u.id = f.followed_id
    WHERE f.follower_id = ?
      AND u.follower_count >= ?
    LIMIT 100
  `, [userId, CELEBRITY_THRESHOLD]);

  // 3. Fetch recent celebrity tweets (fan-out on read for celebrities)
  const celebTweetIds = await Promise.all(
    celebFollows.map(({ followed_id }) =>
      redis.lrange(`user_tweets:${followed_id}`, 0, 19)
    )
  );

  // 4. Merge all tweet IDs, fetch details, rank by time
  const allIds = [...new Set([...prebuiltIds, ...celebTweetIds.flat()])];
  const tweets = await batchFetchTweets(allIds);

  return tweets
    .sort((a, b) => b.created_at - a.created_at)
    .slice(0, 20);
}

The Full Timeline Service Architecture

Twitter Home Timeline -- Full Architecture WRITE PATH 1. Tweet stored in TweetStore 2. Fanout Service consumes from Kafka 3. Social Graph: who follows author? 4. Regular users: write to Redis timeline 5. Celebrities: skip (read-time merge) READ PATH 1. LRANGE timeline:{userId} from Redis 2. Celebrity Merge: fetch celebrity lists 3. Tweet Hydration: batch-fetch by IDs 4. Ranking: recency + engagement score 5. Return top 20, paginate with cursor Key Stores Redis Home timelines (tweet ID lists) User tweet lists (celebrity lookup) TTL: 7 days (cold users evicted) Manhattan Source of truth for all tweets Distributed KV store (HBase-like) Twitter's internal database FlockDB Social graph (who follows whom) Optimized for follower lookups

What About Inactive Users?

Precomputed timelines for all 500M users would need too much Redis storage. Twitter skips the inactive users:

User stateTimeline strategy
Active (logged in recently)Twitter pre-builds it in Redis and updates it on each write
Inactive (not logged in for 30d+)Redis evicts the timeline
Inactive user returnsTwitter rebuilds the timeline on the first open (one-time fan-out on read)
New user (first open)Twitter builds it from the followed accounts + trending

Building Something Like This Yourself

// A production-ready simplified version using Redis + PostgreSQL

class TimelineService {
  constructor(redis, db) {
    this.redis = redis;
    this.db = db;
    this.CELEBRITY_THRESHOLD = 50_000;  // lower for smaller platforms
    this.TIMELINE_MAX_SIZE = 800;
  }

  async onTweetCreated(tweetId, authorId) {
    const followerCount = await this.getFollowerCount(authorId);

    if (followerCount >= this.CELEBRITY_THRESHOLD) {
      // Celebrity: only append to their own tweet list
      await this.redis.lpush(`user_tweets:${authorId}`, tweetId);
      await this.redis.ltrim(`user_tweets:${authorId}`, 0, 999);
      return;
    }

    // Regular: fan-out to all followers
    const followers = await this.db.query(
      'SELECT follower_id FROM follows WHERE followed_id = ?',
      [authorId]
    );

    if (followers.length === 0) return;

    // Batch Redis writes with pipelining
    const batchSize = 1000;
    for (let i = 0; i < followers.length; i += batchSize) {
      const batch = followers.slice(i, i + batchSize);
      const pipeline = this.redis.pipeline();
      for (const { follower_id } of batch) {
        const key = `timeline:${follower_id}`;
        pipeline.lpush(key, tweetId);
        pipeline.ltrim(key, 0, this.TIMELINE_MAX_SIZE - 1);
        pipeline.expire(key, 30 * 24 * 60 * 60); // 30 day TTL
      }
      await pipeline.exec();
    }
  }

  async getTimeline(userId, cursor = 0, limit = 20) {
    // 1. Pre-built timeline
    const prebuiltIds = await this.redis.lrange(
      `timeline:${userId}`, cursor, cursor + limit * 3 - 1
    );

    // 2. Celebrity merging
    const celebs = await this.getCelebrityFollows(userId);
    const celebIds = await Promise.all(
      celebs.map(id => this.redis.lrange(`user_tweets:${id}`, 0, 19))
    );

    // 3. Merge, deduplicate, sort, paginate
    const allIds = [...new Set([...prebuiltIds, ...celebIds.flat()])];
    return allIds.slice(0, limit);
  }
}

The core insight

Building a feed is a write-time versus read-time tradeoff. Precomputing (fan-out on write) makes reads instant. It also makes writes expensive for accounts with many followers. On-demand assembly (fan-out on read) is simple. It does not scale for popular accounts. The hybrid model precomputes for regular users and merges on read for celebrities. Every major social platform solves the problem this way. The exact threshold and implementation differ, but the pattern is the same everywhere.