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.

Let's work out why the naive solution fails, then build toward what actually works.

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

The simplest design: don't precompute anything. When a user opens their feed, query all the tweets from people 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. Breaks at any meaningful social platform scale.

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

Flip the model: instead of querying at read time, push tweets to 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 beautifully — until a celebrity with 100M followers tweets. Suddenly you need to make 100 million Redis writes for a single tweet.

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

The Hybrid: Twitter's Actual Architecture

Twitter uses a hybrid model that combines both approaches based on 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?

Precomputing timelines for all 500M users would require enormous Redis storage. Twitter doesn't bother for inactive users:

User stateTimeline strategy
Active (logged in recently)Pre-built in Redis, updated on write
Inactive (not logged in for 30d+)Timeline evicted from Redis
Inactive user returnsTimeline reconstructed on first open (one-time fan-out on read)
New user (first open)Bootstrapped from 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

Feed generation is a write-time vs read-time tradeoff. Precomputing (fan-out on write) makes reads instant but makes writes expensive for accounts with many followers. On-demand assembly (fan-out on read) is simple but doesn't scale for popular accounts. The hybrid model — precompute for regular users, merge on read for celebrities — is how every major social platform solves this. The exact threshold and implementation differ, but the pattern is universal.