← All Posts
High Level Design Series · Real-World Designs· Post 53 of 70

Design: News Feed (Facebook/Twitter)

Why News Feed Is THE Classic Interview Question

If you could only prepare one system design problem, it should be the news feed. Every major tech company — Facebook, Twitter/X, Instagram, LinkedIn, TikTok — has a feed at its core. The problem is elegant in its simplicity ("show users a list of posts from people they follow") yet devastatingly complex at scale.

The news feed sits at the intersection of nearly every major distributed systems concept: fan-out strategies, caching at massive scale, real-time delivery, ranking algorithms, social graphs, content delivery networks, and data partitioning. Master this design and you've implicitly mastered half the building blocks of large-scale system design.

Mark Zuckerberg (2006): "News Feed is the central nervous system of Facebook. It determines what 2 billion people see every time they open the app."

What makes this problem particularly interesting is that the "obvious" solution (just query all posts from friends when the user opens the app) completely falls apart at scale. The core challenge is a fundamental trade-off between write amplification and read latency — and the optimal solution is neither extreme but a carefully engineered hybrid.

Requirements

Functional Requirements

  1. Publish posts: A user can create a post (text, images, video, links) that is stored and made visible to their followers.
  2. Generate feed: A user sees a personalized feed of posts from people they follow, ranked by relevance.
  3. Follow/unfollow: Users can follow and unfollow other users, which dynamically changes their feed content.
  4. Feed interactions: Users can like, comment, share, and react to posts in their feed.
  5. Real-time updates: New posts from followed users should appear in the feed with minimal delay (seconds, not minutes).
  6. Multi-media support: Posts can contain text, images, videos, and links with rich previews.

Non-Functional Requirements

Back-of-the-Envelope Estimation

DAU:             500M users
Avg follows:     200 users per user
Posts per day:   500M (1 post per active user on avg)
Feed reads/day:  10B (20 reads per user per day)

Feed read QPS:   10B / 86400 ≈ 115,000 QPS (peak ~300K QPS)
Post write QPS:  500M / 86400 ≈ 5,800 QPS (peak ~15K QPS)

Read:Write ratio ≈ 20:1 (heavily read-heavy system)

Fan-out on write for a post:
  Average user with 200 followers → 200 cache writes per post
  Celebrity with 10M followers → 10,000,000 cache writes per post!
  
Feed cache size per user:
  Store top 500 post IDs = 500 × 8 bytes (postId) = 4 KB per user
  500M users × 4 KB = 2 TB of feed cache (fits in a Redis cluster)

Post storage:
  500M posts/day × 1 KB avg = 500 GB/day ≈ 180 TB/year
Key insight from the numbers: The system is 20× more read-heavy than write-heavy. This heavily favors pre-computing feeds (fan-out on write) for most users. But the variance is enormous — a celebrity's single post could trigger 10 million cache writes, which is the core tension in this design.

The Key Decision: Fan-Out Strategies

The single most important architectural decision in news feed design is: when do we compute the feed? There are exactly three approaches, and understanding the trade-offs between them is what separates a good answer from a great one in interviews.

Approach 1: Fan-Out on Write (Push Model)

When a user publishes a post, the system immediately pushes the post into every follower's pre-computed feed cache. When a follower opens their feed, the cache is already populated — just read and return.

User Alice publishes a post (postId: 12345)
  │
  ├─→ Write post to Posts DB
  │
  └─→ Fan-out Service:
       │  Look up Alice's followers: [Bob, Carol, Dave, ... 500 users]
       │
       ├─→ ZADD feed:bob 1714500000 "12345"     (Redis sorted set)
       ├─→ ZADD feed:carol 1714500000 "12345"
       ├─→ ZADD feed:dave 1714500000 "12345"
       └─→ ... (500 writes total)

Bob opens his feed:
  │
  └─→ ZREVRANGE feed:bob 0 49    →  [postId list, already sorted]
       │
       └─→ Hydrate post objects from Post Cache/DB  →  Return feed
How it works:
  1. User creates a post → stored in Posts DB.
  2. Fan-out service reads the user's follower list.
  3. For each follower, inserts the postId into their feed cache (Redis sorted set, scored by timestamp).
  4. When a follower loads their feed, it's a single ZREVRANGE — O(log N + K) where K is the page size.
ProsCons
✓ Blazing fast reads — pre-computed, single cache lookup ✗ Massive write amplification for celebrities (1 post → millions of writes)
✓ Consistent read latency regardless of how many users you follow ✗ Wasted work for inactive users (writing to feeds that may never be read)
✓ Simple read path — no complex aggregation at read time ✗ Delayed delivery — a post to 10M followers could take minutes to fully fan out
✓ Works well when follower counts are bounded ✗ Huge memory footprint — every user needs a cache entry

Approach 2: Fan-Out on Read (Pull Model)

No pre-computation. When a user opens their feed, the system dynamically pulls recent posts from all followed users, merges them, ranks them, and returns the result.

