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.
This article has an interactive companion
Don't just read it — step through it interactively, one state change at a time.
"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:
| Property | Target | Why it drives design |
|---|---|---|
| Timeline read latency | p99 < 200ms | Rules out computing the feed on every read |
| Timeline freshness | seconds, eventual | Lets fanout run async on a queue |
| Availability | reads > writes | A stale timeline beats a 500; favor AP |
| Durability | no lost tweets | Quorum write before ack |
| Read:write ratio | ~5:1 on timelines | Optimize 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.
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/secNow the number that reshapes everything — fanout amplification. If every new tweet is copied into each follower's timeline at write time:
Fanout writes = 5,800 tweets/sec × 200 avg followers
= 1,160,000 inserts/sec (avg)
≈ 3–5M inserts/sec at peakSo 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.
| Strategy | Write cost | Read cost | Best for |
|---|---|---|---|
| Fanout on write | O(followers) | O(1) fetch + hydrate | Normal users (~200 followers) |
| Fanout on read | O(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:
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 cursorTweets 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:
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|userThree logical tables plus two caches back it:
-- 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.
Deep dives: search, trends, and edits
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
- System Design Interview Walkthrough: Design Twitter — Hello Interview's end-to-end walkthrough of the hybrid fanout model and follow-up questions.
- Practice this topic on YeetCode — step through the design interactively on the roadmap.
- Related builds that reuse these patterns: Design Uber, Design Google Maps, Design Netflix, and Design Spotify.
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.