Index/Twitter / Instagram Feed

SponsorGitHub
Design13 min

Twitter / Instagram Feed

A home timeline serving 150k reads per second, where one post can reach a hundred million followers.

Primary concepts and the hard part

Concepts
fan-out on write vs readhybrid fan-outwide-column storagecachingdenormalizationcursor paginationread:write skew
The hard part they’re probing
The celebrity problem. A design that only works for the median user is a fail. They want to see you recognize that fan-out cost is bimodal and split the code path.

Requirements

Functionalwhat it must do
Post a tweet/photo (text + optional media)
Follow / unfollow a user
View home timeline (posts from people you follow, reverse-chronological)
View a user's own profile timeline
Non-functionalhow well it must do it
Read-heavy: assume ~100:1 read:write
Home timeline load p99 < 200ms
Availability over consistency — a 5-second-stale timeline is fine, a failed load is not
Eventually consistent; posts appear within a few seconds
Out of scopedeliberately left out
DMs
Search
ML ranking
Ads
Notifications
Scale
300M DAU · 
Posts:    50M/day    → ~500/sec  (peak 3x = 1,500/sec)
Timeline reads:              → ~150,000/sec
Avg followers:  ~200         → fan-out multiplier 200
Celebrity max:  100M followers
Post row: ~300B → 50M × 300B = 15 GB/day of posts
Feed rows: 50M × 200 = 10B rows/day of references (~50B each = 500 GB/day)
Conclusion
10B feed writes/day is the whole problem. That number is what forces the hybrid.

API / Model

POST
/v1/posts
{text, media_ids}
201Createdpost_id
GET
/v1/timeline/home?cursor=&limit=50
200OKhome feed page
GET
/v1/users/{id}/posts?cursor=&limit=50
200OKuser's posts
POST
/v1/users/{id}/follow
204No Content
DELETE
/v1/users/{id}/follow
204No Content
primary or partition keysort key, ↓ newest firstforeign key → referenced columnnullableHover a table or column to trace its keys

Two directions of the social graph are stored separately because fan-out needs "who follows X" while the UI needs "who does X follow". Same data, two access patterns, two tables. That's Module 2 in action.


High-level architecture

DatabaseCacheObject storageQueue / streamAPI gatewayFocusClick a node for details

The feed is a write path and a read path behind one API Gateway. They never call each other: the write path fills two stores, and the read path merges them.

  1. The client's POST /posts passes the API Gateway, which handles auth and rate limiting, and reaches the Post Service.
  2. The Post Service writes the post to the posts store in Cassandra and publishes post.created to Kafka, then returns without waiting for fan-out.
  3. The Fan-out service, running as a consumer group, reads the event and checks whether the author has more than 100k followers.
  4. For an ordinary author, it fans out to followers with batched, idempotent writes, adding one reference row per follower to user_timeline, keyed by user_id and sorted by post_id descending.
  5. For a celebrity, it skips eager fan-out. The post lands in the Redis celebrity cache instead, where one list serves every follower at read time.

Reads never wait on fan-out. A GET /timeline reaches the Timeline Service, which touches only what the write path left behind, user_timeline rows and the celebrity cache, and keeps each merged page in the Redis assembled timeline for 30 seconds. Images and video bypass both paths: the response carries media URLs, and the client loads the bytes from Blob storage + CDN.

Read path in words: Timeline Service checks Redis for an assembled timeline. On miss, it reads the user's precomputed user_timeline rows (cheap, single partition), separately fetches recent posts from the handful of celebrities that user follows (cached, so near-free), merges the two lists by post_id descending, hydrates post bodies, and caches the result for 30 seconds.


Trade-offs and deep dives

01
Why fan-out on write is the base case

Reads outnumber writes 100:1. Precomputing moves work from the frequent operation to the rare one. Read becomes a single-partition sequential scan on a sorted key: ~1ms.

02
Why it can't be the only case

100M followers × even a few posts/day saturates the write path and arrives as a spike. There is no amount of horizontal scaling that makes 100M synchronous writes a good idea for one post.

03
Why the hybrid works

The costs are inversely distributed. A user follows many ordinary accounts (so precomputation pays off) but only a few celebrities (so read-time merge is cheap). You pick the cheap side of each. And a celebrity's recent-posts list is one cache entry serving 100M readers — the highest leverage cache in the system.

04
Threshold T is a tunable, not a constant

Set it empirically where fan-out cost exceeds merge cost. Expect it to differ by product.

05
Active-user optimization

Only fan out to users active in the last ~30 days. Most registered accounts are dormant; this can eliminate the majority of feed writes. Dormant users get a full read-time rebuild when they return.

06
Deletes

Don't fan out deletions — that's another N writes. Filter at read time by checking a tombstone set, or by verifying the post still exists during hydration. Trade a slightly more expensive read for avoiding a huge delete fan-out.

07
Feed length cap

Keep ~800-1,000 refs per user. Nobody scrolls further; without a cap storage grows unbounded.

08
Idempotent fan-out

Kafka is at-least-once, so the same post may be fanned out twice. PK=(user_id, post_id) makes a duplicate write a harmless overwrite.

09
Pagination

Cursor-based on post_id (Snowflake IDs are time-sortable, so the ID is the cursor). Offset pagination would break as new posts shift positions.

10
Late follow

If Bob follows Alice after she posted, her post isn't in his feed. Either backfill her recent posts into his timeline on follow, or accept it and let it resolve going forward. Backfill-on-follow is the better UX and is a bounded, small job.


Possible follow-up questions

6 questions·try answering before you reveal