YeetCode
System Design · Advanced Designs

Design Twitter: Fanout, Timelines, and the Celebrity Problem

How to design Twitter in a system design interview — fanout-on-write vs read, the celebrity problem, capacity math, timeline caching, search, and trending topics.

9 min readBy Bhavesh Singh
design twitterfanout on writetimeline generationcelebrity problemsocial graph

This article has an interactive companion

Don't just read it — step through it interactively, one state change at a time.

Practice this topic on YeetCode

"Design Twitter" looks like a feed problem, but the interview lives or dies on one number: 200 versus 100 million. The average user has around 200 followers; a celebrity has a hundred million. Any design that treats those two the same way falls over, and the interviewer knows it. Your job is to find the seam.

The core loop is small — post a tweet, follow people, read a home timeline of the accounts you follow, newest first. Everything hard comes from scale and from the wild spread of follower counts. Get the fanout strategy right and the rest of the system organizes itself around it.

What are we actually building?

Nail the functional scope before touching a datastore. For a 45-minute interview, the in-scope features are:

  • Post a tweet — 280 characters, optionally media, immutable once written.
  • Follow / unfollow a user, building a directed social graph.
  • Home timeline — a reverse-chronological (or ranked) merge of tweets from everyone you follow.
  • User timeline — one author's own tweets.
  • Search — keyword, hashtag, and user queries.

Push likes, retweets, DMs, notifications, and ads to "if we have time." The non-functional bounds are what make it a senior-level answer:

PropertyTargetWhy it drives design
Timeline read latencyp99 < 200msRules out computing the feed on every read
Timeline freshnessseconds, eventualLets fanout run async on a queue
Availabilityreads > writesA stale timeline beats a 500; favor AP
Durabilityno lost tweetsQuorum write before ack
Read:write ratio~5:1 on timelinesOptimize the read path first

The single most important line to say out loud: reads dominate, so we pay work at write time to make reads cheap. That one sentence is the whole design.

Capacity estimation: run the numbers

Interviewers want assumptions they can push on, not false precision. Start from 250M daily active users, roughly 2 tweets posted per user per day, 10 timeline reads per user per day, and an average of 200 followers.

text
Tweets/day = 250M × 2 = 500M ≈ 5,800 writes/sec (avg) Timeline reads= 250M × 10 = 2.5B/day ≈ 29,000 reads/sec (avg) Peak factor ≈ 5× → reads peak ~145,000/sec

Now the number that reshapes everything — fanout amplification. If every new tweet is copied into each follower's timeline at write time:

text
Fanout writes = 5,800 tweets/sec × 200 avg followers = 1,160,000 inserts/sec (avg) ≈ 3–5M inserts/sec at peak

So the timeline cache absorbs ~40× more write traffic than the read path it serves (1,160,000 ÷ 29,000) — the opposite of the naive intuition that a read-heavy product has a read-heavy backend. It's why you shard timelines across a large Redis fleet by user_id, and it sets up the celebrity problem: one account with 100M followers would add 100 million writes on top of that 1.16M baseline the instant it tweets.

Storage is cheaper to reason about: ~300 bytes of tweet metadata × 500M tweets/day is ~150 GB/day, ~55 TB/year — trivially shardable, and dominated in practice by media, which lives in blob storage plus a CDN, not the tweet table.

Fanout on write vs fanout on read

Two ways to build a home timeline, and the answer is "both."

Fanout on write (push): when a user tweets, push the tweet_id into every follower's precomputed timeline list. Reads become an O(1) list fetch plus bulk hydration. Reads are dirt cheap; writes explode with follower count.

Fanout on read (pull): store nothing per-follower. At read time, gather the accounts you follow, fetch their recent tweets, and merge-sort by time. Writes are trivial; reads scale with how many people you follow.

StrategyWrite costRead costBest for
Fanout on writeO(followers)O(1) fetch + hydrateNormal users (~200 followers)
Fanout on readO(1)O(followees × tweets)Celebrities / inactive users

The production answer is a hybrid. Fan out on write for ordinary accounts. For any author above a follower threshold — call it 10K–100K — do not fan out; their tweets stay in their own user timeline and get pulled and merge-sorted in at read time. That single rule bounds average fanout to ~200 writes per tweet instead of 100M, kills the write hotspot when a mega-account posts, and keeps read latency bounded since you only merge a handful of celebrity feeds per read.

The timeline cache and tweet storage

Per-follower timelines live in a Redis-backed store: each user's home timeline is a capped list of roughly the 800 most recent tweet IDs — IDs, not bodies. Storing IDs keeps each list small (a few KB) and means an edit or delete never has to rewrite millions of cached copies. The list is trimmed on insert so it never grows unbounded.

Read path, end to end:

text
GET /timeline?user_id=U&cursor=C 1. LRANGE the user's Redis timeline list → ~800 tweet_ids 2. Merge-sort in the celebrity tweets pulled at read time 3. Bulk-hydrate tweet bodies (Redis hot cache → LSM store on miss) 4. Return a page + next cursor

Tweets themselves are immutable, which unlocks the storage choice. Use an append-only LSM store (Cassandra / Manhattan-style) sharded by tweet_id, where tweet_id is a Snowflake 64-bit ID — a timestamp, a shard number, and a sequence counter packed together. That gives two free wins: IDs sort chronologically, and a point read routes directly to the owning shard from the ID's bits alone, no lookup table. Hot recent tweets (under a week old) also sit in a Redis cluster hashed by tweet_id, so sub-10ms reads hit cache and cold tweets fall through to the LSM store. Durability comes from a quorum write within one region plus async replication to a secondary region.

The social graph

