Lesson 103 of 129
Design a News Feed
A news feed shows a user a list of recent posts from the accounts they follow, ordered by time or relevance. Feeds are read far more often than posts are written, and the read must be fast. The central design question is when to do the work of assembling a feed: at post time or at read time.
Requirements
Functional
- Publish a post (text, images, video reference).
- Follow and unfollow users.
- Retrieve a feed: posts from followed accounts, newest first (ranking is a later extension).
- Paginate through the feed.
Non-functional
- Low read latency: feed load in a few hundred milliseconds.
- High availability: favor availability over strict consistency; a post appearing seconds late is acceptable.
- Scalable: hundreds of millions of users, heavily skewed follower counts.
- Durable posts: posts are never lost, even if a feed cache is.
Estimation
Assume 300 million daily active users (DAU), each opening the feed 10 times per day, and 2 posts per day per 10 users on average (0.2 posts per user per day).
- Feed reads: 300e6 x 10 = 3e9/day, or 3e9 / 86,400 ≈ 35,000 reads/s average. Peak about 3x: 105,000/s.
- Posts: 300e6 x 0.2 = 60e6/day ≈ 700/s.
- Fan-out: if the average user has 200 followers, fan-out on write produces 700 x 200 = 140,000 feed inserts/s. This is comparable to the read rate, which is the point of the trade-off.
- Post storage: 60e6/day x 1 KB metadata = 60 GB/day, or about 22 TB per year (media stored separately in object storage).
- Feed cache: keep the latest 500 post IDs per user, 8 bytes each = 4 KB per user. For 300e6 users: 1.2 TB for the IDs alone. Storing a score with each ID doubles it to roughly 2.4 TB. That fits across a Redis cluster and shrinks further if only recently active users get a cached feed.
What the numbers imply
- The average of 140,000 inserts per second hides skew: one account with 50 million followers is 50 million inserts from a single post, about 6 minutes at 140,000 per second. That is why celebrities are excluded from fan-out.
- A Redis node handles on the order of 100,000 simple operations per second, so average fan-out needs a small cluster, and peaks (3x) plus skew need dozens of shards; the 2.4 TB cache needs about 40 nodes at 64 GB usable each, doubled for replicas.
- 105,000 reads per second at 20 posts per page means hydration must be a batched multi-get with a post-cache hit rate above 95 percent; otherwise the post store sees millions of reads per second.
API design
POST /v1/posts {text, media_ids[]} -> 201 {post_id}
POST /v1/users/{id}/follow -> 204
DELETE /v1/users/{id}/follow -> 204
GET /v1/feed?limit=20&cursor=<c> -> {posts[], next_cursor}The feed endpoint uses cursor pagination, not page offsets. New posts arrive while the user scrolls, so offsets would shift and produce duplicates or gaps. The cursor encodes the last returned post's (timestamp, post_id).
Data model
| Store | Schema | Reasoning |
|---|---|---|
| Posts | post_id -> {author_id, text, media refs, created_at} |
Key-value or wide-column store partitioned by post_id; write-heavy and immutable |
| Follow graph | follower_id -> [followee_id] and reverse followee_id -> [follower_id] |
Both directions are needed: reads use followees, fan-out uses followers. Sharded relational or wide-column table with both indexes |
| Feed cache | user_id -> list of (post_id, score) |
Redis sorted set or list, bounded length (see Redis) |
| User profiles | user_id -> {...} |
Relational or key-value, heavily cached |
Post IDs should be time-sortable (for example Snowflake-style IDs, see unique ID generator), so ordering by ID approximates ordering by time and the cursor is just an ID.
High-level design
flowchart LR
Client["Client"] --> LB["Load balancer"]
LB --> PostSvc["Post service"]
LB --> FeedSvc["Feed service"]
PostSvc --> PostDB[("Post store")]
PostSvc --> Q["Fan-out queue"]
Q --> FanW["Fan-out workers"]
FanW --> Graph["Graph service"]
Graph --> GraphDB[("Follow graph")]
FanW --> FeedCache[("Feed cache")]
FeedSvc --> FeedCache
FeedSvc --> PostCache[("Post cache")]
PostCache --> PostDB
FeedSvc --> Graph
FeedSvc --> Rank["Ranking service"]Component responsibilities:
- Post service: validates, persists the post, enqueues a fan-out job.
- Fan-out workers: for each follower of the author, insert the post ID into that follower's feed cache.
- Feed service: reads the ID list from the feed cache, hydrates post content and author info from post and user caches, merges in celebrity posts (see below), optionally ranks, and returns the page.
- Graph service: answers follower and followee queries.
Publish and read paths:
sequenceDiagram
participant A as Author client
participant PS as Post service
participant Q as Fan-out queue
participant W as Fan-out worker
participant FC as Feed cache
participant R as Reader client
participant FS as Feed service
A->>PS: create post
PS->>PS: persist post
PS->>Q: enqueue post_id, author_id
PS-->>A: 201 Created
Q->>W: job
W->>FC: add post_id to each follower's feed
R->>FS: GET /feed
FS->>FC: top N post IDs
FS->>FS: hydrate posts, rank
FS-->>R: page of postsDeep dive
Fan-out on write vs fan-out on read
Fan-out on write (push model): when a post is created, copy its ID into every follower's precomputed feed.
- Reads are a single cache lookup: fast.
- Write cost is proportional to follower count. An account with 50 million followers causes 50 million inserts per post, delays visibility, and wastes work on inactive users.
Fan-out on read (pull model): store nothing per follower; at read time, fetch the latest posts of every followed account and merge.
- Writes are cheap, and no work is spent on inactive users.
- Reads are expensive: a user following 500 accounts triggers 500 lookups plus a merge on each feed load.
| Fan-out on write | Fan-out on read | |
|---|---|---|
| Read latency | Low | High |
| Write cost | High, scales with followers | Low |
| Storage | Feed copy per user | None extra |
| Celebrity posts | Very expensive | Cheap |
| Inactive users | Wasted work | No cost |
Hybrid approach
Most large systems combine both:
- Normal accounts (below a follower threshold) use fan-out on write.
- High-follower accounts are excluded from fan-out. Their posts are stored once, in the author's own timeline.
- At read time, the feed service takes the user's precomputed feed and merges in recent posts from the celebrity accounts the user follows (the graph service can list which followees are flagged as celebrities).
- Fan-out to inactive users is skipped or limited: only users active in the last few days get precomputed feeds; others are built on demand when they return.
The threshold is a tunable parameter, and the merge cost at read time is bounded by the number of followed celebrity accounts.
Feed cache design
Each user's feed is a bounded sorted set of (post_id, score), trimmed to the latest few hundred entries. Only IDs are stored; content comes from a separate post cache. This keeps entries small and means an edited or deleted post needs a single change in the post cache and not in millions of feeds. Deleted posts are filtered at hydration.
If a feed cache node is lost, feeds are rebuilt from the follow graph and post store using the pull path. The cache is therefore a performance layer, not the source of truth.
Concrete settings and alternatives. Celebrity threshold: for example 100,000 followers; at that size fan-out costs 100,000 inserts per post, above roughly the cost of a pull merge. A user following up to about 20 celebrities makes 20 parallel lookups of cached author timelines, a few milliseconds, then merges by post ID. Feed cache: 500 entries per user, only for users active in the last 7 days; TTL of 7 days on the key, refreshed on read. Alternative to sorted sets is a plain list appended at the head (cheaper, but no idempotent re-add and no re-scoring for ranking). Consistency model: eventual, with a fan-out lag target of p99 under 5 seconds; a user's own post is inserted synchronously for read-your-writes. Pagination via (timestamp, post_id) cursor is stable under concurrent inserts.
Ranking
Chronological order requires only the timestamp. Relevance ranking adds a scoring stage: candidate generation (feed IDs plus celebrity posts), feature lookup (author affinity, engagement, recency), a scoring model, and a diversity filter. This runs only on the few hundred candidates, not on all posts, which keeps latency bounded.
Follow and unfollow
Following triggers a backfill: recent posts of the new followee are inserted into the follower's feed. Unfollowing removes that author's entries lazily, by filtering at read time and by cleaning up asynchronously.
Bottlenecks and scaling
- Graph store: shard by user ID (see sharding); store both directions of edges. Cache follower lists of hot accounts.
- Fan-out failures: the queue delivers jobs at least once, and adding a post ID to a sorted set is idempotent (the same member is not added twice), so a retried or partially completed job is safe to re-run. If the queue lags, posts appear late in followers' feeds but are not lost, because the post store is the source of truth.
- Fan-out queue: partition by author; workers batch inserts per cache shard. A large fan-out job is split into chunks so it can be parallelized and retried in parts (see message queues).
- Feed cache: shard by user ID with consistent hashing; replicate for availability.
- Hot posts: a viral post is read by millions; cache it aggressively with replicated copies (see hot keys).
- Media: serve images and video through a CDN.
- Consistency: the feed is eventually consistent. Read-your-own-writes is improved by inserting a user's own new post into their own feed immediately on publish.
Failure modes
- Fan-out queue backs up. Feeds lag; posts are safe in the post store. Prioritize recent posts and active users, and shed fan-out for inactive users first.
- Feed cache shard lost. Users on it hit the pull path: 500 followees means 500 lookups per user, so rebuild is rate limited and results are re-cached (with request coalescing) to avoid a stampede on the post store.
- Post cache miss storm. A viral post whose cache entry expires can send millions of reads to the store; use request coalescing and jittered TTLs.
- Graph service slow. Fan-out workers stall; cache follower lists of hot authors and read from replicas.
- At 10x (1.4 million average inserts per second). Lower the celebrity threshold, restrict push to users active in the last few days, add feed shards, and shift more work to read-time merge. Region-local feeds with async cross-region replication of the post store keep latency down.
Trade-offs and follow-up questions
- What follower threshold defines a celebrity? Chosen from the distribution of follower counts and fan-out cost; it can be adaptive.
- How to keep the feed fresh for a returning inactive user? Rebuild on demand with the pull path, then warm the cache.
- How to handle deletes and privacy changes? Enforce visibility at read time in the feed service; the cache only holds candidates.
- How to add "seen" tracking to avoid repeats? Store a per-user set or Bloom filter of viewed post IDs and filter candidates.
- How to support real-time updates without refresh? Push a "new posts available" signal over a persistent connection (see WebSockets, SSE and long polling).
- What if a celebrity's follower count grows quickly? The threshold check runs at post time, so the author switches to pull mode on the next post; existing cached entries remain valid.
- What if the user follows 5,000 accounts? Push covers most; read-time work is bounded by celebrities only.
- What about a post edit? The feed holds IDs, so update the post cache once.
- How do you rank fairly under a 200 ms budget? Score only about 500 candidates with cheap features, and cache features per author.
