System design deep dive · HLD
Design a Facebook-style News Feed: full high-level design.
Requirements, back-of-envelope capacity estimation, a high-level design diagram, a deeper deployment architecture diagram that solves the celebrity fan-out problem, sequence diagrams for post creation and feed reads, and an entity-relationship diagram for the data model - with the reasoning an interviewer expects behind every box and arrow.
1. Clarify requirements before drawing any box
A strong system design answer starts by pinning down scope out loud. For a news feed, that means separating "what must this feed do" from "how fast and how personalized must it be", and stating the scale assumptions that every later diagram depends on.
Functional requirements
Non-functional requirements
2. Back-of-the-envelope capacity estimation
These numbers decide almost everything downstream: whether posts can be fanned out to every follower synchronously, how big the per-user feed cache needs to be, and why a single "one-size-fits-all" fan-out strategy breaks for the biggest accounts.
| Metric | Assumption | Resulting estimate |
|---|---|---|
| Daily active users | 2B MAU, ~35% DAU/MAU ratio | ~700M DAU |
| Posts created / day | ~7% of DAU create at least one post/day | ~50M posts/day ≈ ~580 writes/sec avg, ~2,500/sec peak |
| Fan-out write amplification | ~300 avg follows/followers per user | 50M posts × 300 ≈ 15B fan-out writes/day ≈ ~174K/sec avg, ~500K/sec peak |
| Feed reads (app opens) | 700M DAU × ~8 opens/day | ~5.6B feed loads/day ≈ ~65K reads/sec avg, ~195K/sec peak |
| Feed cache storage | 700M users × ~500 capped entries × ~120 bytes/entry | ≈ 42 TB hot cache, sized for a Redis/RocksDB cluster |
| Post store growth | 50M posts/day × ~800 bytes metadata (media stored separately) | ≈ 40 GB/day metadata ≈ ~44 TB metadata over 3 years |
3. High-level design (HLD)
The HLD names the major components and the one-directional data flow between them - write path, fan-out, cache, and the read-time ranking call - without committing yet to replica counts, sharding, or regions, which belong in the architecture diagram in the next section.
What each box owns
Post service (write path)
Accepts a new post (text, plus a media reference if a photo/video was already uploaded to object storage), writes it durably to the Post store, and enqueues an async fan-out job. It returns to the client immediately after the durable write - fan-out happens after the response, never before it.
Fan-out service and graph store
For a normal account, reads the author's follower list from the Graph store and pushes a lightweight reference (post_id, author_id) into each follower's capped feed cache. For an account above the celebrity follower-count threshold, it skips fan-out entirely and marks the post for read-time merge instead - the full reasoning is in the architecture section below.
Feed cache and ranking service (read path)
The Feed service fetches the caller's precomputed feed_cache_entries, merges in any un-fanned-out celebrity posts, then calls the Ranking service to re-score the merged candidate set with fresh engagement signals before returning the top N - reads are never served in raw precomputed order.
Post store, graph store, and the async feature pipeline
The Post store is a sharded database (with an object store for media blobs) and is the durable source of truth. The Graph store holds follow edges optimized for "get everyone who follows X" at extreme scale. A Kafka-based pipeline aggregates likes/comments/shares into a feature store that the Ranking service reads from, so engagement never has to update ranking synchronously.
4. Detailed architecture diagram
The architecture diagram takes every HLD box and answers "how is this actually deployed, and what happens for the accounts with millions of followers?" - which is the specific detail an interviewer is checking for once they've accepted the high-level shape.
| Decision | Choice | Reasoning |
|---|---|---|
| Fan-out strategy | Hybrid: fan-out-on-write below ~1M followers, fan-out-on-read above it | Pure push would mean a single post from a 50M-follower celebrity generates 50M synchronous-ish writes; pure pull would mean every reader re-fans-out on every load. Hybrid pays each cost only where it's cheap. |
| Social graph sharding | Graph store sharded by follower_id, not followee_id | The fan-out read pattern is "get all followers of author X"; the feed-build read pattern is "get everyone user Y follows" - sharding by follower_id keeps a user's own follow list co-located for the read path that runs on every feed load. |
| Feed cache shape | Bounded ring buffer, ~500 entries/user, evict lowest rank_score | Keeps per-user memory bounded regardless of how many accounts they follow; the ranking service can always re-score whatever subset is present rather than requiring a complete history. |
| Multi-region replication | Feed cache replicated asynchronously, cross-region pub/sub invalidation | Feed freshness explicitly tolerates seconds of lag (an eventual-consistency requirement), so the write path never blocks on a cross-region round trip. |
5. Sequence diagrams for the two critical flows
A sequence diagram is where an interviewer checks whether you actually understand call order, what is synchronous versus fire-and-forget, and exactly where the celebrity account skips a step.
5.1 Post creation and fan-out (normal user, write path)
Step 5 does not wait on steps 6-8: the client sees success as soon as the post is durably stored. For a celebrity account, steps 6-8 never run at all - the fan-out worker checks the author's follower_count, marks the post for read-time merge instead, and stops, which is exactly the branch shown in the deep-dive section below.
5.2 Feed read at app load (read path, with ranking)
Steps 2-5 build a merged candidate set from two sources: the precomputed feed_cache_entries (cheap, but only covers non-celebrity authors) and a direct, TTL-cached read of the celebrity post index (covers the accounts that were never fanned out). Steps 6-9 then re-score that entire merged set with fresh signals - which is precisely why a feed_cache_entries row can be seconds stale and it still doesn't matter: staleness in the cached rank is corrected by the ranking call on every single read.
6. Entity-relationship (ER) diagram and schema
The data model has to answer three questions: how is a follow relationship stored so that "get all of X's followers" stays fast even at 50M followers, how is a per-user feed kept bounded instead of growing forever, and where do engagement counters live without write-amplifying the hot posts table.
Key modeling decisions
| Storage choice | Use when | Watch out for |
|---|---|---|
| Relational (Postgres/MySQL), sharded - posts & users | You want a durable schema, moderate write volume relative to fan-out, and transactional updates to a single post row. | Sharding posts at this scale still needs an app-level or Vitess-style resharding strategy as author activity grows unevenly. |
| Wide-column / graph store - follow_edges & feed_cache_entries | The access pattern is pure key lookups at extreme write and read volume: "get all followers of X", "get feed_cache_entries for user Y". | No cheap ad-hoc joins or aggregation; fan-out and eviction logic has to live entirely in the application layer. |
7. Deep dives interviewers actually probe
How do you handle a celebrity account with 50 million followers without melting the fan-out pipeline?
Fanning a single post out to 50M feed_cache_entries rows - even asynchronously - would consume a huge slice of the entire system's write budget for one post. The fix is a hard branch in the Fan-out service based on the author's follower_count: below the celebrity threshold, fan out normally; above it, skip fan-out entirely and index the post in a small, TTL-cached "celebrity post index" instead. The Feed service then merges that index into every follower's candidate set at read time, so the cost is paid once per index write instead of once per follower.
def on_new_post(post):
save_post(post) # always durable first
author = get_user(post.author_id)
if author.follower_count > CELEBRITY_THRESHOLD: # e.g. 1,000,000
mark_as_celebrity_post(post) # indexed for read-time merge only
return # skip fan-out entirely
enqueue(FanOutJob(post.post_id, author.id)) # normal async fan-out
Fan-out-on-write vs fan-out-on-read: what's actually being traded off?
Fan-out-on-write costs roughly 15B feed_cache_entries writes/day at today's scale (~300 followers avg), but keeps every feed read O(1) - just read your own precomputed list. Pure fan-out-on-read costs nothing at post time, but every single feed load would have to query and merge posts from everyone the reader follows (up to hundreds of accounts) at read time - multiplied across ~5.6B feed loads/day, that's far more expensive in aggregate than the write side. The hybrid design pays the write cost for the 99.9% of accounts where it's cheap in aggregate and reads stay O(1), and pays the read cost only for the tiny minority of celebrity accounts, whose recent posts can be cached once and shared across every reader instead of duplicated per-follower.
Why rank the feed with ML instead of just showing posts in reverse-chronological order?
Once a user follows hundreds of accounts that don't all post at convenient times, reverse-chronological wastes the limited attention of a single app session on whatever happened to post most recently, regardless of how relevant it is. Ranking instead predicts a probability of engagement per candidate post using features like recency decay, historical affinity with the author, post type (photo/video vs text), and short-term virality signals (recent like/comment velocity), then orders by that score - usually blended with a small amount of exploration/diversity to avoid feedback loops that over-favor a few accounts.
score = (
0.35 * recency_decay(post.created_at) +
0.30 * affinity(user_id, post.author_id) +
0.25 * predicted_engagement_prob(user_id, post) +
0.10 * post_type_boost(post.media_type)
)
What happens to a feed when a post is deleted after it's already been fanned out to a million feed_cache_entries?
Deleting a post marks it is_deleted in the Post store, but the system deliberately does not synchronously scrub every feed_cache_entries row referencing it - that would reintroduce the exact same write-amplification problem fan-out already has. Instead, the Feed service filters out deleted or tombstoned post_ids at read time while hydrating the candidate set (a cheap existence/status check), and stale rows are quietly evicted later during the cache's normal capacity-based eviction cycle. This is the eventual-consistency requirement showing up concretely: a deleted post can theoretically still occupy a feed_cache_entries slot for a while, but it will never actually be shown.
What is the single biggest bottleneck as this scales 10x?
Probably not the flagship celebrity case - that's already special-cased. The real risk is the "mid-tier influencer" band: accounts with, say, 50K-500K followers, too numerous to hand-classify as celebrities individually but numerous enough in aggregate that their combined fan-out write volume dominates the Graph store and Feed cache write path. A secondary bottleneck is feature store lookup latency in the Ranking service, since a larger merged candidate set (more celebrities followed, more mid-tier accounts) means more feature fetches per feed load. Mitigations include a dynamically adjusted celebrity threshold, tiered fan-out (fan out first to followers active in the last 7 days, defer the rest), and batching feature-store reads per request instead of one lookup per candidate post.
Post a Comment
Add