Primary concepts and the hard part
Requirements
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)
API / Model
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
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.
- The client's
POST /postspasses the API Gateway, which handles auth and rate limiting, and reaches the Post Service. - The Post Service writes the post to the posts store in Cassandra and publishes
post.createdto Kafka, then returns without waiting for fan-out. - The Fan-out service, running as a consumer group, reads the event and checks whether the author has more than 100k followers.
- For an ordinary author, it fans out to followers with batched, idempotent writes, adding one reference row per follower to
user_timeline, keyed byuser_idand sorted bypost_iddescending. - 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.
Same product at 10x the traffic. A 100x jump would mean more daily users than there are people online, so 10x (roughly the largest social apps today) is the realistic next tier. Dashed outlines mark what's new or reshaped compared with today's design.
| Today | At 10x | |
|---|---|---|
| Daily active users | 300M | ~3B |
| Posts at peak | 1,500/sec | 15,000/sec |
| Timeline reads | ~150k/sec | ~1.5M/sec |
| Feed writes | 10B/day | ~100B/day, ~3.5M/sec at peak |
| Largest account | 100M followers | ~500M followers |
| Regions | 1 | ~5 |
What changes, and the number that forces it
- One region → about five. At 3B users the audience is everywhere, and a 200ms p99 can't absorb a cross-ocean round trip on every timeline load. Each region serves reads from its own timeline lists and caches. Posts are written in the author's home region, and both the posts table and
post.createdare replicated to every other region. Snowflake IDs already carry datacenter bits, so IDs stay unique without coordination. - Fan-out runs where the follower lives. Each region's fan-out consumers read the mirrored topic and only write timelines for followers homed in that region. A post from Tokyo to followers in São Paulo crosses the ocean once, as one event, not as millions of timeline writes.
- The threshold counts active followers. ~3.5M timeline writes/sec at peak is what breaks first, as the 10x follow-up predicts. Deciding eager vs pull on active followers removes most of it, because a dormant follower never gets a row: a 50M-follower account with 2M daily actives fans out like a 2M one.
- Priority lanes by fan-out size. A post to millions of active followers takes minutes of worker time. Routing authors into lanes by fan-out size, each with its own worker pool, means an ordinary user's post reaches their 200 followers in seconds even while a large post is still fanning out. It's the same bulkhead idea as the notification design.
- Timelines move to capped in-memory lists. A timeline is only ever appended to, trimmed at 800, and read whole, so a replicated in-memory list store (the shape Twitter ran on Redis) beats Cassandra rows for both writes and reads. Only active users keep a list: ~1B users × 800 refs × ~20 bytes is ~16 TB of RAM before replication, split across regions by where users live. A returning dormant user's timeline is rebuilt from the posts table on first load.
- Follower lists are chunked. A 500M-follower reverse graph in one partition is a hot, unbounded row. Splitting each list into partitions of ~10k ids lets fan-out workers page through it in parallel and keeps every partition a normal size.
- The read-time merge gets a cap. Once more accounts sit on the pull side, a user following hundreds of them would merge hundreds of lists per load. The merge takes only the few dozen celebrity lists with the most recent posts, and quieter ones surface on the next refresh of the assembled cache.
What stays the same
The hybrid itself: fan-out on write for ordinary authors, merge at read time for large ones. Fan-out writes stay idempotent on (user_id, post_id), pagination stays cursor-based on Snowflake IDs, deletes are still filtered at read time rather than fanned out, and media bytes still come from blob storage and the CDN, never through app servers. At 10x the same split is simply applied per region and per lane.
Trade-offs and deep dives
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.
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.
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.
Set it empirically where fan-out cost exceeds merge cost. Expect it to differ by product.
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.
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.
Keep ~800-1,000 refs per user. Nobody scrolls further; without a cap storage grows unbounded.
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.
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.
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
user_timeline write volume. Mitigations: raise the celebrity threshold, tighten the active-user window, shard fan-out workers by author.