Follow edges are their own service (think FlockDB or Meta's TAO), not a JOIN in your tweet database. At 1B+ edges the trick is to store every edge twice: one index keyed follower → followee, one keyed followee → follower, each sharded by its primary side. That way "who do I follow?" (needed to pull celebrity tweets) and "who follows me?" (needed to fan out) are both single-shard queries instead of a scatter-gather.

Celebrity follower lists are enormous, so they're read with cursor pagination and cached in compressed form — roaring bitmaps pack tens of millions of follower IDs into a few MB. Follow/unfollow writes go through a write-behind queue so both indexes update atomically via a transactional outbox, and invalidation uses versioning rather than blunt cache purges.

API and data model

Keep the surface small and boring:

text
POST /v1/tweet { text, media_ids? } → { tweet_id } GET /v1/timeline/home ?cursor=&limit= → { tweets[], next_cursor } GET /v1/timeline/user ?user_id=&cursor= → { tweets[], next_cursor } POST /v1/follow { target_user_id } DELETE /v1/follow { target_user_id } GET /v1/search ?q=&type=keyword|hashtag|user

Three logical tables plus two caches back it:

sql
-- Tweets: immutable, sharded by tweet_id (Snowflake) tweet(tweet_id PK, author_id, text, media_ids, created_at) -- Social graph: stored twice, sharded by the primary side following(follower_id, followee_id, created_at) -- index by follower followers(followee_id, follower_id, created_at) -- index by followee -- Timeline cache: Redis list, ~800 recent tweet_ids per user timeline:{user_id} → [tweet_id, tweet_id, ...]

Cursor pagination uses the tweet_id itself — because Snowflake IDs are time-ordered, "give me the next page before cursor X" is a bounded range read with no offset scan.

Search over trillions of tweets runs on Elasticsearch/Lucene sharded by time — daily or weekly indices. Old shards go read-only and cold; a small set of hot recent indices handle near-real-time writes. Tweets land in Kafka, get tokenized and entity-extracted, then bulk-index into the hot shards with a 1–5s refresh interval. Queries scatter across the relevant time shards with early termination once enough results are collected. For sub-second freshness, a separate in-memory inverted-index tier (Twitter's Earlybird) serves the newest tweets.

Trending topics run as a streaming job (Flink/Heron) over the tweet firehose, extracting n-grams and hashtags into sliding-window counts (last 5m, 1h) held in a per-region count-min sketch. The key insight: score by velocity — current rate versus historical baseline — so a genuinely novel burst outranks a term that's always popular. Spam resistance comes from counting unique authors with HyperLogLog instead of raw occurrences, dropping terms fewer than N distinct accounts touch, and weighting by reputation. Per-region trends flush to Redis every minute.

Edit tweet (15-minute window) without breaking immutability: an edit writes a new tweet row with a new tweet_id but a shared edit_group_id, and the original ID keeps a pointer to the latest version. Timeline caches already hold IDs, so hydration just follows the pointer — you never rewrite the ~1M cached timeline entries that reference the tweet. After the window closes, a background job prunes the pointer and the tweet becomes truly immutable again.

How this shows up in interviews

The interviewer's real question is never "draw the boxes." It's "where does this break, and what do you pay to fix it?" Expect the conversation to funnel straight to fanout amplification, then the celebrity problem, then timeline freshness versus cost.

Strong answers do three things: quote the ~1.16M inserts/sec fanout number so the celebrity exclusion is obviously necessary, not a hand-wave; name the source of truth for each piece of state (LSM store for tweets, graph service for edges, Redis for timelines); and volunteer the trade-off you'd reverse under a different workload — say, dropping fanout-on-write if the product became follow-thousands, read-rarely. Walk one tweet from POST through fanout to a follower's read and you've demonstrated the whole system.

Further learning

FAQ

Why can't Twitter fan out every tweet to every follower's timeline?

Amplification, not principle, is the problem — pushing a tweet costs O(followers) writes, a rounding error at 200 followers and a self-inflicted outage at 100 million. The fix isn't dropping push fanout; it's capping who qualifies. Draw a line at a follower count (roughly 10K–100K): under it, push on write; over it, pull and merge at read time.

Where does the ~40× gap between fanout writes and timeline reads come from?

Arithmetic. 250M DAU × 2 tweets/day ≈ 5,800 writes/sec. Each write fans out to ~200 followers, so timeline inserts run ~1.16M/sec, spiking to 3–5M/sec. Timeline reads sit around 29,000/sec (250M × 10 reads/day). Divide the two and writes outrun reads by roughly 40× — why timelines shard across a large Redis fleet rather than one hot store.

Why does the timeline cache hold tweet IDs instead of tweet bodies?

Size and mutability. An 800-entry list of IDs is a few KB; full bodies would be far larger and need rewriting on every edit or delete. Keeping only IDs means the cache never has to know a tweet changed — hydration fetches whatever the ID currently resolves to, hitting Redis first and the LSM store on a miss.

How does search index new tweets fast enough without choking on history?

By splitting along time. Trillions of historical tweets sit in cheap, read-only, time-sharded Elasticsearch/Lucene indices, while a small rotating set of "hot" indices absorbs new writes. Tweets arrive via Kafka, get tokenized and entity-tagged, and land in a hot shard within a 1–5s refresh — with an in-memory Earlybird-style tier catching stragglers. A query fans out only to the shards it needs and stops once it has enough hits.

How do you let users edit a tweet without invalidating cached timelines?

Never mutate the row readers already point to. An edit is a fresh insert — new tweet_id, shared edit_group_id — and the original ID is updated to point at that row. Since cached timelines only ever stored the ID, nothing downstream changes: hydration follows the pointer to whatever text is current. When the 15-minute window expires, a cleanup job drops the pointer.

Make it stick: run this one yourself

Don't just read it — step through it interactively, one state change at a time.

Practice this topic on YeetCode