Loading…
Loading…
Design Twitter's core functionality — users post tweets, follow other users, and see a timeline feed showing tweets from people they follow. The system must handle celebrities with millions of followers.
[Write Path]
User → API Gateway → Tweet Service
│
Kafka (tweet events)
│
┌───────────┴────────────┐
│ │
Fan-out Service Search Indexer
│ (Elasticsearch)
injects tweet into
followers' timeline caches
[Read Path]
User → API Gateway → Timeline Service → Redis Cache → DB fallback
Fan-out on write (push model): when a tweet is posted, a background worker writes it to all followers' timeline caches. Timeline reads are O(1) from Redis.
Feed generation (which posts land in a timeline, via fan-out) and feed ranking (what order they're shown in) are deliberately separate concerns. The fan-out mechanism above populates a timeline cache with candidate post IDs in roughly chronological order — a separate ranking service re-orders (or re-scores) that candidate set at read time, based on signals like recency, engagement, and affinity to the viewing user. Keeping these separate means the ranking algorithm (which evolves constantly, often ML-driven) can be swapped or A/B tested without touching the fan-out/storage architecture at all — this is exactly where an ML ranking model would plug in, as a post-processing step over the candidate set fan-out already produced, not as part of fan-out itself.
Posts themselves (full content) live in a durable store (see the general storage-choice discussion in Design a Chat Application's database design). The feed cache is a distinct, separate structure: Redis sorted sets, keyed per user, scored by timestamp (or a ranking score) — storing only post IDs, not full content. Rendering a feed means reading the sorted set for the ID list, then batch-fetching full post content from the durable store (or a post-content cache) for just that page's IDs — separating "which posts, in what order" from "the posts' actual content" keeps the frequently-read, frequently-reordered structure small and fast.
Offset-based (LIMIT 20 OFFSET 40) is simple but breaks under concurrent writes — if new posts arrive between two page requests, offset-based pagination can skip or duplicate items, since "item at position 40" shifts as new items are inserted. Cursor-based pagination instead uses an opaque pointer (typically the last-seen item's score/timestamp) as the starting point for the next page — "give me items older than cursor X" is stable regardless of how many new items arrived in between, which is why every major feed product uses cursor-based pagination, not offset-based, for infinite scroll.
POST /api/v1/tweets
Body: { text, mediaIds? }
Response: { tweetId, createdAt }
GET /api/v1/timeline/home?cursor=&limit=20
Response: { tweets: [...], nextCursor }
GET /api/v1/tweets/{tweetId}
POST /api/v1/users/{userId}/follow
DELETE /api/v1/users/{userId}/follow
GET /api/v1/search?q=keyword&cursor=
Tweets (Cassandra — append-only, time-series)
tweets
tweet_id UUID PK
user_id UUID
content TEXT
media_urls LIST<TEXT>
created_at TIMESTAMP
like_count COUNTER
User social graph (dedicated graph store or Cassandra)
followers
user_id UUID PK
follower_id UUID
following
user_id UUID PK
followee_id UUID
Timeline cache (Redis Sorted Set, score = tweet timestamp)
timeline:{userId} → ZSet of {tweetId: timestamp}
Fan-out on write breaks for celebrities (e.g., Obama with 130M followers). Writing to 130M timeline caches per tweet takes ~10 seconds.
Solution — Hybrid fan-out:
At read time, the timeline service merges:
Retain only the 800 most recent tweet IDs per user. Older tweets loaded from DB on scroll.
Partitioning data by the user's geographic region so reads/writes happen physically close to where the user actually is, reducing cross-continent latency — a user in Tokyo reading/writing to a Tokyo-region shard avoids round-tripping to a US-based datacenter on every request. The cost: cross-region queries (e.g. a global trending-topics feature spanning all regions) become genuinely more complex, needing to fan out across regions and aggregate, rather than a single local query.
An alternative to pure hash/range sharding: a central lookup service explicitly maps shard keys (e.g. user ID) to physical shards, rather than deriving the mapping algorithmically from a hash function. This gives full, explicit control over placement — exactly what geo-sharding needs ("this user's data should live in the EU shard specifically," a business/compliance requirement a pure hash function can't express) — at the cost of that directory service itself becoming a potential bottleneck or single point of failure if it isn't made highly available in its own right (typically via its own replication and caching, since it's read far more often than written).