Design Twitter (the feed problem)
The setup
You're interviewing for a senior backend role at a large social media company. The interviewer settles into the chair, opens their notebook, and says: "Let's design Twitter. Specifically the home timeline — the part where a user opens the app and sees recent tweets from people they follow. Think out loud as you go."
You have 60 minutes. The interviewer wants to see how you think, not whether you've memorized an answer. They'll push back at every architectural decision. The unstated test: can you balance competing concerns (read latency vs write cost vs operational complexity vs cost), articulate explicit tradeoffs, and adapt when challenged?
Many candidates fail this not because they don't know the technical pieces, but because they jump immediately to "we'll use Kafka and Cassandra." The interviewer doesn't learn anything from that. The good candidate spends the first 5-7 minutes locking down scope — because the design depends entirely on the answers.
The first 5 minutes: clarifying questions (don't skip)
| Question | Why it matters | Story answer |
|---|---|---|
| What's in scope — just the feed, or also DMs, search, notifications, media uploads? | Scope determines depth. Trying to design everything in 60 minutes produces shallow work everywhere. Locking scope is a senior signal. | "Home timeline and posting tweets only. Skip DMs, search, notifications, media." |
| What's our scale? MAU, tweets/day, peak read QPS? | You can't pick an architecture without numbers. A system for 100K users looks nothing like a system for 300M. | "300M MAU. 500M tweets/day. Read traffic averages 200K timeline reads/sec; peaks can hit 1M during major events." |
| What's the read-to-write ratio? | Tells you whether to optimize the read path or write path. Feed systems are extreme — most users are passive consumers. | "~100:1 reads vs writes. Most users scroll, don't tweet." |
| How fresh does the timeline need to be? | Sub-second freshness pushes you toward push-based; minutes-tolerant freshness is far more forgiving and changes the architecture. | "A few seconds of staleness is acceptable. Eventual consistency is fine." |
| Are users uniformly distributed in follower counts? | The celebrity problem is THE central challenge. Asking this question demonstrates awareness of long-tail distributions. | "Massively long-tail. Median user has ~100 followers. 99th percentile has thousands. Top 100 accounts have tens of millions." |
| Tweet character limit? Media attachments? | Affects storage size, CDN strategy, bandwidth costs. | "280 characters text only for this session. Images and video out of scope." |
| What's the p99 latency target for timeline render? | This single number anchors the entire design. 100ms vs 1000ms means different architectures. | "p99 under 200ms for the first page of timeline." |
| Globally distributed users or single region? | Multi-region adds significant complexity. Better to scope down for the first design and add regions later. | "Single primary region for this session; mention multi-region as future work." |
Locked-in requirements
Functional:
- User can post a tweet (280 char text)
- User can follow / unfollow other users
- User can view their home timeline: recent tweets from followed users, reverse chronological
- User can view a user's profile timeline: that user's own tweets
Non-functional:
- 300M MAU; 500M tweets/day; 1M timeline reads/sec peak
- p99 timeline render < 200ms
- Eventual consistency acceptable (3-5 sec lag tolerable for new tweets)
- 99.9% availability
- Long-tail follower distribution (celebrities are first-class concern)
Back-of-envelope capacity estimation
Quantification is a senior reflex. Compute these numbers explicitly — they justify every later decision.
Tweet write rate: 500M / day ÷ 86400 sec = ~5,800 tweets/sec average Peak (3× average during major events): ~17,000/sec Read rate: 200K timeline reads/sec average; 1M/sec peak Tweet storage: Avg tweet: 280 chars + 200 bytes metadata = ~700 bytes 500M × 700 = 350 GB/day raw tweets Annual: ~130 TB 10-year: ~1.3 PB (with replication 3× = ~4 PB) User & follow data: 300M users × 1KB profile = 300 GB Avg follower count: ~200; total follow edges: 60B × 24 bytes = ~1.5 TB Sparsely sharded by user_id Timeline cache footprint: 300M users × 1000 cached tweet refs × ~80 bytes = ~24 TB But only 10% of users are "active" in a 24h window Active cache footprint: ~2.4 TB (fits in a Redis cluster) Read bandwidth: 1M reads/sec × 100KB payload = 100 GB/sec = 800 Gbps Need CDN + edge caching for timeline payloads
These numbers establish constraints: storage is petabyte-scale (commodity), reads dominate bandwidth (need caching), the cache fits in memory if you only cache active users.
The naive design (and exactly why it fails)
The intermediate engineer's instinct: just query.
SELECT t.* FROM tweets t JOIN follows f ON t.user_id = f.followee_id WHERE f.follower_id = $me ORDER BY t.created_at DESC LIMIT 100;
Why this fails at scale, quantitatively:
- A user who follows 1,000 people: the join touches potentially 1,000 partitions of the tweets table. Even if each partition lookup is 1ms (optimistic), that's 1 second per query.
- At 1M reads/sec across the cluster, you'd need 1M × 1 sec = 1M concurrent DB queries running. Impossible on commodity infrastructure.
- The ORDER BY DESC + LIMIT requires sorting matching rows — at scale, the sort itself dominates.
- Hot users (celebrities followed by millions) cause specific partitions to be hot — disproportionate read load on those shards.
The fundamental insight: at this scale, you don't compute the timeline on read; you precompute it on write. Or some hybrid. This single architectural pivot — moving work from read to write — is the central lesson of feed design.
Design Decision 1: Push vs Pull (the central tradeoff)
Two pure architectures, each with severe weaknesses:
| Push (fan-out on write) | Pull (fan-out on read) | |
|---|---|---|
| Concept | When user X posts, immediately push the tweet into the cached "inbox" of every follower | When user Y opens timeline, fetch each followed user's recent tweets and merge in real time |
| Read latency | Excellent — just read precomputed inbox | Poor — scatter-gather across many partitions |
| Write latency | Excellent — async fan-out | Excellent — single write |
| Write amplification | Catastrophic for celebs — 1 tweet = 60M cache writes | None — 1 tweet = 1 write |
| Storage cost | High — N copies of each tweet in N inboxes | Low — single copy |
| Freshness | Slight delay — fan-out is async | Always fresh — computed at query time |
| Sweet spot | Users with moderate follower counts; read-heavy patterns | Users with massive follower counts; read-rare |
Pure push fails on celebrities. Justin Bieber has 60M followers. When he tweets, fan-out is 60M cache writes. At 10ms per write with 1000 parallel workers: 60M / 1000 / 100 ops/sec = 600 seconds. Or with bigger fan-out workers: still minutes of work for one tweet. Meanwhile other writes pile up.
Pure pull fails on normal reads. A timeline read requires fetching from N followees' partitions. For a user following 500 people, that's 500 partition reads merged in real time. Even with sub-ms reads per partition, the total adds up; tail latency is awful.
The resolution: hybrid push-pull, segmented by follower count.
- For users below a threshold (e.g., < 10K followers): use push. Their fan-out is manageable. Their followers' timelines are precomputed.
- For users above the threshold ("celebrities"): use pull. Their tweets aren't fanned out; their followers' timelines pull live from celebrity tweet streams when reading.
- A user's home timeline is the MERGE of: (a) their precomputed inbox from non-celebrity follows, and (b) live-fetched recent tweets from celebrity follows.
This is approximately what real Twitter has done historically. The threshold is a tuning knob — set so that the system stays in the operating envelope.
Design Decision 2: Where do inboxes live?
| Storage | Pros | Cons | Verdict |
|---|---|---|---|
| Redis (in-memory) | Sub-ms reads; ZADD natively gives sorted-set timeline | Memory is expensive; loses data on node failure | Primary choice for hot path |
| Cassandra | Cheap per byte; durable | Higher latency (~5-10ms); not as fast as Redis | Backing/cold tier |
| Postgres | Familiar; ACID | Doesn't scale to this write volume; would saturate | No |
| In-process cache only | Fastest | Can't share state across servers; per-user routing needed | L1 cache only |
Decision: Redis primary for active users; lazy compute + Redis populate for inactive users.
Memory math: 30M active users × 1000 cached tweet refs × ~80 bytes per ref = ~2.4 TB total cache footprint. Across a Redis cluster of 30 shards with replication: ~5 TB raw memory cost. At $1/GB/month: ~$5K/month for the timeline cache.
Each user's inbox is a Redis sorted set (ZSET) keyed by tweet_id, scored by timestamp. ZADD for inserts (fan-out worker), ZRANGE for reads (timeline service). Capped at 1000 entries per inbox via periodic ZREMRANGEBYRANK.
Design Decision 3: Tweet storage
Tweets need persistent storage. Access patterns:
- Write: append-only, ~17K/sec peak
- Read by tweet_id: lookups by single ID (timeline fetches the actual tweet content)
- Read by user_id: a user's profile timeline (their own tweets in time order)
| Option | Fit |
|---|---|
| Cassandra, partitioned by user_id, clustered by created_at DESC | Strong match — write-heavy, time-ordered within partition, scales by user |
| Postgres sharded by tweet_id | Random distribution but scatter-gather for profile timeline; worse fit |
| DynamoDB with composite key | Works; vendor lock-in; more cost at our scale |
Cassandra wins. Schema: tweets((user_id), created_at DESC, tweet_id). A user's tweets are co-located in one partition, sorted by recency. Reading "user X's last 20 tweets" is a single partition read.
For tweet_id lookups (when timeline returns IDs and we need content), a separate secondary table: tweets_by_id(tweet_id). Yes, this duplicates data — that's normal in Cassandra. Denormalize for query patterns.
The complete architecture
flowchart LR
U[User posts tweet] --> API[API gateway]
API --> WS[("Tweet store
(Cassandra)")]
API --> FanQ[/Fan-out queue/]
FanQ --> FW1[Fan-out worker]
FanQ --> FW2[Fan-out worker]
FW1 -->|look up followers| FG[("Follower graph")]
FW2 --> FG
FW1 -->|push tweet_id| IC[("Inbox cache
(Redis, per user)")]
FW2 --> IC
R[Reader requests timeline] --> TLS[Timeline service]
TLS -->|"get inbox IDs"| IC
TLS -->|"hydrate tweets"| WS
TLS --> R
┌──────────────┐
│ Mobile/Web │
└──────┬───────┘
│
▼
┌──────────────────┐
│ API Gateway │
└──┬───────┬───────┘
│ │
│ └─ Write path (POST /tweet)
│ ↓
│ ┌─────────────────┐
│ │ Tweet Service │
│ └────────┬────────┘
│ │
│ ├──► Cassandra (tweets by user_id)
│ │
│ └──► Kafka topic: "tweet.posted"
│ ↓
│ ┌──────────────────────┐
│ │ Fan-out Worker Pool │
│ └──────────┬───────────┘
│ │ For each follower (if not celebrity):
│ │ ZADD inbox:{follower_id} {tweet_id}
│ ▼
│ Redis Cluster (inboxes)
│
└─ Read path (GET /timeline)
↓
┌───────────────────────┐
│ Timeline Service │
└────────┬──────────────┘
│
├──► Redis: read inbox:{user_id}
├──► Cassandra: for each celebrity follow, fetch recent tweets
├──► Merge by timestamp; pick top-100 tweet_ids
├──► Cassandra: fetch tweet content by tweet_ids
└──► Return assembled timeline
Cross-examination round 1: the celebrity problem
A: Right. Multiple mitigations stacked: (1) Cache Bieber's recent tweets (last 100) in a separate Redis cache keyed by user_id. Timeline service reads from this cache, not Cassandra directly. (2) Replicate that cache across multiple Redis shards (read replicas). (3) Add an L1 cache in each timeline service instance — celebrity tweets are read so frequently that an in-process cache (Caffeine) has near-100% hit rate for the top 1000 celebrities.
A: Threshold check happens at fan-out time, not at follow time. The fan-out worker reads current follower count when processing each tweet. As soon as the count crosses the threshold, subsequent tweets are routed through the pull path. Previously-fanned-out tweets that already populated inboxes stay there — followers see them via inbox; new tweets they see via pull merge. The transition is seamless from the user's perspective.
Cross-examination round 2: the inactive user problem
A: Two strategies. (a) Return a partial timeline quickly (top-100 follows by recency or relevance) while a background job completes the full rebuild. (b) Cap the cold rebuild to the most-recent-activity follows (e.g., top 200 follows that have tweeted recently). The user sees a reasonable timeline; we accept that some less-active follows are missed for the first page until full rebuild completes.
Cross-examination round 3: write amplification math
A: Worst case: 17K × 9999 = 170M writes/sec. That overwhelms the Redis tier. Mitigation: dynamic threshold adjustment based on system load. If fan-out queue depth grows, temporarily lower the threshold to push more users into the pull path. Reactive load shedding.
Failure mode analysis
| Failure | Impact | Mitigation |
|---|---|---|
| Redis shard fails | Affected users' inboxes are gone temporarily | Replica failover (5-10 sec). Inboxes rebuilt lazily from Cassandra. Users see degraded but functional timeline. |
| Fan-out worker pool overloaded | New tweets take longer to appear in followers' inboxes | Kafka buffers; workers scale on queue depth; backpressure shows as freshness lag (a few seconds → a few minutes) but no data loss. |
| Cassandra partition hot | Specific user's reads/writes slow | Replicate hot partition's data (key replication: same content under multiple partition keys). Read can hit any replica. |
| Celebrity tweet goes viral, 10M reads in 5 min | L2 cache key for celebrity's tweets gets hammered | L1 in-process cache absorbs ~99% of reads. CDN can cache full timeline payload for high-cardinality reads. |
| Kafka outage | Tweets are written but no fan-out happens | Backlog accumulates in Kafka (durable). Once Kafka recovers, workers catch up. Followers see delayed but eventually-correct timelines. |
Scaling phases
| Scale | Architecture |
|---|---|
| 1K users | Single Postgres with the JOIN query. Postgres buffer cache makes it fast. No premature complexity. |
| 100K users | Read replicas for the JOIN query. Add Redis cache for hot users' precomputed timelines. |
| 10M users | Move tweets to Cassandra (write-heavy fit). Pure push fan-out with Kafka. Redis for active-user inboxes. |
| 100M users | Introduce celebrity threshold; hybrid push/pull. Multiple cache tiers (in-process + Redis cluster). |
| 1B users | Regional deployments. Pre-rendered timeline payloads cached at CDN edge for top-N% of users. ML-ranked feed (rather than strict chronological) added as a separate layer. |
Operational considerations
- Observability: per-tier metrics (fan-out queue depth, Redis hit rate per shard, Cassandra partition latency p99, celebrity cache hit rate). SLO: 99.9% of timeline requests under 200ms.
- Capacity planning: Redis at <60% utilization; Cassandra at <50%; Kafka topic at <40% capacity. The fan-out worker pool auto-scales on queue depth.
- Deploys: timeline service and fan-out workers deploy independently. Backward-compatible Kafka message schemas (Avro with schema registry) — old workers can still consume new messages.
- Cost: at this scale, the dominant cost is Redis memory (~$10K-$50K/month for the active cache) and Cassandra storage (~$5K-$20K/month). LLM/ML costs if you add ranking later can dominate everything else.
Takeaway
The Twitter timeline problem has been the canonical feed design case study for over a decade. The lessons generalize to every similar system (Facebook feed, Instagram, LinkedIn, even Reddit). The core insight: at scale, you choose where the computation happens — write time or read time. Push (precompute) makes reads cheap but writes expensive; pull (compute on demand) inverts the cost. Almost every interesting feed/timeline/notification system in production uses a hybrid because the workload is heterogeneous. The hybrid isn't a compromise — it's a workload-fitted optimization.
The second lesson: the long tail dominates architectural complexity. If everyone had the same follower count, this would be a much simpler design. The fact that some users have 60M followers while most have 100 forces the hybrid. Whenever you see a heavy-tailed distribution in a system you're designing, expect to need a special path for the tail.