Field note / architecture

publishedupdated 2026-05-01#twitter#feed#fanout#cassandra#redis

Design: Twitter / X Feed

How to design Twitter — fanout on write vs read, timeline generation, tweet storage, trending topics, and handling celebrity accounts.

Overview

Twitter's core challenge is delivering a personalized home timeline to 300M+ daily active users with sub-100ms read latency. The system ingests ~6,000 tweets per second at peak, each of which may need to be delivered to millions of followers. The central design tension is between write amplification (fanout-on-write) and read amplification (fanout-on-read), and Twitter's production architecture uses a hybrid of both. Tweet storage uses Cassandra for high-throughput time-series writes, Redis for hot timeline caches, and Elasticsearch for search and trending. Media (images, video) goes to S3 with CloudFront CDN delivery.


Architecture

 WRITE PATH
 ─────────────────────────────────────────────────────────────────────
 User POST /tweet
      │
      ▼
 ┌────────────────┐     ┌─────────────────────────────────────────┐
 │  API Gateway   │────▶│  Tweet Service                          │
 │  (auth, rate   │     │  - Assign tweet_id (TIMEUUID/Snowflake) │
 │   limit)       │     │  - Write to Cassandra (tweets table)    │
 └────────────────┘     │  - Publish to Kafka topic: new-tweets   │
                        └────────────────┬────────────────────────┘
                                         │
                         ┌───────────────▼───────────────────────┐
                         │  Kafka: new-tweets topic               │
                         └──────┬────────────────┬───────────────┘
                                │                │
               ┌────────────────▼──┐       ┌─────▼────────────────┐
               │  Fanout Service   │       │  Search Indexer       │
               │                   │       │  (Elasticsearch)      │
               │  - Fetch follower │       │  - Index tweet text   │
               │    list from DB   │       │  - Update hashtag     │
               │  - Celebrity?     │       │    sliding window      │
               │    → skip ZADD    │       └──────────────────────┘
               │  - Normal user?   │
               │    → ZADD to each │
               │    follower's     │
               │    Redis timeline │
               └──────────────────┘
                        │
                        ▼
         ┌──────────────────────────────┐
         │  Redis Cluster               │
         │                              │
         │  Key: timeline:{userId}      │
         │  Type: Sorted Set            │
         │  Score: tweet timestamp      │
         │  Member: tweet_id            │
         │                              │
         │  Max size: 800 tweets/user   │
         │  (trim older entries)        │
         └──────────────────────────────┘

 READ PATH
 ─────────────────────────────────────────────────────────────────────
 User GET /timeline
      │
      ▼
 ┌─────────────────────────────────────────────────────────────────┐
 │  Timeline Read Service                                           │
 │                                                                  │
 │  1. ZREVRANGE timeline:{userId} 0 99  → last 100 tweet_ids      │
 │     (fanout-on-write result, normal users)                       │
 │                                                                  │
 │  2. For each celebrity the user follows:                         │
 │     - Fetch latest N tweets from Cassandra (fanout-on-read)      │
 │     - Merge + re-sort by timestamp with step-1 results           │
 │                                                                  │
 │  3. Hydrate tweet_ids → tweet objects (Redis tweet cache         │
 │     or Cassandra read)                                           │
 │                                                                  │
 │  4. Return merged, deduplicated timeline                         │
 └─────────────────────────────────────────────────────────────────┘

 MEDIA PATH
 ─────────────────────────────────────────────────────────────────────
 Tweet with image/video
      │
      ▼
 ┌──────────────┐    ┌──────────────┐    ┌────────────────────────┐
 │  Media       │───▶│  S3 Bucket   │───▶│  CloudFront CDN        │
 │  Upload Svc  │    │  (origin)    │    │  (edge cache, global)  │
 └──────────────┘    └──────────────┘    └────────────────────────┘

 NOTIFICATIONS
 ─────────────────────────────────────────────────────────────────────
 Kafka: new-tweets ──▶ Notification Service ──▶ APNs / FCM / WebSocket

Technical Implementation

FanoutService — Redis ZADD with Celebrity Threshold

// src/services/fanout.service.ts
import { Redis } from 'ioredis';
import { FollowerRepository } from '../repositories/follower.repository';

const redis = new Redis(process.env.REDIS_URL!);
const followerRepo = new FollowerRepository();

const CELEBRITY_THRESHOLD = 1_000_000; // 1M followers
const TIMELINE_MAX_SIZE = 800;         // trim beyond this
const TIMELINE_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days

