Hirestack
Hirestack
Worked Example 15.1 · 1 of 15

Design Twitter (the feed problem)

📖 13 min read · 16 sections · May 2026
Asked at: Meta Twitter/X LinkedIn Quora

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)

QuestionWhy it mattersStory 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:

Non-functional:

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:

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)
ConceptWhen user X posts, immediately push the tweet into the cached "inbox" of every followerWhen user Y opens timeline, fetch each followed user's recent tweets and merge in real time
Read latencyExcellent — just read precomputed inboxPoor — scatter-gather across many partitions
Write latencyExcellent — async fan-outExcellent — single write
Write amplificationCatastrophic for celebs — 1 tweet = 60M cache writesNone — 1 tweet = 1 write
Storage costHigh — N copies of each tweet in N inboxesLow — single copy
FreshnessSlight delay — fan-out is asyncAlways fresh — computed at query time
Sweet spotUsers with moderate follower counts; read-heavy patternsUsers 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.

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.

Senior insight: notice this is a workload-driven architecture decision. The data distribution (long-tail follower counts) forced a hybrid. If everyone had similar follower counts, pure push would work. If everyone had millions, pure pull would work. The hybrid only exists because the workload is heterogeneous. This is the general lesson: let the data shape drive the architecture, not the other way around.

Design Decision 2: Where do inboxes live?

StorageProsConsVerdict
Redis (in-memory)Sub-ms reads; ZADD natively gives sorted-set timelineMemory is expensive; loses data on node failurePrimary choice for hot path
CassandraCheap per byte; durableHigher latency (~5-10ms); not as fast as RedisBacking/cold tier
PostgresFamiliar; ACIDDoesn't scale to this write volume; would saturateNo
In-process cache onlyFastestCan't share state across servers; per-user routing neededL1 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:

OptionFit
Cassandra, partitioned by user_id, clustered by created_at DESCStrong match — write-heavy, time-ordered within partition, scales by user
Postgres sharded by tweet_idRandom distribution but scatter-gather for profile timeline; worse fit
DynamoDB with composite keyWorks; 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

Interviewer: Justin Bieber tweets. Walk through what happens in your design.
Tweet service writes the tweet to Cassandra (user_id=Bieber). Publishes "tweet.posted" event to Kafka. Fan-out worker consumes the event. Worker fetches Bieber's follower count from a cache: it's 60M. That exceeds my celebrity threshold (10K), so fan-out is SKIPPED. The tweet is now in Cassandra but not in any inbox. When followers query their timelines, the timeline service detects they follow Bieber, fetches Bieber's recent tweets directly from Cassandra (a single partition read), and merges into the response.
Follow-up: But now 60M people querying their timeline will each fetch Bieber's tweets from Cassandra. That's a hot partition.
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.
Follow-up: A user gains followers and crosses the celebrity threshold mid-day. Now their fan-out should stop. Handle.
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

Interviewer: A user hasn't opened the app in 6 months. Their inbox has been evicted from Redis. They log in. Walk through.
Timeline service tries to read their inbox from Redis. Cache miss. Triggers an inbox rebuild: for each of their follows (up to maybe 5,000), fetch the last few tweets from each. Merge. This is essentially a one-shot pull-style query. Heavy: potentially 5,000 partition reads. But it's a rare event per user. After rebuild, populate Redis so subsequent reads are fast.
Follow-up: That first request takes seconds. UX is bad.
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

Interviewer: Show me the math on fan-out cost at peak.
17K tweets/sec peak. Average tweet has ~200 followers (median; long-tail tail handled separately by pull). So fan-out writes per sec: 17K × 200 = 3.4M Redis writes/sec. Sharded across 30 Redis shards: ~110K writes/sec/shard. Modern Redis can handle 100K+ ops/sec per shard. Comfortable headroom. The actual peak is concentrated in time (an event triggers a flurry) — the Kafka queue absorbs spikes; fan-out workers drain at sustainable rate; user inboxes are at-most-1000-entries each so old entries roll off.
Follow-up: What if all 17K tweets/sec come from users with exactly the threshold-minus-1 follower count (say 9,999 each)?
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

FailureImpactMitigation
Redis shard failsAffected users' inboxes are gone temporarilyReplica failover (5-10 sec). Inboxes rebuilt lazily from Cassandra. Users see degraded but functional timeline.
Fan-out worker pool overloadedNew tweets take longer to appear in followers' inboxesKafka buffers; workers scale on queue depth; backpressure shows as freshness lag (a few seconds → a few minutes) but no data loss.
Cassandra partition hotSpecific user's reads/writes slowReplicate 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 minL2 cache key for celebrity's tweets gets hammeredL1 in-process cache absorbs ~99% of reads. CDN can cache full timeline payload for high-cardinality reads.
Kafka outageTweets are written but no fan-out happensBacklog accumulates in Kafka (durable). Once Kafka recovers, workers catch up. Followers see delayed but eventually-correct timelines.

Scaling phases

ScaleArchitecture
1K usersSingle Postgres with the JOIN query. Postgres buffer cache makes it fast. No premature complexity.
100K usersRead replicas for the JOIN query. Add Redis cache for hot users' precomputed timelines.
10M usersMove tweets to Cassandra (write-heavy fit). Pure push fan-out with Kafka. Redis for active-user inboxes.
100M usersIntroduce celebrity threshold; hybrid push/pull. Multiple cache tiers (in-process + Redis cluster).
1B usersRegional 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

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.