Bob opens his feed (follows: [Alice, Carol, Eve, ... 200 users])
  │
  └─→ Feed Service:
       │  Look up Bob's following list: [Alice, Carol, Eve, ...]
       │
       ├─→ GET posts:alice (last 50 posts)
       ├─→ GET posts:carol (last 50 posts)
       ├─→ GET posts:eve (last 50 posts)
       └─→ ... (200 parallel fetches)
       │
       └─→ Merge 200 × 50 = 10,000 posts
            │
            └─→ Rank by relevance/time  →  Return top 50

Alice publishes a post:
  │
  └─→ Write post to Posts DB
       (That's it. No fan-out. Zero extra writes.)
ProsCons
✓ No write amplification — publishing is instant ✗ Slow reads — must aggregate from hundreds of sources on every request
✓ No wasted work — only compute feeds that are actually requested ✗ Read latency scales with number of follows (bad for power users)
✓ Feed is always fresh — no stale cache issues ✗ High read-time compute cost — 200+ parallel queries per feed load
✓ Minimal storage — no per-user feed caches ✗ Difficult to add complex ranking (ranking 10K posts in real-time is expensive)

Approach 3: Hybrid (The Real-World Solution)

This is what Facebook, Twitter, and Instagram actually use. The insight: most users don't have millions of followers. The fan-out-on-write problem is really the celebrity problem, not a general problem.

The Hybrid Strategy

  • Normal users (followers < threshold, e.g., 10,000): Fan-out on write. When they post, write to all followers' feed caches.
  • Celebrities/influencers (followers ≥ threshold): Do NOT fan-out on write. Their posts are stored separately.
  • At read time: Merge the user's pre-computed feed cache with fresh celebrity posts fetched on-the-fly.
Normal user Alice (500 followers) publishes a post:
  └─→ Fan-out on write: write to all 500 followers' caches  ✓

Celebrity Taylor (10M followers) publishes a post:
  └─→ Store post in celebrity_posts table  (no fan-out)

Bob opens his feed (follows: 195 normal users + 5 celebrities):
  │
  ├─→ Step 1: ZREVRANGE feed:bob 0 99      (pre-computed from normal users)
  │     → Returns 100 cached post IDs
  │
  ├─→ Step 2: Fetch recent posts from 5 celebrities Bob follows
  │     → Returns ~25 posts (5 per celebrity, last 24h)
  │
  ├─→ Step 3: Merge the two sets (125 posts total)
  │
  ├─→ Step 4: Rank by relevance score
  │
  └─→ Step 5: Return top 20 posts
Why hybrid works: ~99% of users have <10K followers, so fan-out on write works perfectly for them. Only ~1% of users (celebrities, brands, news outlets) trigger the fan-out problem. By handling those separately at read time, we get the best of both worlds: fast pre-computed feeds for the 99% case, with modest read-time cost for the celebrity posts (typically a user follows <50 celebrities, so it's just 50 extra fetches).
AspectFan-Out on WriteFan-Out on ReadHybrid
Read latency ~5ms (cache hit) ~200–500ms (aggregation) ~20–50ms (cache + few fetches)
Write cost O(followers) per post O(1) per post O(followers) for normal, O(1) for celeb
Celebrity handling Terrible (write storm) No problem Handled at read time
Memory High (all user caches) Low Moderate (no celeb fan-out)
Freshness Seconds delay for fan-out Always fresh Fresh for celebrity posts
Complexity Low Low Highest (two code paths)
Used by Early Twitter Facebook, Twitter/X, Instagram

Animation: Fan-Out on Write vs Read

Animation: Hybrid Fan-Out Approach

System Architecture

The news feed system decomposes into five core services, each with a clear single responsibility. This separation allows independent scaling — the fan-out service needs massive write throughput, while the feed service needs ultra-low read latency.

Post Service

Handles creating, storing, and retrieving individual posts. This is the write path entry point.

POST /api/v1/posts
{
  "user_id": "alice_123",
  "content": "Hello world!",
  "media_ids": ["img_456", "vid_789"],
  "visibility": "public"
}

Responsibilities:
  1. Validate content (text length, media format, spam check)
  2. Store post in Posts DB (Cassandra/MySQL)
  3. Upload media to object storage → CDN
  4. Publish event to message queue: "post.created"
  5. Return postId to client

Storage schema (Cassandra):
  CREATE TABLE posts (
    post_id     UUID,
    user_id     UUID,
    content     TEXT,
    media_urls  LIST<TEXT>,
    created_at  TIMESTAMP,
    like_count  COUNTER,
    share_count COUNTER,
    PRIMARY KEY (post_id)
  );

  -- Denormalized table for user's own posts (used by pull model)
  CREATE TABLE user_posts (
    user_id    UUID,
    created_at TIMESTAMP,
    post_id    UUID,
    PRIMARY KEY (user_id, created_at)
  ) WITH CLUSTERING ORDER BY (created_at DESC);

Fan-Out Service

The most critical service. Consumes post.created events from the message queue and distributes posts to followers' feed caches.

Fan-Out Service (event-driven, async):
  │
  ├─→ Consume "post.created" event from Kafka
  │     { post_id: "12345", user_id: "alice_123", timestamp: 1714500000 }
  │
  ├─→ Check: is alice_123 a celebrity?
  │     │
  │     ├─→ YES (followers ≥ 10K):
  │     │     Store in celebrity_posts table. Done.
  │     │
  │     └─→ NO (followers < 10K):
  │           │
  │           ├─→ Query social graph: GET followers of alice_123
  │           │     → [bob, carol, dave, ... 500 users]
  │           │
  │           ├─→ Filter out inactive users (haven't logged in >30 days)
  │           │     → [bob, carol, dave, ... 350 active users]
  │           │
  │           └─→ For each active follower:
  │                 ZADD feed:{follower_id} {timestamp} {post_id}
  │                 ZREMRANGEBYRANK feed:{follower_id} 0 -501
  │                 (keep only latest 500 entries per feed)
  │
  └─→ Publish "fanout.complete" event (for monitoring)

Scaling strategy:
  - Kafka partitioned by user_id (ensures ordering per user)
  - Multiple consumer groups for parallel processing
  - Batch Redis writes using PIPELINE (100 writes per round trip)
  - Rate limit fan-out to prevent thundering herd
Fan-out worker scaling: During peak hours (e.g., Super Bowl, elections), fan-out volume can spike 10×. The fan-out service must be horizontally scalable. Each worker processes one Kafka partition. Add more workers + partitions to handle spikes. Use a back-pressure mechanism — if the fan-out queue grows beyond a threshold, drop fan-out for low-priority posts (old content, low-engagement users).

Feed Service

The read path. Assembles and returns a user's personalized feed.

GET /api/v1/feed?cursor=1714500000&limit=20

Feed Service flow:
  │
  ├─→ Step 1: Read pre-computed feed from cache
  │     ZREVRANGEBYSCORE feed:{user_id} {cursor} -inf LIMIT 0 100
  │     → Returns up to 100 post IDs from normal-user fan-out
  │
  ├─→ Step 2: Identify celebrities the user follows
  │     GET celebrity_follows:{user_id}
  │     → [taylorswift, barackobama, cristiano, ... 12 celebrities]
  │
  ├─→ Step 3: Fetch recent celebrity posts (fan-out on read)
  │     For each celebrity:
  │       ZREVRANGEBYSCORE user_posts:{celeb_id} {cursor} {cursor-24h} LIMIT 0 10
  │     → Returns ~60 celebrity post IDs
  │
  ├─→ Step 4: Merge + deduplicate
  │     Union of cached posts + celebrity posts
  │     Remove duplicates (celebrity might also be in cache during transition)
  │     → ~160 candidate posts
  │
  ├─→ Step 5: Hydrate post objects
  │     MGET post:{id1} post:{id2} ... (from Post Cache)
  │     Fallback to Posts DB for cache misses
  │     → Full post objects with content, media URLs, engagement counts
  │
  ├─→ Step 6: Ranking (call Ranking Service)
  │     Send 160 candidate posts + user context to Ranking Service
  │     → Returns ranked list with relevance scores
  │
  ├─→ Step 7: Apply business rules
  │     - Remove posts from muted/blocked users
  │     - Apply content policy filters
  │     - Inject sponsored posts at positions 3, 8, 15
  │     - Deduplicate near-identical content
  │
  └─→ Step 8: Return top 20 posts + next cursor
       {
         "posts": [...],
         "next_cursor": "1714498200",
         "has_more": true
       }

Ranking Service

This is where the "magic" happens — transforming a chronological list of posts into a personalized, engaging feed. Modern feed ranking is a multi-stage ML pipeline.

Ranking Pipeline (3 stages):

Stage 1: Candidate Generation (~1000 → ~300 posts)
  - Pre-computed feed cache (fan-out on write results)
  - Celebrity post retrieval (fan-out on read)
  - "Out-of-network" recommendations (posts liked by friends of friends)
  - Trending/viral content injection

Stage 2: Scoring (~300 → scored list)
  For each candidate post, predict:
    P(like)     = probability user will like the post
    P(comment)  = probability user will comment
    P(share)    = probability user will share
    P(click)    = probability user will click through
    P(hide)     = probability user will hide/report

  Final score = w₁·P(like) + w₂·P(comment) + w₃·P(share)
              + w₄·P(click) - w₅·P(hide) + w₆·recency_boost
              + w₇·relationship_score

Stage 3: Re-ranking (business rules)
  - Diversity: don't show 5 posts from same user in a row
  - Freshness: boost posts from last hour
  - Content type mixing: alternate text/image/video
  - Ad slot insertion at fixed positions
  - Anti-echo-chamber: inject some diverse viewpoints

Ranking Features

Feature CategoryExamplesSignal Type
User features Age, location, language, device, session time, past engagement rate Context
Post features Content type, text length, has image/video, post age, total engagement Item
Author features Follower count, posting frequency, avg engagement rate, verified status Context
Relationship features How often user interacts with author, mutual friends, DM frequency, profile visits Cross
Temporal features Time since post creation, time of day, day of week, user's typical active hours Context
Engagement velocity Likes per minute in first hour, comment growth rate, share velocity Item
The ranking model: Modern feeds use deep neural networks (typically a multi-task learning model that jointly predicts like, comment, share, and hide probabilities). Facebook's model has evolved from logistic regression (2009) → gradient-boosted trees (2012) → deep neural networks (2016) → transformer-based models (2020+). The model is retrained daily on billions of (user, post, action) triplets.

Notification Service

Handles push notifications, in-app badges, and real-time feed updates via WebSocket/SSE (Server-Sent Events).

Notification triggers:
  - "post.created" → notify close friends (configurable)
  - "post.liked"   → notify post author
  - "post.commented" → notify post author + other commenters
  - "post.shared"  → notify post author

Real-time feed update via WebSocket:
  1. Fan-out service completes writing to Bob's feed cache
  2. Publishes "feed.updated" event with user_id=bob
  3. WebSocket gateway routes event to Bob's open connection
  4. Client receives event → fetches new posts → prepends to feed
  
  This gives the "new posts available" banner effect:
  ┌──────────────────────────────────┐
  │  ↑  3 new posts — tap to see    │
  └──────────────────────────────────┘

Storage Design

Feed Cache: Redis Sorted Sets

The feed cache is the heart of the read path. Each user has a Redis sorted set where the member is a postId and the score is the post's creation timestamp.

Redis data structure per user:
  Key:    feed:{user_id}
  Type:   Sorted Set (ZSET)
  Member: post_id (8 bytes)
  Score:  Unix timestamp in milliseconds (8 bytes)

Example:
  ZADD feed:bob 1714500000000 "post_abc123"
  ZADD feed:bob 1714499800000 "post_def456"
  ZADD feed:bob 1714499600000 "post_ghi789"

Read latest 20 posts:
  ZREVRANGEBYSCORE feed:bob +inf -inf LIMIT 0 20
  → ["post_abc123", "post_def456", "post_ghi789", ...]

Cursor-based pagination (next page):
  ZREVRANGEBYSCORE feed:bob (1714499600000 -inf LIMIT 0 20
  (The "(" prefix makes it exclusive — don't re-return the last item)

Trim to keep only latest 500:
  ZREMRANGEBYRANK feed:bob 0 -501

Memory calculation:
  Per entry: ~40 bytes (16 member + 8 score + 16 overhead)
  Per user (500 entries): ~20 KB
  500M users: 10 TB
  With 3x replication: 30 TB across Redis cluster
Why Redis sorted sets?
  • O(log N) insert and remove — efficient for fan-out writes.
  • O(log N + K) range queries — K is page size, perfect for pagination.
  • Automatic ordering by score — no need to sort at read time.
  • Atomic operations — ZADD is thread-safe, no locks needed.
  • TTL support — can expire inactive user caches to save memory.
  • Pipeline support — batch 100+ writes per round-trip for fan-out.

Redis Cluster Topology

Redis Cluster for Feed Cache:
  ┌──────────────────────────────────────────────────────────┐
  │  Shard 0 (users a-d)    │  Shard 1 (users e-h)          │
  │  ┌─────────┐            │  ┌─────────┐                  │
  │  │ Primary │──replicate──│  │ Primary │──replicate──     │
  │  └─────────┘            │  └─────────┘                  │
  │  ┌─────────┐            │  ┌─────────┐                  │
  │  │ Replica │            │  │ Replica │                  │
  │  └─────────┘            │  └─────────┘                  │
  │  ┌─────────┐            │  ┌─────────┐                  │
  │  │ Replica │            │  │ Replica │                  │
  │  └─────────┘            │  └─────────┘                  │
  ├──────────────────────────┼──────────────────────────────-┤
  │  Shard 2 (users i-p)    │  Shard 3 (users q-z)          │
  │  ...                     │  ...                          │
  └──────────────────────────────────────────────────────────┘

  Total: ~100 shards × 3 replicas = 300 Redis instances
  Reads go to replicas (load distribution)
  Writes go to primaries only
  Hash slot: CRC16(user_id) mod 16384

Post Object Cache

Post object cache (separate Redis cluster):
  Key:   post:{post_id}
  Value: Serialized post object (protobuf/JSON)
  TTL:   7 days (posts older than 7 days evicted, fallback to DB)

  Structure:
  {
    "post_id": "abc123",
    "user_id": "alice_123",
    "content": "Hello world!",
    "media_urls": ["https://cdn.example.com/img/456.jpg"],
    "created_at": 1714500000,
    "like_count": 1542,
    "comment_count": 87,
    "share_count": 23,
    "author": {
      "user_id": "alice_123",
      "display_name": "Alice",
      "avatar_url": "https://cdn.example.com/avatars/alice.jpg",
      "is_verified": false
    }
  }

  Cache-aside pattern:
    1. Feed Service requests post:abc123
    2. Cache HIT → return immediately
    3. Cache MISS → query Posts DB → write to cache → return

  Engagement count updates:
    Use Redis HINCRBY for atomic counter updates
    Sync back to Posts DB asynchronously every 30 seconds

Social Graph Storage

The social graph (who follows whom) is queried on every write (fan-out) and every read (celebrity post fetch). It needs to support two critical queries: "get all followers of X" and "get all users X follows".

Option 1: Adjacency list in relational DB
  CREATE TABLE follows (
    follower_id  BIGINT,
    followee_id  BIGINT,
    created_at   TIMESTAMP,
    PRIMARY KEY (follower_id, followee_id)
  );
  CREATE INDEX idx_followee ON follows(followee_id, follower_id);
  
  -- "Who does Bob follow?" (for fan-out on read)
  SELECT followee_id FROM follows WHERE follower_id = 'bob';
  
  -- "Who follows Alice?" (for fan-out on write)
  SELECT follower_id FROM follows WHERE followee_id = 'alice';

Option 2: Graph database (Neo4j/Amazon Neptune)
  (Bob)-[:FOLLOWS]->(Alice)
  
  MATCH (a:User {id: 'alice'})-[:FOLLOWED_BY]->(follower)
  RETURN follower.id

Option 3: Dedicated graph service (Facebook TAO)
  Optimized for high-throughput edge lookups
  Distributed across regions
  Caches hot edges in memory

  At Facebook scale, the social graph is:
    ~2 billion nodes (users)
    ~400 billion edges (follow/friend relationships)
    Sharded by user_id, replicated globally
Facebook's TAO: Facebook built a dedicated graph store called TAO (The Associations and Objects) specifically for the social graph. TAO is a read-optimized, eventually-consistent, geographically-distributed cache built on top of MySQL. It handles billions of reads per second with single-digit millisecond latency by caching edges aggressively. This is a common pattern: when the generic tool doesn't scale, build a purpose-specific one.

Media Storage & CDN

Media upload flow:
  1. Client uploads image/video to Upload Service
  2. Upload Service stores original in object storage (S3)
  3. Media Processing Service:
     - Image: generate thumbnails (150px, 300px, 600px, 1200px)
     - Video: transcode to multiple bitrates (240p, 480p, 720p, 1080p)
     - Extract metadata (dimensions, duration, GPS if allowed)
  4. Processed media pushed to CDN edge nodes
  5. Post stores CDN URL, not the raw storage URL

CDN cache strategy:
  - Popular media (viral posts): cached at all edge nodes
  - Recent media: cached at regional edge nodes
  - Old media: origin-pull on demand
  - CDN TTL: 30 days for images, 7 days for video thumbnails

Pagination: Cursor-Based, Not Offset

Pagination is a critical design detail that many candidates overlook. The feed is a live, constantly-changing list. Offset-based pagination breaks catastrophically in this scenario.

The Offset Problem

Offset-based (BAD for feeds):
  Page 1: GET /feed?offset=0&limit=20   → posts [0..19]
  Page 2: GET /feed?offset=20&limit=20  → posts [20..39]
  
  BUT between page 1 and page 2 requests, 5 new posts are inserted!
  
  Page 1: returns posts [A, B, C, D, E, ... T]     (20 posts)
  — 5 new posts inserted: [V, W, X, Y, Z] —
  Page 2: offset=20 now returns posts [P, Q, R, S, T, ...]
  
  Posts P, Q, R, S, T appear on BOTH pages! (duplicates)
  And posts [V, W, X, Y, Z] are never seen! (missed content)

Cursor-Based Pagination (The Solution)

Cursor-based (CORRECT for feeds):
  Page 1: GET /feed?limit=20
    → returns posts [A, B, C, ... T]
    → response includes: next_cursor = "1714499600000" (timestamp of last post T)
  
  Page 2: GET /feed?cursor=1714499600000&limit=20
    → "give me 20 posts OLDER than timestamp 1714499600000"
    → returns posts [U, V, W, X, ... ] (no duplicates, no gaps)

  Even if 100 new posts arrive between requests,
  the cursor anchors to a fixed point in time.

Redis implementation:
  -- Page 1:
  ZREVRANGEBYSCORE feed:bob +inf -inf LIMIT 0 20 WITHSCORES
  → Last entry score = 1714499600000

  -- Page 2:
  ZREVRANGEBYSCORE feed:bob (1714499600000 -inf LIMIT 0 20 WITHSCORES
  (Exclusive lower bound with parenthesis prefix)
Why cursor pagination is perfect for sorted sets: Redis sorted sets natively support score-based range queries with ZREVRANGEBYSCORE. The cursor IS the score (timestamp). There's no "offset scanning" — Redis jumps directly to the cursor position using its skip list structure. This is O(log N + K), not O(offset + K).

Cursor Edge Cases

Problem: Two posts with the exact same timestamp
  Solution: Use composite cursor = "timestamp:post_id"
  
  Page 1 last item: timestamp=1714500000, post_id="abc"
  Cursor: "1714500000:abc"
  
  Page 2 query: all posts where
    (timestamp < 1714500000) OR 
    (timestamp = 1714500000 AND post_id < "abc")

Problem: Feed cache expires between page loads
  Solution: If cache miss, fall back to Posts DB query
  The cursor still works because it's a timestamp, not a cache-specific offset

Problem: User scrolls infinitely (reaches end of cache)
  Solution: Cache stores 500 entries. After that, switch to DB-backed pagination
  ZCARD feed:bob → if near 500, next page falls through to DB

The Celebrity Problem & Solutions

The celebrity problem is the single biggest challenge in news feed design. It deserves its own deep section because understanding it thoroughly demonstrates mastery of the trade-offs.

The Math of Celebrity Fan-Out

Scenario: Taylor Swift (100M followers) posts a photo

Fan-out on write:
  100,000,000 Redis ZADD operations
  At 100K writes/sec per Redis shard: 1,000 seconds = 16.7 minutes
  Even with 100 fan-out workers in parallel: still 10 seconds
  
  During those 10 seconds:
  - 100M write operations consume massive Redis bandwidth
  - Other users' fan-out operations queue up (head-of-line blocking)
  - Redis cluster CPU and memory spike
  - Network bandwidth: 100M × 40 bytes ≈ 4 GB of data
  
  And Taylor posts 3-5 times per day.
  And there are thousands of celebrities.
  The write amplification is catastrophic.

Solutions

Solution 1: Celebrity Threshold (Hybrid Model)

The primary solution used by Facebook and Twitter. Set a follower threshold (e.g., 10,000). Users above this threshold are "celebrities" — their posts skip fan-out on write and are fetched at read time.

  • Static threshold: Simple to implement. User above 10K followers → celebrity.
  • Dynamic threshold: Based on real-time system load. If fan-out queue is backed up, lower the threshold temporarily.
  • Gradual transition: As a user gains followers, gradually reduce the percentage of followers who get fan-out. At 5K followers: fan-out to 100%. At 50K: fan-out to 50%. At 500K: fan-out to 10%. At 5M: fan-out to 0%.

Solution 2: Selective Fan-Out

Only fan-out to active followers. If a user hasn't opened the app in 7 days, skip them.

100M followers of Taylor Swift:
  - 30M active in last 24 hours
  - 50M active in last 7 days
  - 20M inactive (haven't opened app in 30+ days)

Fan-out only to 30M active users:
  70% reduction in write volume!

When an inactive user returns:
  1. Their cache is empty/stale
  2. Fall back to fan-out on read for their first feed load
  3. Resume fan-out on write going forward

Solution 3: Priority-Based Fan-Out

Not all followers are equal. Fan-out in priority order:

Priority 1 (immediate): Close friends, frequent interactors
  → Fan-out within 1 second

Priority 2 (fast): Active users who engage with this author
  → Fan-out within 10 seconds

Priority 3 (normal): Active users who follow but rarely engage
  → Fan-out within 1 minute

Priority 4 (lazy): Inactive users
  → Skip fan-out entirely, pull on demand

Solution 4: Tiered Caching

Use a two-tier approach for celebrity content:

Tier 1 — L1 Cache (per data center):
  Cache celebrity posts in a shared, regional cache
  Key: celebrity_posts:{user_id}:{date}
  One copy per data center, not per follower!
  
Tier 2 — User Feed Cache (per user):
  Normal user posts cached per-follower as before

At read time:
  Feed = Merge(user_feed_cache, regional_celebrity_cache)

Feed Ranking: Deep Dive

Chronological vs. Ranked Feeds

Early social networks showed posts in pure reverse chronological order. This was simple and transparent, but it had a fundamental problem: as users followed more accounts, the signal-to-noise ratio plummeted. Users missed important posts from close friends because they were buried under a flood of low-quality content.

AspectChronologicalRanked
User experience Predictable but overwhelming at scale Engaging but opaque ("why am I seeing this?")
Engagement Lower — users miss good content Higher — surfaces best content first
Implementation Trivial — sort by timestamp Complex — ML ranking pipeline
Bias risk Low — neutral ordering High — can create filter bubbles
Freshness Always shows latest May surface older "better" content
Creator fairness Equal — all posts shown in order Unequal — algorithm picks winners

The Ranking Formula

Modern feed ranking score for a candidate post:

score(user, post) =
    α · P(engage | user, post)     // engagement prediction
  + β · affinity(user, author)      // relationship strength
  + γ · recency(post)               // time decay
  + δ · content_quality(post)       // content quality signal
  + ε · diversity_bonus(post, feed) // diversity injection
  - ζ · P(negative | user, post)    // negative signal penalty

Where:
  P(engage) = ML model predicting click/like/comment/share
  affinity  = weighted sum of past interactions:
              affinity(u, a) = Σ wᵢ · interaction_countᵢ · time_decayᵢ
              interactions: likes (w=1), comments (w=3), shares (w=5),
                           DMs (w=7), profile visits (w=2), tags (w=4)
  
  recency   = 1 / (1 + (now - post.created_at) / half_life)
              half_life = 6 hours (post loses 50% of recency score every 6 hours)
  
  content_quality = f(image_quality_score, text_sentiment, 
                      spelling_score, engagement_velocity)
  
  diversity_bonus = bonus if post type or author differs from last N shown
  
  P(negative) = ML model predicting hide/report/unfollow

Ranking Infrastructure

Ranking service architecture:

  ┌─────────────┐     ┌──────────────────┐     ┌────────────────┐
  │ Feed Service │────→│ Feature Store     │────→│ ML Model Server│
  │ (candidates) │     │ (user/post/edge   │     │ (TensorFlow    │
  │              │     │  features, cached) │     │  Serving)      │
  └─────────────┘     └──────────────────┘     └────────────────┘
                                                        │
                                                        ▼
                                                ┌────────────────┐
                                                │ Scored + Ranked │
                                                │ Post List       │
                                                └────────────────┘

Feature Store:
  - Pre-computed features updated hourly
  - User features: engagement_rate, avg_session_time, content_preferences
  - Author features: credibility_score, avg_post_engagement, posting_frequency
  - Edge features: interaction_count, last_interaction_time, affinity_score
  
  Stored in: Redis (hot features) + Cassandra (full history)
  Latency requirement: feature lookup <5ms

Model Serving:
  - TensorFlow Serving / ONNX Runtime
  - Batch inference: score 200 posts in a single GPU call (~10ms)
  - Model size: ~500MB (distilled from larger training model)
  - A/B testing: run multiple model versions simultaneously
  - Canary deployment: roll out new model to 1% → 5% → 25% → 100%
Latency budget for ranking: The total feed API latency target is 500ms. The breakdown:
Cache read: 5ms | Celebrity fetch: 20ms | Post hydration: 15ms | Ranking: 30ms | Business rules: 5ms | Serialization: 5ms | Network: 20ms | Buffer: 400ms.
The ranking service gets only 30ms to score hundreds of posts. This is why efficient feature serving and GPU batch inference are critical.

Cache Design: Deep Dive

Multi-Layer Cache Architecture

Layer 1: Client-side cache
  - App caches last loaded feed in local storage
  - On app open: show cached feed immediately, fetch fresh in background
  - Stale-while-revalidate pattern
  - TTL: 5 minutes

Layer 2: CDN / Edge cache
  - Not used for personalized feeds (each user's feed is different)
  - Used for: media (images, videos), user profiles, trending posts

Layer 3: Application-level cache (Redis)
  ┌─────────────────────────────────────────────────────┐
  │  Feed Cache (ZSET)           Post Cache (STRING)     │
  │  feed:{user_id}              post:{post_id}          │
  │  ┌─────────────────┐        ┌─────────────────┐     │
  │  │ postId₁ : score₁│        │ serialized post  │     │
  │  │ postId₂ : score₂│        │ object (protobuf)│     │
  │  │ ...              │        │                  │     │
  │  └─────────────────┘        └─────────────────┘     │
  │                                                      │
  │  User Cache (HASH)           Social Graph Cache       │
  │  user:{user_id}              followers:{user_id}     │
  │  ┌─────────────────┐        following:{user_id}      │
  │  │ name, avatar,    │        celebrity_follows:       │
  │  │ follower_count,  │         {user_id}              │
  │  │ is_celebrity     │                                │
  │  └─────────────────┘                                 │
  └─────────────────────────────────────────────────────┘

Layer 4: Database
  - Posts DB (Cassandra): source of truth for post content
  - Social Graph DB (MySQL/Neo4j): source of truth for relationships
  - User DB (MySQL): source of truth for user profiles

Cache Invalidation Strategies

Feed cache invalidation:
  - Write-through: fan-out service writes to cache AND DB simultaneously
  - No explicit invalidation needed — new entries push old ones out (ZREMRANGEBYRANK)
  - TTL: 7 days for inactive users (save memory)
  - On unfollow: remove unfollowed user's posts from cache
    → ZRANGEBYSCORE to find their posts, ZREM to remove them
    → Or just let them age out naturally (simpler, slightly stale)

Post cache invalidation:
  - Write-through on post update (edit, delete)
  - Event-driven: "post.updated" → invalidate post:{post_id}
  - Engagement counters: updated in-place via HINCRBY (no invalidation)
  - Deleted posts: set a "deleted" flag, TTL 24 hours, then remove
    → All feed reads check the deleted flag and skip

Social graph cache invalidation:
  - Follow/unfollow events trigger cache invalidation
  - followers:{user_id} invalidated on any follow/unfollow
  - Warm cache on invalidation (write-through, not lazy)
  - Celebrity status changes: rare, handled by background job

Cache Warming & Cold Start

Cold start scenarios:
  1. New user signs up → no feed cache exists
     Solution: Generate initial feed from recommended accounts + trending posts
     
  2. User returns after long absence → cache expired
     Solution: Rebuild cache via fan-out on read for first request
     Background job: re-fan-out recent posts to warm the cache
     
  3. Redis node fails → portion of feed caches lost
     Solution: Replica promotion (Redis Sentinel/Cluster)
     If all replicas lost: graceful degradation to fan-out on read
     Background rebuild from Posts DB within minutes

Cache warming strategy for new deployments:
  1. Deploy new Redis cluster with warm replicas
  2. Run "cache warmer" service that pre-loads feeds for daily active users
  3. Active users (logged in last 24h): warm immediately
  4. Regular users (logged in last 7d): warm within 1 hour
  5. Inactive users: warm on demand (lazy)

Putting It All Together

Complete News Feed Architecture:

  ┌─────────────────────────────────────────────────────────────────────┐
  │                           CLIENTS                                   │
  │   Mobile App / Web Browser / API Consumers                         │
  └──────────────────┬─────────────────────┬───────────────────────────┘
                     │ POST /posts          │ GET /feed
                     ▼                      ▼
  ┌──────────────────────────────────────────────────────────────────┐
  │                    API GATEWAY / LOAD BALANCER                    │
  │        (rate limiting, auth, routing, SSL termination)            │
  └──────┬───────────────────────────────────┬──────────────────────-┘
         │                                   │
    ═══ WRITE PATH ═══                  ═══ READ PATH ═══
         │                                   │
         ▼                                   ▼
  ┌──────────────┐                    ┌──────────────┐
  │ Post Service │                    │ Feed Service  │
  │              │                    │               │
  │ 1. Validate  │                    │ 1. Read cache │
  │ 2. Store post│                    │ 2. Fetch celeb│
  │ 3. Upload    │                    │ 3. Merge      │
  │    media     │                    │ 4. Hydrate    │
  │ 4. Publish   │                    │ 5. Rank       │
  │    event     │                    │ 6. Return     │
  └──────┬───────┘                    └──┬───────┬───┘
         │                               │       │
         ▼                               ▼       ▼
  ┌──────────────┐              ┌────────────┐ ┌──────────────┐
  │ Message Queue│              │ Feed Cache  │ │ Ranking      │
  │ (Kafka)      │              │ (Redis ZSET)│ │ Service      │
  └──────┬───────┘              └─────────---┘ │              │
         │                                      │ ML Model    │
         ▼                                      │ Feature Store│
  ┌──────────────┐                              └──────────────┘
  │ Fan-Out      │
  │ Service      │─────→ Feed Cache (Redis)
  │              │
  │ Check celeb? │─────→ Celebrity Posts Table
  │ Get followers│─────→ Social Graph
  └──────────────┘
         │
         ▼
  ┌──────────────┐
  │ Notification │
  │ Service      │─────→ Push Notifications
  │              │─────→ WebSocket Gateway
  └──────────────┘

  ═══ STORAGE LAYER ═══
  ┌────────────┐  ┌────────────┐  ┌────────────┐  ┌────────────┐
  │ Posts DB   │  │ Social     │  │ User DB    │  │ Object     │
  │ (Cassandra)│  │ Graph DB   │  │ (MySQL)    │  │ Storage    │
  │            │  │ (Neo4j/    │  │            │  │ (S3)       │
  │            │  │  MySQL)    │  │            │  │     + CDN  │
  └────────────┘  └────────────┘  └────────────┘  └────────────┘

Interview Tips & Common Follow-ups

Common Follow-Up Questions

Q: "How do you handle a user unfollowing someone?"

Answer: For fan-out-on-write feeds, there are two approaches: (1) Lazy cleanup — don't immediately remove the unfollowed user's posts from the cache. They'll naturally age out as newer posts push them off the 500-entry limit. The feed service filters them at read time. (2) Eager cleanup — scan the user's feed cache and remove posts authored by the unfollowed user. This is a ZRANGEBYSCORE + ZREM operation, which is expensive but provides cleaner UX.

Q: "How do you handle post deletions?"

Answer: Mark the post as deleted in the Posts DB (soft delete). When hydrating feed posts, the Feed Service checks the deleted flag and skips those posts. Optionally, publish a "post.deleted" event to asynchronously remove the post from all feed caches — but this is an expensive reverse fan-out and is usually done lazily. The client also maintains a local blocklist of deleted post IDs.

Q: "How do you handle the feed for a brand new user?"

Answer: Cold start problem. The feed cache is empty. Options: (1) Show trending/popular posts globally. (2) Suggest users to follow based on signup data (interests, location, contacts). (3) Show an "onboarding feed" curated by editors. (4) Once the user follows ≥10 accounts, switch to the normal feed generation pipeline. This usually includes a "follow suggestions" carousel in the feed itself.

Q: "How do you prevent the feed from becoming an echo chamber?"

Answer: Add a diversity signal to the ranking formula. Techniques: (1) Topic diversity — ensure the feed shows posts from multiple topics, not just one. (2) Source diversity — cap the number of posts from any single author per screen. (3) Serendipity injection — randomly promote 5-10% of posts from outside the user's usual content bubble. (4) "Following" vs "For You" tab split — let users choose chronological or ranked views.

Q: "How do you handle multi-region deployment?"

Answer: Deploy the full stack in each region (US-East, EU-West, Asia-Pacific). Each region has its own Redis cluster and Kafka cluster. Posts DB uses cross-region replication (Cassandra is natively multi-DC). Fan-out happens in every region — a user in the US posting will trigger fan-out in all regions where their followers exist. Feed reads are served from the nearest region. Social graph is replicated globally with eventual consistency.

Key Takeaways