interface Tweet {
  tweetId: string;
  authorId: string;
  timestamp: number; // Unix ms — used as sorted set score
}

export async function fanoutTweet(tweet: Tweet): Promise<void> {
  const followerCount = await followerRepo.getFollowerCount(tweet.authorId);

  if (followerCount >= CELEBRITY_THRESHOLD) {
    // Celebrity: skip fanout-on-write entirely.
    // Followers will pull celebrity tweets at read time.
    console.log(`Celebrity fanout skipped for author ${tweet.authorId} (${followerCount} followers)`);
    return;
  }

  // Normal user: push tweet_id into every follower's Redis timeline
  const followerIds = await followerRepo.getFollowerIds(tweet.authorId, { batchSize: 5000 });

  const pipeline = redis.pipeline();
  for (const followerId of followerIds) {
    const key = `timeline:${followerId}`;

    // ZADD score=timestamp member=tweetId
    pipeline.zadd(key, tweet.timestamp, tweet.tweetId);

    // Trim to TIMELINE_MAX_SIZE (keep most recent)
    pipeline.zremrangebyrank(key, 0, -(TIMELINE_MAX_SIZE + 1));

    // Refresh TTL on every write
    pipeline.expire(key, TIMELINE_TTL_SECONDS);
  }

  await pipeline.exec();
}

Timeline Read Service — Hybrid Merge

// src/services/timeline.service.ts
import { Redis } from 'ioredis';
import { CassandraClient } from '../db/cassandra';
import { FollowerRepository } from '../repositories/follower.repository';
import { TweetRepository } from '../repositories/tweet.repository';

const redis = new Redis(process.env.REDIS_URL!);
const cassandra = new CassandraClient();
const followerRepo = new FollowerRepository();
const tweetRepo = new TweetRepository();

const CELEBRITY_THRESHOLD = 1_000_000;
const PAGE_SIZE = 20;

interface TimelineEntry {
  tweetId: string;
  timestamp: number;
}

export async function getHomeTimeline(userId: string, beforeTimestamp?: number): Promise<TimelineEntry[]> {
  const cutoff = beforeTimestamp ?? Date.now();

  // Step 1: Pull from push-model Redis timeline (normal users the caller follows)
  const rawEntries = await redis.zrevrangebyscore(
    `timeline:${userId}`,
    cutoff,
    '-inf',
    'WITHSCORES',
    'LIMIT', 0, PAGE_SIZE * 3 // over-fetch to account for celebrity merge
  );

  const pushEntries: TimelineEntry[] = [];
  for (let i = 0; i < rawEntries.length; i += 2) {
    pushEntries.push({ tweetId: rawEntries[i], timestamp: Number(rawEntries[i + 1]) });
  }

  // Step 2: Pull latest tweets from each celebrity the user follows (fanout-on-read)
  const followedCelebrities = await followerRepo.getCelebrityFollows(userId, CELEBRITY_THRESHOLD);

  const celebrityTweetPromises = followedCelebrities.map((celebId) =>
    tweetRepo.getRecentTweets(celebId, cutoff, PAGE_SIZE)
  );
  const celebrityResults = await Promise.all(celebrityTweetPromises);
  const celebEntries: TimelineEntry[] = celebrityResults.flat();

  // Step 3: Merge, sort descending by timestamp, deduplicate, take PAGE_SIZE
  const merged = [...pushEntries, ...celebEntries];
  const seen = new Set<string>();
  const deduplicated = merged
    .filter((e) => {
      if (seen.has(e.tweetId)) return false;
      seen.add(e.tweetId);
      return true;
    })
    .sort((a, b) => b.timestamp - a.timestamp)
    .slice(0, PAGE_SIZE);

  return deduplicated;
}

Tweet Storage — Cassandra Partition Key Design

// Tweet table schema and repository
// Partition key: author_id groups tweets by author
// Clustering key: tweet_id DESC sorts newest-first within a partition
// TIMEUUID encodes the timestamp, so ORDER BY tweet_id DESC == ORDER BY time DESC

/*
CREATE TABLE tweets (
    author_id   UUID,
    tweet_id    TIMEUUID,          -- Snowflake-style or Cassandra TIMEUUID
    text        TEXT,
    media_urls  LIST<TEXT>,
    reply_to    UUID,
    retweet_of  UUID,
    created_at  TIMESTAMP,
    PRIMARY KEY (author_id, tweet_id)
) WITH CLUSTERING ORDER BY (tweet_id DESC)
  AND compaction = {'class': 'TimeWindowCompactionStrategy',
                    'compaction_window_unit': 'DAYS',
                    'compaction_window_size': 1};
*/

import com.datastax.oss.driver.api.core.CqlSession;
import com.datastax.oss.driver.api.core.cql.*;

import java.util.List;
import java.util.UUID;

public class TweetRepository {

    private final CqlSession session;

    private static final String INSERT_TWEET =
        "INSERT INTO tweets (author_id, tweet_id, text, media_urls, created_at) " +
        "VALUES (?, now(), ?, ?, toTimestamp(now()))";

    private static final String FETCH_RECENT =
        "SELECT tweet_id, text, media_urls, created_at FROM tweets " +
        "WHERE author_id = ? AND tweet_id < maxTimeuuid(?) " +
        "ORDER BY tweet_id DESC LIMIT ?";

    public TweetRepository(CqlSession session) {
        this.session = session;
    }

    public void saveTweet(UUID authorId, String text, List<String> mediaUrls) {
        PreparedStatement ps = session.prepare(INSERT_TWEET);
        session.execute(ps.bind(authorId, text, mediaUrls));
    }

    public List<Row> getRecentTweets(UUID authorId, long beforeEpochMs, int limit) {
        PreparedStatement ps = session.prepare(FETCH_RECENT);
        // maxTimeuuid(timestamp) returns the largest TIMEUUID for that millisecond
        // so "tweet_id < maxTimeuuid(cutoff)" gives us all tweets before cutoff
        ResultSet rs = session.execute(ps.bind(authorId, beforeEpochMs, limit));
        return rs.all();
    }
}

Interview Preparation

Q: Why Cassandra for tweet storage rather than MySQL or PostgreSQL?

Tweets are a classic time-series write workload: extremely high write throughput (~6K/s globally), mostly append-only, and reads are always by author + time range. Cassandra's LSM-tree storage engine handles sequential writes efficiently. The partition key author_id means all of a user's tweets live on the same node set (no cross-node joins). TimeWindowCompactionStrategy keeps hot recent data on fast storage and compacts cold data separately. You cannot achieve this write throughput with a single RDBMS without enormous sharding complexity. Cassandra also has no single point of failure — every node is symmetric, so you get multi-region replication essentially for free.

Q: Fanout-on-write vs fanout-on-read — what are the real trade-offs?

Fanout-on-write (push): At tweet time, write the tweet_id into every follower's timeline sorted set in Redis. Reads are O(1) — just ZREVRANGE. The cost is write amplification: one tweet from a user with 100K followers causes 100K Redis writes. This is fine for normal users but catastrophic for celebrities. Fanout-on-read (pull): Do nothing at write time. At read time, query the database for the latest tweets from each person you follow, then merge. Read is expensive (N DB queries per timeline load), but write is cheap. Twitter's production architecture is hybrid: fanout-on-write for normal users (< ~1M followers), fanout-on-read for celebrities. The read service merges both sets on the fly.

Q: How do you handle the Lady Gaga / Katy Perry problem — accounts with 100M followers?

If you fanout-on-write for a 100M-follower account, one tweet triggers 100M Redis writes. At even 1μs per write that's 100 seconds of pipeline time — completely impractical. The solution is to exempt celebrity accounts from write fanout entirely. Instead, the timeline read service detects which accounts the user follows that are celebrities (above the follower threshold), fetches their most recent tweets directly from Cassandra at read time, and merges them with the Redis sorted set result. The merge is cheap because the read service only needs the last 20–50 tweets from each celebrity and there are rarely more than a handful of celebrities in any user's follow graph.

Q: How does Twitter generate trending topics?

Every tweet is published to Kafka and consumed by a search indexer that writes to Elasticsearch. A separate trending service maintains a sliding-window count of hashtag occurrences using Redis sorted sets: ZINCRBY trending:hashtags:{window_bucket} 1 "#hashtag". Every 30 seconds a cron job reads the top-K hashtags across the current and previous window buckets, applies geographic filtering (different trends per country), and publishes the result to a cache. Personalization layer then filters out trends the user has already seen. The velocity of a trending topic (rate of acceleration, not just count) matters more than the raw count — a hashtag gaining 10K mentions in 5 minutes beats one with 1M mentions spread over a day.


Learning Resources