~learn.
?

Day 0447 minPreface+60 XP

Feed Caching.

Yesterday, you learned to prevent thundering herds. You can now protect your database from cache stampedes.

4 stages16 code

watch it first · newThe whole lesson in a 18-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 1 min

Preface#

Yesterday, you learned to prevent thundering herds. You can now protect your database from cache stampedes.

Today, we tackle one of the most interesting caching problems in system design: personalized feeds.

THE FEED PROBLEM

Your social media app has 100 million users. Each user has a personalized feed. Each user follows between 10 and 10,000,000 other users.

When a user opens the app: "Show me the latest posts from people I follow, sorted by time (or relevance), instantly."

Seems simple. Let's do the math. User opens app:

  1. → Get list of 500 accounts they follow
  2. → Fetch recent posts from each account
  3. → Merge and sort
  4. → Return top 50 posts

Query time per account: 10ms · Total time: 500 × 10ms = 5 seconds. 5 seconds to load a feed? Users will leave.

Okay, let's cache the feed! But wait:
  • → 100 million users
  • → Each feed: 50 posts × 1KB = 50KB
  • → Total cache: 100M × 50KB = 5 terabytes

5 terabytes of RAM just for feeds? That's an expensive cache cluster (and it's why real systems cache post IDs, not whole posts).

And when someone posts
  • → Celebrity with 50 million followers posts
  • → Update 50 million cached feeds?
  • → That's 50 million cache writes for one post!

This is the feed caching problem.

Today, you'll learn how Twitter, Instagram, and LinkedIn solve this — and when to use each approach.

ExplainerThe whole lesson: feed caching

Computing a feed on open from 500 followed accounts takes 5 seconds, caching every feed takes 5 TB, and a celebrity post means 50 million cache writes. Fan-out on write makes reads one cache lookup but amplifies writes; fan-out on read is cheap to write but slow to read. The hybrid pushes regular users' posts, stores celebrity posts to be pulled and merges them at read time, and at scale feeds are ranked, sharded by user, trimmed to 500–1,000 posts and skip inactive users.

Part I · Stage 1 of 4 · 23 min

Part I: Foundations#

Chapter 1: Understanding Feeds#

1.1 What Is a Feed?#

A feed is a personalized, time-ordered (or relevance-ordered) list of content from sources a user has chosen to follow.

FEED ANATOMY — YOUR FEED
Post Likes Comments Reposts
@celebrity · 2 min ago — Just landed in Paris! 45,231 2,891 5,632
@friend · 15 min ago — Great coffee at the new place downtown 12 3 0
@news_outlet · 1 hour ago — Breaking: Major announcement expected... 8,432 1,203 3,891
[Load More...]
Components
  • Posts from accounts user follows
  • Ordered by time (or relevance algorithm)
  • Paginated (load more on scroll)
  • Updated in near real-time

1.2 The Core Challenge#

THE FEED CHALLENGE: TWO COMPETING CONCERNS
  • READ LATENCY: User opens app → Feed appears instantly (<200ms). Users won't wait. Every extra delay costs engagement.
  • WRITE FANOUT: User posts → Appears in all followers' feeds. Celebrity posts → 50 million feeds need updating.

These goals conflict:

Fast reads (pre-compute everything) Fast writes (compute on demand)
ok — Instant feed retrieval ok — Single write per post
no — Massive write amplification (celebrity problem) no — Slow reads (must query many sources)
no — Huge storage for all pre-computed feeds no — Database load at read time
Your turn · checkpoint 1not answered

Pushing every tweet

Take 500 million tweets a day and an average of 200 followers per account. If every tweet is pushed into every follower's feed, how many feed insertions is that a day, and a second?

Think it through, or jot it down. Then compare.

One strong answer

  • Per day: 500 million × 200 = 100 billion feed insertions.
  • Per second: 100 billion ÷ 86,400 s ≈ 1.1 million a second, on average.
  • And that's before celebrities, whose single posts land in tens of millions of feeds at once.

1.3 The Numbers#

Let's understand the scale:

TWITTER-SCALE NUMBERS (round, illustrative figures for this exercise)
Metric Value
Users 500 million
Daily Active Users 250 million
Tweets per day 500 million
Tweets per second ~6,000
Average followers 200
Median followers 50
Celebrity followers 50+ million (top accounts)
Feed requests per day Billions
Feed latency target <200ms
The math problem
  • If we pre-compute feeds: 500M tweets × 200 avg followers = 100 billion feed insertions/day = 1.1 million feed insertions/second
  • If we compute on read: Billions of feed reads · Each read: query 200 followees, merge, sort = Trillions of database operations/day

1.4 Key Terminology#

Term Definition
Fan-out Distributing a post to multiple feeds
Fan-out on write Pre-compute feeds when post is created
Fan-out on read Compute feeds when user requests
Write amplification One write causes many downstream writes
Read amplification One read causes many downstream reads
Hot user User with many followers (celebrity)
Cold user User with few followers or inactive
Timeline Another term for feed

Chapter 2: The Two Fundamental Approaches#

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

When a user posts, immediately push to all followers' feeds.

DiagramFan-out on write
%% title: Fan-out on write
%% steps:
%% A --> PS: User A posts "Hello World"
%% PS --> B, PS --> C, PS --> D, PS --> E: The post service saves it, then pushes it into every follower's feed cache
%% B: When B opens the app, the feed is read straight from B's cache: no computation needed
flowchart TD
  A@{ icon: "sd:user", label: "User A posts 'Hello World'" }
  PS["Post service<br/>1. Save post to Posts table<br/>2. Get A's followers: B, C, D, E, ...<br/>3. For each follower, add post to their feed cache"]:::core
  B@{ icon: "sd:cache", label: "B's Feed Cache<br/>A:post" }
  C@{ icon: "sd:cache", label: "C's Feed Cache<br/>A:post" }
  D@{ icon: "sd:cache", label: "D's Feed Cache<br/>A:post" }
  E@{ icon: "sd:cache", label: "E's Feed Cache<br/>A:post" }
  A --> PS
  PS --> B
  PS --> C
  PS --> D
  PS --> E
  class A client
  class B,C,D,E cache
  1. User A posts "Hello World"
  2. The post service saves it, then pushes it into every follower's feed cache
  3. When B opens the app, the feed is read straight from B's cache: no computation needed

When B opens app:

  • Read B's feed cache directly
  • Instant! No computation needed

Implementation:

Note: the samples call fetch_one, as in the databases library (or a thin wrapper with that method). With asyncpg the equivalent is fetchrow, which returns a Record.

pythonFanOutOnWriteFeedService · 152 lines
# Fan-Out on Write Implementation

class FanOutOnWriteFeedService:
    """
    Push-based feed system.
    
    When a user posts, we immediately push the post
    to all their followers' feed caches.
    """
    
    def __init__(self, redis_client, db_client, queue_client):
        self.redis = redis_client
        self.db = db_client
        self.queue = queue_client
        
        self.feed_size_limit = 800  # Keep last 800 posts per feed
    
    async def create_post(self, user_id: str, content: str) -> dict:
        """Create post and fan out to followers."""
        
        # 1. Save post to database
        post = await self.db.fetch_one(
            """
            INSERT INTO posts (user_id, content, created_at)
            VALUES ($1, $2, NOW())
            RETURNING *
            """,
            user_id, content
        )
        
        # 2. Get follower count to decide strategy
        follower_count = await self._get_follower_count(user_id)
        
        # 3. Fan out to followers
        if follower_count < 10000:
            # Small account: synchronous fan-out
            await self._fan_out_sync(post)
        else:
            # Large account: async fan-out via queue
            await self._fan_out_async(post)
        
        return dict(post)
    
    async def _fan_out_sync(self, post: dict):
        """Synchronous fan-out for small accounts."""
        user_id = post['user_id']
        post_id = post['id']
        created_at = post['created_at'].timestamp()
        
        # Get all followers
        followers = await self.db.fetch(
            "SELECT follower_id FROM follows WHERE followee_id = $1",
            user_id
        )
        
        # Push to each follower's feed
        pipe = self.redis.pipeline()
        for row in followers:
            follower_id = row['follower_id']
            feed_key = f"feed:{follower_id}"
            
            # Add to sorted set (score = timestamp for ordering)
            pipe.zadd(feed_key, {post_id: created_at})
            
            # Trim to keep only recent posts
            pipe.zremrangebyrank(feed_key, 0, -self.feed_size_limit - 1)
        
        await pipe.execute()
    
    async def _fan_out_async(self, post: dict):
        """Async fan-out via message queue for large accounts."""
        await self.queue.send("fan-out-jobs", {
            "post_id": post['id'],
            "user_id": post['user_id'],
            "created_at": post['created_at'].isoformat()
        })
    
    async def get_feed(self, user_id: str, limit: int = 50, offset: int = 0) -> list:
        """Get user's feed - just read from cache!"""
        feed_key = f"feed:{user_id}"
        
        # Get post IDs from sorted set (newest first)
        post_ids = await self.redis.zrevrange(
            feed_key,
            offset,
            offset + limit - 1
        )
        
        if not post_ids:
            return []
        
        # Fetch post details
        posts = await self._get_posts_by_ids(post_ids)
        return posts
    
    async def _get_posts_by_ids(self, post_ids: list) -> list:
        """Fetch post details, with caching."""
        posts = []
        
        # Try cache first
        pipe = self.redis.pipeline()
        for post_id in post_ids:
            pipe.get(f"post:{post_id}")
        cached = await pipe.execute()
        
        # Separate hits and misses
        to_fetch = []
        for i, (post_id, data) in enumerate(zip(post_ids, cached)):
            if data:
                posts.append((i, json.loads(data)))
            else:
                to_fetch.append((i, post_id))
        
        # Fetch missing from database
        if to_fetch:
            ids = [post_id for _, post_id in to_fetch]
            rows = await self.db.fetch(
                "SELECT * FROM posts WHERE id = ANY($1)",
                ids
            )
            
            # Cache and add to results
            row_map = {str(r['id']): r for r in rows}
            pipe = self.redis.pipeline()
            
            for idx, post_id in to_fetch:
                if post_id in row_map:
                    post = dict(row_map[post_id])
                    posts.append((idx, post))
                    pipe.setex(f"post:{post_id}", 3600, json.dumps(post, default=str))
            
            await pipe.execute()
        
        # Sort by original order
        posts.sort(key=lambda x: x[0])
        return [post for _, post in posts]
    
    async def _get_follower_count(self, user_id: str) -> int:
        """Get follower count (cached)."""
        cache_key = f"follower_count:{user_id}"
        
        cached = await self.redis.get(cache_key)
        if cached:
            return int(cached)
        
        count = await self.db.fetch_val(
            "SELECT COUNT(*) FROM follows WHERE followee_id = $1",
            user_id
        )
        
        await self.redis.setex(cache_key, 300, str(count))
        return count

Pros:

  • Extremely fast reads (just read from cache)
  • Predictable read latency
  • Simple read path

Cons:

  • Write amplification (1 post → N cache writes)
  • Celebrity problem (millions of writes per post)
  • Storage heavy (every user has cached feed)
  • Delayed delivery for large fan-outs

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

When a user requests their feed, compute it on demand.

DiagramFan-out on read
%% title: Fan-out on read
flowchart TD
  B@{ icon: "sd:user", label: "User B opens app, requests feed" }
  FS["Feed service<br/>1. Get B's following list: A, C, D, ...<br/>2. For each followee, get their recent posts<br/>3. Merge all posts<br/>4. Sort by time<br/>5. Return top 50"]:::core
  A@{ icon: "sd:database", label: "A's Posts" }
  C@{ icon: "sd:database", label: "C's Posts" }
  D@{ icon: "sd:database", label: "D's Posts" }
  E@{ icon: "sd:database", label: "E's Posts" }
  B --> FS
  FS --> A
  FS --> C
  FS --> D
  FS --> E
  class B client
  class A,C,D,E store
  • Pros: No pre-computation, no storage per user
  • Cons: Slow! Must query many sources per request

Implementation:

pythonFanOutOnReadFeedService · 157 lines
# Fan-Out on Read Implementation

class FanOutOnReadFeedService:
    """
    Pull-based feed system.
    
    When a user requests their feed, we compute it on demand
    by fetching posts from all accounts they follow.
    """
    
    def __init__(self, redis_client, db_client):
        self.redis = redis_client
        self.db = db_client
    
    async def create_post(self, user_id: str, content: str) -> dict:
        """Create post - just save it, no fan-out!"""
        
        # Save post to database
        post = await self.db.fetch_one(
            """
            INSERT INTO posts (user_id, content, created_at)
            VALUES ($1, $2, NOW())
            RETURNING *
            """,
            user_id, content
        )
        
        # Cache the post for fast retrieval
        await self.redis.setex(
            f"post:{post['id']}",
            3600,
            json.dumps(dict(post), default=str)
        )
        
        # Add to user's posts list (for pull queries)
        await self.redis.zadd(
            f"user_posts:{user_id}",
            {str(post['id']): post['created_at'].timestamp()}
        )
        
        # Trim old posts
        await self.redis.zremrangebyrank(f"user_posts:{user_id}", 0, -1001)
        
        return dict(post)
    
    async def get_feed(self, user_id: str, limit: int = 50) -> list:
        """Get feed by pulling from all followees."""
        
        # 1. Get list of accounts user follows
        following = await self._get_following(user_id)
        
        if not following:
            return []
        
        # 2. Fetch recent posts from each followee (parallel)
        tasks = [
            self._get_user_recent_posts(followee_id)
            for followee_id in following
        ]
        
        all_posts_lists = await asyncio.gather(*tasks)
        
        # 3. Merge all posts
        all_posts = []
        for posts in all_posts_lists:
            all_posts.extend(posts)
        
        # 4. Sort by time (newest first)
        all_posts.sort(key=lambda p: p['created_at'], reverse=True)
        
        # 5. Return top N
        return all_posts[:limit]
    
    async def _get_following(self, user_id: str) -> list:
        """Get list of accounts user follows."""
        cache_key = f"following:{user_id}"
        
        # Try cache
        cached = await self.redis.smembers(cache_key)
        if cached:
            return list(cached)
        
        # Fetch from database
        rows = await self.db.fetch(
            "SELECT followee_id FROM follows WHERE follower_id = $1",
            user_id
        )
        
        following = [str(row['followee_id']) for row in rows]
        
        # Cache for 5 minutes
        if following:
            await self.redis.sadd(cache_key, *following)
            await self.redis.expire(cache_key, 300)
        
        return following
    
    async def _get_user_recent_posts(self, user_id: str, limit: int = 100) -> list:
        """Get recent posts from a user (cached)."""
        cache_key = f"user_posts:{user_id}"
        
        # Get post IDs from sorted set
        post_ids = await self.redis.zrevrange(cache_key, 0, limit - 1)
        
        if not post_ids:
            # Cache miss - fetch from database
            rows = await self.db.fetch(
                """
                SELECT * FROM posts 
                WHERE user_id = $1 
                ORDER BY created_at DESC 
                LIMIT $2
                """,
                user_id, limit
            )
            return [dict(row) for row in rows]
        
        # Fetch post details
        return await self._get_posts_by_ids(post_ids)
    
    async def _get_posts_by_ids(self, post_ids: list) -> list:
        """Fetch post details with caching."""
        # Same as fan-out-on-write implementation
        posts = []
        
        pipe = self.redis.pipeline()
        for post_id in post_ids:
            pipe.get(f"post:{post_id}")
        cached = await pipe.execute()
        
        to_fetch = []
        for i, (post_id, data) in enumerate(zip(post_ids, cached)):
            if data:
                posts.append((i, json.loads(data)))
            else:
                to_fetch.append((i, post_id))
        
        if to_fetch:
            ids = [post_id for _, post_id in to_fetch]
            rows = await self.db.fetch(
                "SELECT * FROM posts WHERE id = ANY($1)",
                ids
            )
            
            row_map = {str(r['id']): r for r in rows}
            pipe = self.redis.pipeline()
            
            for idx, post_id in to_fetch:
                if post_id in row_map:
                    post = dict(row_map[post_id])
                    posts.append((idx, post))
                    pipe.setex(f"post:{post_id}", 3600, json.dumps(post, default=str))
            
            await pipe.execute()
        
        posts.sort(key=lambda x: x[0])
        return [post for _, post in posts]

Pros:

  • Fast writes (just save the post)
  • No write amplification
  • Storage efficient (no per-user feed cache)
  • Always fresh (computed in real-time)

Cons:

  • Slow reads (must query many sources)
  • High read amplification
  • Unpredictable latency (depends on following count)
  • Heavy database load at read time
Your turn · checkpoint 2not answered

Push or pull?

You've now seen fan-out on write (push) and fan-out on read (pull). Before the comparison: which one makes writes slow, which makes reads slow, which uses more storage, and which one struggles when an account with millions of followers posts?

Think it through, or jot it down. Then compare.

One strong answer

  • Push: slow, amplified writes (one post becomes N feed writes), instant reads, a cached feed for every user, and a severe celebrity problem.
  • Pull: a single write per post, but slow reads that query every followed account, latency that grows with how many accounts you follow, and no per-user feed storage. Celebrities cost nothing extra.
  • So push suits regular accounts and pull suits celebrities, which is where the hybrid comes from.

2.3 Comparison#

Aspect Fan-Out on Write Fan-Out on Read
Write latency Slow (N cache writes) Fast (single write)
Read latency Fast (single cache read) Slow (N queries)
Storage High (feed per user) Low (posts only)
Write amplification High None
Read amplification None High
Celebrity problem Severe None
Freshness Near real-time Real-time
Best for Read-heavy, regular users Write-heavy, celebrities

Chapter 3: The Hybrid Approach#

Your turn · checkpoint 3not answered

What breaks when a star posts?

Your feed system is pure fan-out on write, and the fan-out workers manage 100,000 feed writes a second. An account with 300 million followers posts a photo. What do followers experience, and what happens if she posts again an hour later?

Think it through, or jot it down. Then compare.

One strong answer

  • 300 million ÷ 100,000/s = 3,000 s, about 50 minutes before the last follower's feed has the post.
  • A second post joins the queue behind the first, so the backlog keeps growing.
  • Meanwhile an ordinary account with 200 followers needs only 200 writes, which is instant. One strategy can't serve both kinds of account.

3.1 The Celebrity Problem#

THE CELEBRITY PROBLEM

A celebrity account with 300 million followers posts a photo.

With fan-out on write
  • → 300 million cache writes
  • → At 100K writes/sec = 50 minutes to complete
  • → Users don't see post for almost an hour!
  • → Meanwhile, she posts again... backlog grows

This doesn't scale.

But most users aren't celebrities:

  • → Average user: 200 followers
  • → 200 cache writes = instant

The insight: Different users need different strategies.

3.2 Hybrid Strategy#

Use fan-out on write for regular users, fan-out on read for celebrities.

DiagramHybrid approach
%% title: Hybrid approach
flowchart TD
  P["User posts"]:::client --> Q{"Follower count?"}:::core
  Q -->|"Regular user: < 10,000 followers"| W["Fan-out on write<br/>Push to all followers' feeds (immediate)"]:::ok
  Q -->|"Celebrity: >= 10,000 followers"| S["Fan-out on read<br/>Store post, don't fan out"]:::queue
  W --> F[("Pre-computed feed<br/>(from regular users)")]:::cache
  R["Follower reads feed"]:::client --> G["Get pre-computed feed"]
  F --> G
  G --> PC["Pull celebrity posts on demand"]
  S -.-> PC
  PC --> M(["Merge and return"]):::ok
ExplainerFeed fan-out: push, celebrities and the hybrid

Fan-out on write pushes each post into every follower's feed cache, so reading a feed is one cache read. A celebrity with 300 million followers would need 300 million writes, about 50 minutes at 100K writes/s. The hybrid pushes for accounts under 10,000 followers and pulls celebrity posts at read time, merging them with the pre-computed feed.

Implementation:

pythonFeedConfig · 292 lines
# Hybrid Feed System

from dataclasses import dataclass
from typing import List, Set
import asyncio


@dataclass 
class FeedConfig:
    """Configuration for hybrid feed system."""
    celebrity_threshold: int = 10000  # Followers to be considered celebrity
    feed_cache_size: int = 800        # Posts to keep in feed cache
    celebrity_posts_limit: int = 100  # Celebrity posts to fetch per request
    feed_ttl: int = 86400             # Feed cache TTL


class HybridFeedService:
    """
    Hybrid feed system combining push and pull.
    
    Regular users (< 10K followers): Fan-out on write
    Celebrities (>= 10K followers): Fan-out on read
    
    This solves the celebrity problem while keeping
    fast reads for most users.
    """
    
    def __init__(
        self,
        redis_client,
        db_client,
        queue_client,
        config: FeedConfig = None
    ):
        self.redis = redis_client
        self.db = db_client
        self.queue = queue_client
        self.config = config or FeedConfig()
        
        # Cache of celebrity user IDs
        self._celebrity_cache: Set[str] = set()
    
    async def create_post(self, user_id: str, content: str) -> dict:
        """Create post with appropriate fan-out strategy."""
        
        # Save post
        post = await self._save_post(user_id, content)
        
        # Check if user is a celebrity
        is_celebrity = await self._is_celebrity(user_id)
        
        if is_celebrity:
            # Celebrity: No fan-out, just index the post
            await self._index_celebrity_post(post)
        else:
            # Regular user: Fan out to followers
            await self._fan_out_to_followers(post)
        
        return post
    
    async def get_feed(self, user_id: str, limit: int = 50) -> list:
        """
        Get feed by merging pre-computed feed with celebrity posts.
        """
        # 1. Get pre-computed feed (from regular users)
        precomputed = await self._get_precomputed_feed(user_id, limit * 2)
        
        # 2. Get celebrities this user follows
        followed_celebrities = await self._get_followed_celebrities(user_id)
        
        # 3. Pull recent posts from followed celebrities
        celebrity_posts = []
        if followed_celebrities:
            celebrity_posts = await self._get_celebrity_posts(
                followed_celebrities,
                limit=self.config.celebrity_posts_limit
            )
        
        # 4. Merge and sort
        all_posts = precomputed + celebrity_posts
        all_posts.sort(key=lambda p: p['created_at'], reverse=True)
        
        # 5. Deduplicate and return top N
        seen = set()
        unique_posts = []
        for post in all_posts:
            if post['id'] not in seen:
                seen.add(post['id'])
                unique_posts.append(post)
                if len(unique_posts) >= limit:
                    break
        
        return unique_posts
    
    async def _is_celebrity(self, user_id: str) -> bool:
        """Check if user is a celebrity (cached)."""
        # Check memory cache first
        if user_id in self._celebrity_cache:
            return True
        
        # Check Redis
        is_celeb = await self.redis.sismember("celebrities", user_id)
        if is_celeb:
            self._celebrity_cache.add(user_id)
            return True
        
        # Check database
        count = await self._get_follower_count(user_id)
        if count >= self.config.celebrity_threshold:
            # Add to celebrity set
            await self.redis.sadd("celebrities", user_id)
            self._celebrity_cache.add(user_id)
            return True
        
        return False
    
    async def _save_post(self, user_id: str, content: str) -> dict:
        """Save post to database."""
        post = await self.db.fetch_one(
            """
            INSERT INTO posts (user_id, content, created_at)
            VALUES ($1, $2, NOW())
            RETURNING *
            """,
            user_id, content
        )
        
        post_dict = dict(post)
        
        # Cache the post
        await self.redis.setex(
            f"post:{post['id']}",
            3600,
            json.dumps(post_dict, default=str)
        )
        
        return post_dict
    
    async def _index_celebrity_post(self, post: dict):
        """Index celebrity post for pull-based retrieval."""
        user_id = post['user_id']
        post_id = str(post['id'])
        timestamp = post['created_at'].timestamp()
        
        # Add to celebrity's posts list
        await self.redis.zadd(
            f"celebrity_posts:{user_id}",
            {post_id: timestamp}
        )
        
        # Trim old posts
        await self.redis.zremrangebyrank(
            f"celebrity_posts:{user_id}",
            0, -self.config.celebrity_posts_limit - 1
        )
    
    async def _fan_out_to_followers(self, post: dict):
        """Fan out post to all followers' feeds."""
        user_id = post['user_id']
        post_id = str(post['id'])
        timestamp = post['created_at'].timestamp()
        
        # Get followers
        followers = await self.db.fetch(
            "SELECT follower_id FROM follows WHERE followee_id = $1",
            user_id
        )
        
        # Update each follower's feed cache
        pipe = self.redis.pipeline()
        
        for row in followers:
            follower_id = row['follower_id']
            feed_key = f"feed:{follower_id}"
            
            pipe.zadd(feed_key, {post_id: timestamp})
            pipe.zremrangebyrank(feed_key, 0, -self.config.feed_cache_size - 1)
        
        await pipe.execute()
    
    async def _get_precomputed_feed(self, user_id: str, limit: int) -> list:
        """Get pre-computed feed from cache."""
        feed_key = f"feed:{user_id}"
        
        post_ids = await self.redis.zrevrange(feed_key, 0, limit - 1)
        
        if not post_ids:
            return []
        
        return await self._get_posts_by_ids(post_ids)
    
    async def _get_followed_celebrities(self, user_id: str) -> list:
        """Get list of celebrities this user follows."""
        # Get all following
        following = await self.redis.smembers(f"following:{user_id}")
        
        if not following:
            rows = await self.db.fetch(
                "SELECT followee_id FROM follows WHERE follower_id = $1",
                user_id
            )
            following = {str(row['followee_id']) for row in rows}
            
            if following:
                await self.redis.sadd(f"following:{user_id}", *following)
                await self.redis.expire(f"following:{user_id}", 300)
        
        # Filter to celebrities only
        celebrities = await self.redis.smembers("celebrities")
        return list(following & celebrities)
    
    async def _get_celebrity_posts(
        self,
        celebrity_ids: list,
        limit: int
    ) -> list:
        """Get recent posts from celebrities."""
        all_posts = []
        
        # Fetch from each celebrity in parallel
        tasks = []
        for celeb_id in celebrity_ids:
            tasks.append(self._get_celebrity_recent_posts(celeb_id))
        
        results = await asyncio.gather(*tasks)
        
        for posts in results:
            all_posts.extend(posts)
        
        # Sort and limit
        all_posts.sort(key=lambda p: p['created_at'], reverse=True)
        return all_posts[:limit]
    
    async def _get_celebrity_recent_posts(self, user_id: str) -> list:
        """Get recent posts from a celebrity."""
        post_ids = await self.redis.zrevrange(
            f"celebrity_posts:{user_id}",
            0, 99
        )
        
        if not post_ids:
            return []
        
        return await self._get_posts_by_ids(post_ids)
    
    async def _get_posts_by_ids(self, post_ids: list) -> list:
        """Fetch posts by IDs with caching."""
        posts = []
        
        # Batch fetch from cache
        pipe = self.redis.pipeline()
        for post_id in post_ids:
            pipe.get(f"post:{post_id}")
        cached = await pipe.execute()
        
        to_fetch = []
        for i, (post_id, data) in enumerate(zip(post_ids, cached)):
            if data:
                posts.append((i, json.loads(data)))
            else:
                to_fetch.append((i, post_id))
        
        # Fetch missing from database
        if to_fetch:
            ids = [pid for _, pid in to_fetch]
            rows = await self.db.fetch(
                "SELECT * FROM posts WHERE id = ANY($1)",
                ids
            )
            
            row_map = {str(r['id']): dict(r) for r in rows}
            
            for idx, post_id in to_fetch:
                if post_id in row_map:
                    posts.append((idx, row_map[post_id]))
        
        posts.sort(key=lambda x: x[0])
        return [post for _, post in posts]
    
    async def _get_follower_count(self, user_id: str) -> int:
        """Get follower count."""
        cached = await self.redis.get(f"follower_count:{user_id}")
        if cached:
            return int(cached)
        
        count = await self.db.fetch_val(
            "SELECT COUNT(*) FROM follows WHERE followee_id = $1",
            user_id
        )
        
        await self.redis.setex(f"follower_count:{user_id}", 300, str(count))
        return count

3.3 Hybrid Benefits#

HYBRID APPROACH BENEFITS
Write Performance
  • Regular user (200 followers): → 200 cache writes (fast)
  • Celebrity (10M followers): → 0 cache writes (instant!) → Post indexed for pull
Read Performance

User following 500 accounts:

  • → 490 regular users: read from pre-computed feed
  • → 10 celebrities: pull their posts
  • → Merge the cached feed (posts from 490 accounts) + up to 100 celebrity posts
  • → Still fast! (one cache read + 10 small pulls)
Storage
  • → Feed caches only contain regular user posts
  • → Celebrity posts stored once, pulled on demand
  • → Significant storage savings
WorksheetThe hybrid for the preface's app (assumptions labelled)
Value
Users (preface) 100 million
Posts per user per day (assumed) 0.2 → 20 million posts a day, ~230/s
Followers per regular post (assumed average) 200 → 4 billion feed inserts a day, ~46,000/s
One post from a 50-million-follower account (preface), pushed 50 million inserts ≈ 18 minutes of the platform's whole average insert load (50M ÷ 46,000/s ≈ 1,090 s)
The same post under the hybrid 1 insert into celebrity_posts:{id}
Feed opens (assumed: 50 million daily users × 10 opens) 500 million a day, ~5,800/s; each also pulls from at most 50 followed celebrities (max_following_to_pull)

Conclusion: pushed, one celebrity post costs as much as 18 minutes of every other post on the platform combined. The hybrid turns it into a single write and moves the cost to reads, where each feed open pays a few small, parallel pulls.

ExplainerBreak it: feed fan-out, push vs pull

Sliders for the poster's follower count and the celebrity threshold (10,000 in the lesson). Below the threshold a post is pushed: one cache write per follower at 100,000 writes/s, so 10M followers wait 100 s and 300M about 50 minutes. At or above it the post is stored once and pulled on read: 0 writes, but each feed load adds pulls. The reader is illustrative: 10 of their 500 followed accounts are above 10,000, and about 3× more clear each 10× lower threshold.

Quick check3 questions · +5 XP each

Push, pull and the hybrid model

  1. Q1/3 A celebrity with 20 million followers posts, and your fan-out workers can do 100,000 feed writes per second. With pure fan-out on write, roughly how long until the last follower's feed has the post?

    Answer & explanation

    Answer: C

    20,000,000 / 100,000 = 200 seconds, a bit over 3 minutes, and the backlog grows if they post again. This delay (plus the write load) is why the hybrid model stops pushing for accounts above the celebrity threshold.

  2. Q2/3 In the hybrid model, a user follows 490 regular accounts and 10 celebrities. What happens when they open their feed?

    Answer & explanation

    Answer: A

    Regular accounts were pushed into feed:{user} at write time; celebrity posts were only indexed, so they are pulled per request and merged. That keeps the read to one cache read plus a few small pulls, instead of 500 queries.

  3. Q3/3 Under pure fan-out on read, users who follow 5,000 accounts complain their feed is far slower than everyone else's. What is the cause?

    Answer & explanation

    Answer: D

    Pull-based feeds have high read amplification and latency that depends on how many accounts you follow. There is no pre-computed feed or write fan-out in a pure pull model; those belong to fan-out on write.

Chapter 4: Advanced Feed Patterns#

4.1 Ranked Feeds (Algorithmic)#

Most modern feeds aren't purely chronological — they use relevance ranking.

RANKED FEED COMPONENTS — Score = f(recency, engagement, affinity, ...)
  • Recency
    • How old is the post?
    • Decay function: score decreases over time
  • Engagement
    • Likes, comments, shares
    • More engagement = higher score
  • Affinity
    • How much does user interact with author?
    • Frequent interactions = higher score
  • Content type
    • Videos might rank higher
    • Posts with images might rank higher
  • Negative signals
    • User hid similar posts
    • Low engagement on similar content

Implementation:

pythonRankingConfig · 132 lines
# Ranked Feed Implementation
from datetime import datetime, timezone


@dataclass
class RankingConfig:
    """Configuration for feed ranking."""
    recency_weight: float = 0.3
    engagement_weight: float = 0.3
    affinity_weight: float = 0.3
    content_weight: float = 0.1
    
    recency_half_life_hours: float = 6.0  # Score halves every 6 hours


class RankedFeedService:
    """
    Feed service with relevance ranking.
    
    Instead of pure chronological order, posts are ranked
    by a combination of recency, engagement, and affinity.
    """
    
    def __init__(self, redis_client, db_client, config: RankingConfig = None):
        self.redis = redis_client
        self.db = db_client
        self.config = config or RankingConfig()
    
    async def get_ranked_feed(
        self,
        user_id: str,
        limit: int = 50
    ) -> list:
        """Get feed ranked by relevance."""
        
        # 1. Get candidate posts (more than we need)
        candidates = await self._get_candidate_posts(user_id, limit * 3)
        
        # 2. Get user's affinity scores
        affinity_scores = await self._get_affinity_scores(user_id)
        
        # 3. Score each post
        scored_posts = []
        for post in candidates:
            score = self._calculate_score(post, affinity_scores)
            scored_posts.append((score, post))
        
        # 4. Sort by score and return top N
        scored_posts.sort(key=lambda x: x[0], reverse=True)
        return [post for _, post in scored_posts[:limit]]
    
    def _calculate_score(
        self,
        post: dict,
        affinity_scores: dict
    ) -> float:
        """Calculate relevance score for a post."""
        
        # Recency score (exponential decay)
        age_hours = (datetime.now(timezone.utc) - post['created_at']).total_seconds() / 3600
        recency_score = 0.5 ** (age_hours / self.config.recency_half_life_hours)
        
        # Engagement score (log scale)
        engagement = post.get('likes', 0) + post.get('comments', 0) * 2
        engagement_score = math.log(engagement + 1) / 10  # Normalize
        
        # Affinity score (from user interaction history)
        author_id = post['user_id']
        affinity_score = affinity_scores.get(author_id, 0.1)  # Default low
        
        # Content type score
        content_score = 0.5
        if post.get('has_image'):
            content_score = 0.7
        if post.get('has_video'):
            content_score = 0.9
        
        # Weighted combination
        total_score = (
            self.config.recency_weight * recency_score +
            self.config.engagement_weight * engagement_score +
            self.config.affinity_weight * affinity_score +
            self.config.content_weight * content_score
        )
        
        return total_score
    
    async def _get_affinity_scores(self, user_id: str) -> dict:
        """Get user's affinity scores for accounts they follow."""
        cache_key = f"affinity:{user_id}"
        
        cached = await self.redis.hgetall(cache_key)
        if cached:
            return {k: float(v) for k, v in cached.items()}
        
        # Calculate from interaction history
        interactions = await self.db.fetch(
            """
            SELECT target_user_id, 
                   COUNT(*) as interaction_count
            FROM user_interactions
            WHERE user_id = $1
            AND created_at > NOW() - INTERVAL '30 days'
            GROUP BY target_user_id
            """,
            user_id
        )
        
        # Normalize to 0-1 range
        if not interactions:
            return {}
        
        max_count = max(row['interaction_count'] for row in interactions)
        scores = {
            str(row['target_user_id']): row['interaction_count'] / max_count
            for row in interactions
        }
        
        # Cache for 1 hour
        if scores:
            await self.redis.hset(cache_key, mapping=scores)
            await self.redis.expire(cache_key, 3600)
        
        return scores
    
    async def _get_candidate_posts(self, user_id: str, limit: int) -> list:
        """Get candidate posts for ranking."""
        # Use hybrid approach to get candidates
        # Then we'll re-rank them
        
        # ... (same as hybrid feed implementation)
        pass
Your turn · checkpoint 4not answered

The shard nobody can find

You spread feeds across Redis shards with shard = hash(user_id) % num_shards, using Python's built-in hash(). The fan-out workers and the feed-reading API run as separate processes on different servers. What goes wrong, and what should the sharding function be?

Think it through, or jot it down. Then compare.

One strong answer

  • Python's hash() of a string is randomized per process, so a fan-out worker and an API server compute different shards for the same user. Posts are written to one shard and read from another, and feeds look empty.
  • Use a stable hash such as crc32(user_id) % num_shards, so every server maps a user to the same shard.

4.2 Sharded Feed Storage#

For massive scale, shard feeds across multiple Redis instances.

SHARDED FEED ARCHITECTURE

Instead of one Redis cluster for all feeds:

Shard Users
Feed Shard 0 Users where crc32(user_id) % N == 0
Feed Shard 1 Users where crc32(user_id) % N == 1
Feed Shard 2 Users where crc32(user_id) % N == 2
...

Sharding function: shard_id = crc32(user_id) % num_shards (a stable hash: every server must map a user to the same shard)

Benefits: Each shard handles subset of users · Horizontal scaling · Failure isolation

pythonShardedFeedStorage · 80 lines
# Sharded Feed Storage

import zlib


class ShardedFeedStorage:
    """
    Sharded storage for feeds.
    
    Distributes feeds across multiple Redis instances
    for horizontal scaling.
    """
    
    def __init__(self, redis_clients: List, num_shards: int):
        self.redis_clients = redis_clients
        self.num_shards = num_shards
    
    def _get_shard(self, user_id: str) -> int:
        """Get shard ID for a user."""
        # Use a stable hash: Python's built-in hash() of a str is randomized
        # per process, so different servers would pick different shards
        return zlib.crc32(user_id.encode()) % self.num_shards
    
    def _get_client(self, user_id: str):
        """Get Redis client for a user."""
        shard_id = self._get_shard(user_id)
        return self.redis_clients[shard_id]
    
    async def add_to_feed(self, user_id: str, post_id: str, timestamp: float):
        """Add post to user's feed."""
        client = self._get_client(user_id)
        feed_key = f"feed:{user_id}"
        
        await client.zadd(feed_key, {post_id: timestamp})
        await client.zremrangebyrank(feed_key, 0, -801)
    
    async def get_feed(self, user_id: str, limit: int = 50) -> list:
        """Get user's feed."""
        client = self._get_client(user_id)
        feed_key = f"feed:{user_id}"
        
        return await client.zrevrange(feed_key, 0, limit - 1)
    
    async def fan_out(self, post: dict, follower_ids: List[str]):
        """Fan out post to multiple followers (potentially different shards)."""
        post_id = str(post['id'])
        timestamp = post['created_at'].timestamp()
        
        # Group followers by shard
        shard_followers = defaultdict(list)
        for follower_id in follower_ids:
            shard_id = self._get_shard(follower_id)
            shard_followers[shard_id].append(follower_id)
        
        # Execute fan-out per shard (parallel)
        tasks = []
        for shard_id, followers in shard_followers.items():
            client = self.redis_clients[shard_id]
            tasks.append(
                self._fan_out_to_shard(client, followers, post_id, timestamp)
            )
        
        await asyncio.gather(*tasks)
    
    async def _fan_out_to_shard(
        self,
        client,
        follower_ids: List[str],
        post_id: str,
        timestamp: float
    ):
        """Fan out to followers in a single shard."""
        pipe = client.pipeline()
        
        for follower_id in follower_ids:
            feed_key = f"feed:{follower_id}"
            pipe.zadd(feed_key, {post_id: timestamp})
            pipe.zremrangebyrank(feed_key, 0, -801)
        
        await pipe.execute()

4.3 Feed Warming#

Pre-populate feeds for active users.

pythonFeedWarmer · 64 lines
# Feed Warming for Active Users

class FeedWarmer:
    """
    Pre-populate feeds for users likely to be active.
    
    Run periodically (e.g., before peak hours) to ensure
    active users have warm feed caches.
    """
    
    def __init__(self, feed_service, redis_client, db_client):
        self.feed_service = feed_service
        self.redis = redis_client
        self.db = db_client
    
    async def warm_active_users(self, hours_ahead: int = 2):
        """Warm feeds for users predicted to be active soon."""
        
        # Get users active at this time yesterday/last week
        predicted_active = await self._predict_active_users(hours_ahead)
        
        logger.info(f"Warming feeds for {len(predicted_active)} users")
        
        # Warm each user's feed
        for user_id in predicted_active:
            try:
                await self._warm_user_feed(user_id)
            except Exception as e:
                logger.warning(f"Failed to warm feed for {user_id}: {e}")
    
    async def _predict_active_users(self, hours_ahead: int) -> List[str]:
        """Predict users who will be active in the next N hours."""
        
        # Simple heuristic: users active at same time yesterday
        return await self.db.fetch_column(
            """
            SELECT DISTINCT user_id 
            FROM user_sessions
            WHERE created_at BETWEEN 
                NOW() - INTERVAL '25 hours' AND 
                NOW() - INTERVAL '23 hours'
            LIMIT 100000
            """
        )
    
    async def _warm_user_feed(self, user_id: str):
        """Populate a user's feed cache."""
        
        # Check if feed already cached
        feed_key = f"feed:{user_id}"
        exists = await self.redis.exists(feed_key)
        
        if exists:
            return  # Already warm
        
        # Compute and cache feed
        feed = await self.feed_service.get_feed(user_id, limit=100)
        
        if feed:
            pipe = self.redis.pipeline()
            for post in feed:
                pipe.zadd(feed_key, {str(post['id']): post['created_at'].timestamp()})
            pipe.expire(feed_key, 86400)
            await pipe.execute()
Quick check3 questions · +5 XP each

Ranking, sharding and warming

  1. Q1/3 The ranking config uses a recency half-life of 6 hours. Ignoring the other signals, how does a 12-hour-old post's recency score compare with a brand-new post's?

    Answer & explanation

    Answer: B

    The score is 0.5 ** (age / half_life): 12 / 6 = 2 half-lives, so 0.5 × 0.5 = 0.25. Exponential decay never reaches zero; old posts with high engagement or affinity can still rank.

  2. Q2/3 When fanning out to followers spread across feed shards, why does ShardedFeedStorage group followers by shard first?

    Answer & explanation

    Answer: D

    Grouping turns thousands of single writes into one pipeline per shard, and asyncio.gather runs those shards concurrently. Each user's feed lives on exactly one shard (crc32(user_id) % num_shards); nothing is replicated across shards.

  3. Q3/3 The feed warmer only warms users who were active at this time yesterday, rather than every user. Why?

    Answer & explanation

    Answer: A

    Warming pays off only for feeds that will be read soon; yesterday's activity at the same hour is a cheap predictor. Anyone it misses still gets a feed computed on demand when they show up.

Part II · Stage 2 of 4 · 10 min

Part II: Implementation#

Chapter 5: Production Feed System#

pythonProductionFeedConfig · 553 lines
# Production Feed System - Complete Implementation

import asyncio
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Set, Optional
from collections import defaultdict
import math

logger = logging.getLogger(__name__)


# =============================================================================
# Configuration
# =============================================================================

@dataclass
class ProductionFeedConfig:
    """Complete configuration for production feed system."""
    
    # Celebrity threshold
    celebrity_threshold: int = 10000
    
    # Cache settings
    feed_cache_size: int = 800
    feed_cache_ttl: int = 86400  # 24 hours
    post_cache_ttl: int = 3600   # 1 hour
    
    # Fan-out settings
    async_fanout_threshold: int = 1000  # Use async for > 1000 followers
    fanout_batch_size: int = 1000
    
    # Ranking settings
    enable_ranking: bool = True
    recency_weight: float = 0.3
    engagement_weight: float = 0.3
    affinity_weight: float = 0.3
    content_weight: float = 0.1
    recency_half_life_hours: float = 6.0
    
    # Performance settings
    max_following_to_pull: int = 50  # Max celebrities to pull per request
    candidate_multiplier: int = 3   # Fetch 3x posts for ranking


# =============================================================================
# Metrics
# =============================================================================

@dataclass
class FeedMetrics:
    """Metrics for feed system."""
    feed_requests: int = 0
    posts_created: int = 0
    fanouts_sync: int = 0
    fanouts_async: int = 0
    celebrity_pulls: int = 0
    cache_hits: int = 0
    cache_misses: int = 0
    avg_feed_latency_ms: float = 0
    
    def to_dict(self) -> dict:
        return {
            'feed_requests': self.feed_requests,
            'posts_created': self.posts_created,
            'fanouts': {'sync': self.fanouts_sync, 'async': self.fanouts_async},
            'celebrity_pulls': self.celebrity_pulls,
            'cache_hit_ratio': self.cache_hits / max(1, self.cache_hits + self.cache_misses),
            'avg_feed_latency_ms': round(self.avg_feed_latency_ms, 2)
        }


# =============================================================================
# Main Feed Service
# =============================================================================

class ProductionFeedService:
    """
    Production-ready feed service.
    
    Features:
    - Hybrid push/pull model
    - Celebrity handling
    - Relevance ranking
    - Sharding support
    - Comprehensive metrics
    """
    
    def __init__(
        self,
        redis_client,
        db_client,
        queue_client,
        config: ProductionFeedConfig = None
    ):
        self.redis = redis_client
        self.db = db_client
        self.queue = queue_client
        self.config = config or ProductionFeedConfig()
        self.metrics = FeedMetrics()
        
        # Celebrity cache (in-memory for speed)
        self._celebrities: Set[str] = set()
        self._celebrities_loaded = False
    
    async def initialize(self):
        """Initialize service (call on startup)."""
        await self._load_celebrities()
    
    # =========================================================================
    # Post Creation
    # =========================================================================
    
    async def create_post(
        self,
        user_id: str,
        content: str,
        media_type: str = None
    ) -> dict:
        """
        Create a post and distribute to followers.
        
        Uses appropriate fan-out strategy based on follower count.
        """
        self.metrics.posts_created += 1
        
        # Save post
        post = await self._save_post(user_id, content, media_type)
        
        # Determine fan-out strategy
        is_celebrity = await self._is_celebrity(user_id)
        
        if is_celebrity:
            # Celebrity: Index for pull, no fan-out
            await self._index_celebrity_post(post)
            logger.debug(f"Celebrity post indexed: {post['id']}")
        else:
            # Regular user: Fan out
            follower_count = await self._get_follower_count(user_id)
            
            if follower_count < self.config.async_fanout_threshold:
                # Small fan-out: synchronous
                await self._fan_out_sync(post)
                self.metrics.fanouts_sync += 1
            else:
                # Large fan-out: asynchronous via queue
                await self._fan_out_async(post)
                self.metrics.fanouts_async += 1
        
        return post
    
    async def _save_post(
        self,
        user_id: str,
        content: str,
        media_type: str
    ) -> dict:
        """Save post to database and cache."""
        post = await self.db.fetch_one(
            """
            INSERT INTO posts (user_id, content, media_type, created_at)
            VALUES ($1, $2, $3, NOW())
            RETURNING id, user_id, content, media_type, created_at,
                      0 as likes, 0 as comments, 0 as shares
            """,
            user_id, content, media_type
        )
        
        post_dict = self._post_to_dict(post)
        
        # Cache post
        await self.redis.setex(
            f"post:{post['id']}",
            self.config.post_cache_ttl,
            json.dumps(post_dict, default=str)
        )
        
        # Add to user's posts
        await self.redis.zadd(
            f"user_posts:{user_id}",
            {str(post['id']): post['created_at'].timestamp()}
        )
        
        return post_dict
    
    async def _index_celebrity_post(self, post: dict):
        """Index celebrity post for pull queries."""
        await self.redis.zadd(
            f"celebrity_posts:{post['user_id']}",
            {str(post['id']): post['created_at'].timestamp()}
        )
        await self.redis.zremrangebyrank(
            f"celebrity_posts:{post['user_id']}",
            0, -101  # Keep last 100
        )
    
    async def _fan_out_sync(self, post: dict):
        """Synchronous fan-out for small follower counts."""
        followers = await self._get_followers(post['user_id'])
        
        if not followers:
            return
        
        post_id = str(post['id'])
        timestamp = post['created_at'].timestamp()
        
        # Batch updates
        pipe = self.redis.pipeline()
        for follower_id in followers:
            feed_key = f"feed:{follower_id}"
            pipe.zadd(feed_key, {post_id: timestamp})
            pipe.zremrangebyrank(feed_key, 0, -self.config.feed_cache_size - 1)
        
        await pipe.execute()
    
    async def _fan_out_async(self, post: dict):
        """Asynchronous fan-out via message queue."""
        await self.queue.send("fanout-jobs", {
            "post_id": str(post['id']),
            "user_id": post['user_id'],
            "timestamp": post['created_at'].timestamp()
        })
    
    # =========================================================================
    # Feed Retrieval
    # =========================================================================
    
    async def get_feed(
        self,
        user_id: str,
        limit: int = 50,
        offset: int = 0,
        ranked: bool = None
    ) -> List[dict]:
        """
        Get user's feed.
        
        Combines pre-computed feed (regular users) with
        pulled celebrity posts, optionally ranked.
        """
        start_time = datetime.now(timezone.utc)
        self.metrics.feed_requests += 1
        
        ranked = ranked if ranked is not None else self.config.enable_ranking
        
        # Get more candidates if ranking
        fetch_limit = limit * self.config.candidate_multiplier if ranked else limit
        
        # 1. Get pre-computed feed
        precomputed = await self._get_precomputed_feed(
            user_id, fetch_limit, offset
        )
        
        # 2. Get celebrity posts
        followed_celebrities = await self._get_followed_celebrities(user_id)
        
        celebrity_posts = []
        if followed_celebrities:
            # Limit celebrities to pull
            celebs_to_pull = followed_celebrities[:self.config.max_following_to_pull]
            celebrity_posts = await self._pull_celebrity_posts(celebs_to_pull)
            self.metrics.celebrity_pulls += len(celebs_to_pull)
        
        # 3. Merge
        all_posts = precomputed + celebrity_posts
        
        # 4. Deduplicate
        seen = set()
        unique_posts = []
        for post in all_posts:
            if post['id'] not in seen:
                seen.add(post['id'])
                unique_posts.append(post)
        
        # 5. Rank or sort chronologically
        if ranked and len(unique_posts) > limit:
            affinity = await self._get_affinity_scores(user_id)
            result = self._rank_posts(unique_posts, affinity)[:limit]
        else:
            unique_posts.sort(key=lambda p: p['created_at'], reverse=True)
            result = unique_posts[:limit]
        
        # Record latency
        latency_ms = (datetime.now(timezone.utc) - start_time).total_seconds() * 1000
        self._update_latency(latency_ms)
        
        return result
    
    async def _get_precomputed_feed(
        self,
        user_id: str,
        limit: int,
        offset: int
    ) -> List[dict]:
        """Get pre-computed feed from cache."""
        feed_key = f"feed:{user_id}"
        
        post_ids = await self.redis.zrevrange(
            feed_key,
            offset,
            offset + limit - 1
        )
        
        if post_ids:
            self.metrics.cache_hits += 1
            return await self._get_posts_by_ids(post_ids)
        
        self.metrics.cache_misses += 1
        return []
    
    async def _pull_celebrity_posts(
        self,
        celebrity_ids: List[str]
    ) -> List[dict]:
        """Pull recent posts from celebrities."""
        all_posts = []
        
        # Parallel fetch
        tasks = [
            self._get_celebrity_recent_posts(celeb_id)
            for celeb_id in celebrity_ids
        ]
        
        results = await asyncio.gather(*tasks, return_exceptions=True)
        
        for result in results:
            if isinstance(result, list):
                all_posts.extend(result)
        
        return all_posts
    
    async def _get_celebrity_recent_posts(
        self,
        user_id: str,
        limit: int = 20
    ) -> List[dict]:
        """Get recent posts from a celebrity."""
        post_ids = await self.redis.zrevrange(
            f"celebrity_posts:{user_id}",
            0, limit - 1
        )
        
        if not post_ids:
            return []
        
        return await self._get_posts_by_ids(post_ids)
    
    # =========================================================================
    # Ranking
    # =========================================================================
    
    def _rank_posts(
        self,
        posts: List[dict],
        affinity_scores: Dict[str, float]
    ) -> List[dict]:
        """Rank posts by relevance."""
        scored = []
        
        for post in posts:
            score = self._calculate_score(post, affinity_scores)
            scored.append((score, post))
        
        scored.sort(key=lambda x: x[0], reverse=True)
        return [post for _, post in scored]
    
    def _calculate_score(
        self,
        post: dict,
        affinity_scores: Dict[str, float]
    ) -> float:
        """Calculate relevance score for a post."""
        c = self.config
        
        # Recency score
        age_hours = (datetime.now(timezone.utc) - post['created_at']).total_seconds() / 3600
        recency = 0.5 ** (age_hours / c.recency_half_life_hours)
        
        # Engagement score (log scale, normalized)
        engagement = post.get('likes', 0) + post.get('comments', 0) * 2
        engagement_score = min(1.0, math.log(engagement + 1) / 10)
        
        # Affinity score
        affinity = affinity_scores.get(str(post['user_id']), 0.1)
        
        # Content score
        content = 0.5
        if post.get('media_type') == 'image':
            content = 0.7
        elif post.get('media_type') == 'video':
            content = 0.9
        
        return (
            c.recency_weight * recency +
            c.engagement_weight * engagement_score +
            c.affinity_weight * affinity +
            c.content_weight * content
        )
    
    async def _get_affinity_scores(self, user_id: str) -> Dict[str, float]:
        """Get user's affinity scores."""
        cache_key = f"affinity:{user_id}"
        
        cached = await self.redis.hgetall(cache_key)
        if cached:
            return {k: float(v) for k, v in cached.items()}
        
        # Calculate from interactions
        rows = await self.db.fetch(
            """
            SELECT target_user_id, COUNT(*) as cnt
            FROM user_interactions
            WHERE user_id = $1 AND created_at > NOW() - INTERVAL '30 days'
            GROUP BY target_user_id
            """,
            user_id
        )
        
        if not rows:
            return {}
        
        max_cnt = max(r['cnt'] for r in rows)
        scores = {str(r['target_user_id']): r['cnt'] / max_cnt for r in rows}
        
        if scores:
            await self.redis.hset(cache_key, mapping={k: str(v) for k, v in scores.items()})
            await self.redis.expire(cache_key, 3600)
        
        return scores
    
    # =========================================================================
    # Helpers
    # =========================================================================
    
    async def _is_celebrity(self, user_id: str) -> bool:
        """Check if user is a celebrity."""
        if not self._celebrities_loaded:
            await self._load_celebrities()
        
        return user_id in self._celebrities
    
    async def _load_celebrities(self):
        """Load celebrity set from Redis."""
        celebrities = await self.redis.smembers("celebrities")
        self._celebrities = set(celebrities) if celebrities else set()
        self._celebrities_loaded = True
    
    async def _get_follower_count(self, user_id: str) -> int:
        """Get follower count (cached)."""
        cached = await self.redis.get(f"follower_count:{user_id}")
        if cached:
            return int(cached)
        
        count = await self.db.fetch_val(
            "SELECT COUNT(*) FROM follows WHERE followee_id = $1",
            user_id
        )
        
        await self.redis.setex(f"follower_count:{user_id}", 300, str(count))
        
        # Update celebrity status
        if count >= self.config.celebrity_threshold:
            await self.redis.sadd("celebrities", user_id)
            self._celebrities.add(user_id)
        
        return count
    
    async def _get_followers(self, user_id: str) -> List[str]:
        """Get list of follower IDs."""
        rows = await self.db.fetch(
            "SELECT follower_id FROM follows WHERE followee_id = $1",
            user_id
        )
        return [str(row['follower_id']) for row in rows]
    
    async def _get_followed_celebrities(self, user_id: str) -> List[str]:
        """Get celebrities that user follows."""
        following = await self.redis.smembers(f"following:{user_id}")
        
        if not following:
            rows = await self.db.fetch(
                "SELECT followee_id FROM follows WHERE follower_id = $1",
                user_id
            )
            following = {str(row['followee_id']) for row in rows}
            
            if following:
                await self.redis.sadd(f"following:{user_id}", *following)
                await self.redis.expire(f"following:{user_id}", 300)
        
        return [uid for uid in following if uid in self._celebrities]
    
    async def _get_posts_by_ids(self, post_ids: List[str]) -> List[dict]:
        """Fetch posts by IDs with caching."""
        posts = []
        to_fetch = []
        
        # Try cache
        pipe = self.redis.pipeline()
        for post_id in post_ids:
            pipe.get(f"post:{post_id}")
        cached = await pipe.execute()
        
        for i, (post_id, data) in enumerate(zip(post_ids, cached)):
            if data:
                posts.append((i, json.loads(data)))
            else:
                to_fetch.append((i, post_id))
        
        # Fetch missing
        if to_fetch:
            ids = [pid for _, pid in to_fetch]
            rows = await self.db.fetch(
                "SELECT * FROM posts WHERE id = ANY($1)",
                ids
            )
            
            row_map = {str(r['id']): self._post_to_dict(r) for r in rows}
            
            pipe = self.redis.pipeline()
            for idx, post_id in to_fetch:
                if post_id in row_map:
                    post = row_map[post_id]
                    posts.append((idx, post))
                    pipe.setex(
                        f"post:{post_id}",
                        self.config.post_cache_ttl,
                        json.dumps(post, default=str)
                    )
            
            await pipe.execute()
        
        posts.sort(key=lambda x: x[0])
        return [post for _, post in posts]
    
    def _post_to_dict(self, row) -> dict:
        """Convert database row to dict."""
        post = dict(row)
        if isinstance(post.get('created_at'), str):
            post['created_at'] = datetime.fromisoformat(post['created_at'])
        return post
    
    def _update_latency(self, latency_ms: float):
        """Update average latency metric."""
        n = self.metrics.feed_requests
        old_avg = self.metrics.avg_feed_latency_ms
        self.metrics.avg_feed_latency_ms = old_avg + (latency_ms - old_avg) / n
    
    def get_metrics(self) -> dict:
        """Get service metrics."""
        return self.metrics.to_dict()
Quick check3 questions · +5 XP each

The production feed service

  1. Q1/3 With the production defaults (celebrity threshold 10,000; async fan-out threshold 1,000), a user with 5,000 followers posts. What happens?

    Answer & explanation

    Answer: C

    5,000 is below the celebrity threshold, so it is pushed, but above 1,000, so the fan-out goes through the queue rather than blocking the request. Pull-only indexing is reserved for accounts at or above 10,000 followers.

  2. Q2/3 get_feed pulls posts from at most max_following_to_pull (50) followed celebrities. What does this cap protect?

    Answer & explanation

    Answer: A

    Every followed celebrity adds a pull at read time; without a cap, a user following hundreds of celebrities would bring back the pull model's read amplification. The 100-post limit per celebrity is a separate trim on the write side.

  3. Q3/3 With ranking enabled, the service fetches limit × candidate_multiplier posts (150 for a page of 50). Why fetch more than it returns?

    Answer & explanation

    Answer: D

    Ranking reorders by recency, engagement and affinity; if you only took the newest 50, the ranker could never surface a highly relevant post just outside that window. In chronological mode the multiplier isn't applied, because the newest 50 are already the answer.

Part III · Stage 3 of 4 · 5 min

Part III: Real-World Application#

Chapter 6: How Big Tech Does It#

6.1 Case Study: Twitter — Hybrid with Heavy Push#

Twitter's approach (as described in its engineering talks on the home timeline):

  • Primarily fan-out on write: each tweet ID is pushed into followers' home timelines held in Redis (roughly 800 entries per timeline, replicated)
  • Accounts with very large follower counts are not fanned out; their tweets are merged in at read time
  • Long-inactive users are skipped during fan-out; their timeline is rebuilt when they return

Why mostly push?

  • Timeline reads vastly outnumber tweets (hundreds of thousands of reads/s vs a few thousand tweets/s on average)
  • A precomputed timeline turns each read into a single cache lookup
DiagramTwitter feed architecture (simplified)
%% title: Twitter feed architecture (simplified)
flowchart TD
  TC["Tweet Created"]:::client
  TS["Tweet service<br/>1. Save tweet (Manhattan)<br/>2. Index for search<br/>3. Fan out tweet ID to followers'<br/>Redis timelines (skip very large accounts)"]:::service
  TR["Timeline Request"]:::client
  TL["Timeline service<br/>1. Read precomputed timeline from Redis<br/>2. Merge tweets from very large accounts<br/>3. Rank (algorithmic timeline)<br/>4. Hydrate tweets and return"]:::core
  TC --> TS
  TR --> TL

Key optimizations:

  • Timelines store only tweet IDs; tweet bodies come from a separate tweet cache
  • Fan-out skips very large accounts and long-inactive users
  • Parallel fetching with timeouts
  • Algorithmic ranking on top of the candidate set

6.2 Case Study: Instagram — Hybrid with Heavy Push (simplified)#

Instagram's approach (thresholds below are illustrative):

  • Primarily fan-out on write (push-based)
  • Pull only for mega-celebrities

Why mostly push?

  • Instagram users typically follow fewer accounts than Twitter users
  • Photos/videos are high-value content
  • User expectation of seeing all followed content
DiagramInstagram feed architecture
%% title: Instagram feed architecture
flowchart TD
  PC@{ icon: "sd:camera", label: "Post Created" }
  MS["Media service<br/>1. Save media to storage<br/>2. Create post record<br/>3. Check follower count"]:::core
  SY["Sync fan-out"]:::ok
  AS@{ icon: "sd:queue", label: "Async fan-out (queued)" }
  CEL["Celebrity treatment<br/>(pull-based)"]:::service
  PC --> MS
  MS -->|"< 10K"| SY
  MS -->|">= 10K"| AS
  MS -->|"> 1M"| CEL
  FR@{ icon: "sd:phone", label: "Feed Request" }
  FS["Feed service<br/>1. Read from feed cache (Cassandra)<br/>2. If following mega-celebrities, pull their posts<br/>3. Merge and rank<br/>4. Apply 'posts you may have missed' logic"]:::core
  FR --> FS
  class PC,FR client
  class AS queue

Key features:

  • Feed candidates stored in Cassandra
  • Ranking applied when the feed is read (scores can be pre-computed at fan-out)
  • "You're all caught up" notice marks the point where you have seen every post from the last two days (you can keep scrolling past it)
Your turn · checkpoint 5not answered

"John liked this"

Your feed must also show posts that your connections reacted to, with "John liked this" on top. Would you push those into feeds when the reaction happens, or assemble them when the feed is read? How do you avoid showing the same post twice?

Think it through, or jot it down. Then compare.

One strong answer

  • Assemble on read. Pushing would turn every like into a fan-out to all of John's connections, and reactions are far more frequent than posts.
  • At read time: fetch direct posts and recent reactions from connections, load the reacted-to posts, add the attribution, then merge and rank.
  • Deduplicate by post ID: if a post arrives both directly and via a reaction, keep the direct one and attach the reaction as metadata.

6.3 Case Study: LinkedIn — Professional Feed#

LinkedIn's unique challenges:

  • "Viral" posts spread beyond connections (reactions show to network)
  • Professional content requires different ranking
  • Ads mixed into feed
DiagramLinkedIn feed architecture
%% title: LinkedIn feed architecture
flowchart TD
  PS@{ icon: "sd:user", label: "Post/Share Created" }
  subgraph FM["Feed mixer"]
    direction TB
    FG@{ icon: "sd:database", label: "Follow Graph Store<br/>connections/followers" }
    AC@{ icon: "sd:database", label: "Activity Store<br/>posts, reactions, comments" }
    AD["Ads Service<br/>sponsored content"]:::service
  end
  PS --> FM
  FR@{ icon: "sd:user", label: "Feed Request" }
  S1["1. Pull from multiple sources:<br/>Direct connections' posts<br/>Posts connections reacted to (2nd degree)<br/>Hashtags followed<br/>Company pages followed"]:::core
  S2["2. Rank with professional relevance model"]:::core
  S3["3. Mix in ads based on targeting"]:::core
  S4["4. Apply diversity rules<br/>(no same author twice in a row)"]:::core
  FR --> S1 --> S2 --> S3 --> S4
  class PS,FR client
  class FG,AC store

Key insight:

  • LinkedIn feed includes "2nd degree" content: "John Doe likes this" expands content distribution
  • This makes pure push impossible - must compute on read

6.4 Summary: Industry Approaches#

Platform Primary Strategy Celebrity Handling Typical Following (assumed)
Twitter Hybrid, mostly push Very large accounts merged at read time ~200 following
Instagram Push Pull for >1M followers ~150 following
Facebook Hybrid Complex ranking ~300 friends
LinkedIn Pull 2nd degree content ~500 connections
TikTok Pull (algorithm) Minimal follow concept Algorithmic

Chapter 7: Common Mistakes#

7.1 Mistake 1: Pure Push at Scale#

Pure push at scale

WRONG: Fan-out everything to everyone

pythoncreate_post · 14 lines
async def create_post(user_id: str, content: str):
    post = await save_post(user_id, content)

    followers = await get_all_followers(user_id)  # Could be millions!

    for follower in followers:
        await add_to_feed(follower, post)  # Millions of operations!

    return post

# Celebrity with 10M followers posts
# = 10M synchronous cache writes
# = Minutes or hours to complete
# = Post not visible for ages

CORRECT: Strategy based on follower count

pythoncreate_post · 16 lines
async def create_post(user_id: str, content: str):
    post = await save_post(user_id, content)

    follower_count = await get_follower_count(user_id)

    if follower_count < CELEBRITY_THRESHOLD:
        # Push for regular users
        if follower_count < 1000:
            await fan_out_sync(post)
        else:
            await fan_out_async(post)
    else:
        # Index for pull (celebrities)
        await index_celebrity_post(post)

    return post

7.2 Mistake 2: Unbounded Feed Size#

Unbounded feed size

WRONG: Keep all posts forever

pythonadd_to_feed · 5 lines
async def add_to_feed(user_id: str, post_id: str):
    await redis.zadd(f"feed:{user_id}", {post_id: timestamp})
    # Never trimmed!
    # Feed grows forever
    # Memory exhaustion

CORRECT: Trim to reasonable size

pythonadd_to_feed · 7 lines
FEED_SIZE_LIMIT = 800  # Keep last 800 posts

async def add_to_feed(user_id: str, post_id: str):
    pipe = redis.pipeline()
    pipe.zadd(f"feed:{user_id}", {post_id: timestamp})
    pipe.zremrangebyrank(f"feed:{user_id}", 0, -FEED_SIZE_LIMIT - 1)
    await pipe.execute()

7.3 Mistake 3: Ignoring Inactive Users#

Ignoring inactive users

WRONG: Fan out to all followers equally

pythonfan_out · 6 lines
async def fan_out(post: dict, followers: List[str]):
    for follower in followers:
        await add_to_feed(follower, post)

    # Fans out to users who haven't logged in for years
    # Wasted writes

CORRECT: Prioritize active users

pythonfan_out · 17 lines
async def fan_out(post: dict, followers: List[str]):
    # Get activity status
    activity = await get_user_activity_batch(followers)

    # Prioritize by activity
    active = [f for f in followers if activity[f] == 'active']
    recent = [f for f in followers if activity[f] == 'recent']
    dormant = [f for f in followers if activity[f] == 'dormant']

    # Always fan out to active users
    await fan_out_batch(post, active)

    # Fan out to recent users
    await fan_out_batch(post, recent)

    # Skip dormant users (they'll pull on return)
    # Or fan out in background with low priority

7.4 Mistake 4: Sequential Feed Assembly#

Sequential feed assembly

WRONG: Sequential fetches

pythonget_feed · 10 lines
async def get_feed(user_id: str) -> list:
    following = await get_following(user_id)

    all_posts = []
    for followee in following:
        posts = await get_user_posts(followee)  # Sequential!
        all_posts.extend(posts)

    # 500 following = 500 sequential queries
    # At 10ms each = 5 seconds!

CORRECT: Parallel fetches

pythonget_feed · 13 lines
async def get_feed(user_id: str) -> list:
    following = await get_following(user_id)

    # Parallel fetch
    tasks = [get_user_posts(followee) for followee in following]
    results = await asyncio.gather(*tasks)

    all_posts = []
    for posts in results:
        all_posts.extend(posts)

    # 500 following = 1 parallel batch
    # Total time ≈ slowest single query

7.5 Mistake Checklist#

Before deploying feed system:

Before deploying feed system0/7 ready
  • Celebrity handling — Different strategy for high-follower users
  • Feed size limits — Trim old posts
  • Activity awareness — Prioritize active users
  • Parallel operations — Don't sequentially fetch
  • Async fan-out — Queue large fan-outs
  • Monitoring — Track fan-out times, feed latency
  • Graceful degradation — Serve partial feed if slow
Quick check3 questions · +5 XP each

Real-world feeds and common mistakes

  1. Q1/3 LinkedIn shows posts your connections reacted to ("John liked this"). Why does that push its design toward computing the feed on read?

    Answer & explanation

    Answer: B

    Second-degree content turns every reaction into a potential fan-out event, which is far more frequent than posting. Pulling reactions at read time avoids that amplification. Ranking can run at either time: scores can be pre-computed at fan-out and refined at read.

  2. Q2/3 An account with 8,000 followers posts, but 60% of those followers haven't logged in for a year. What does the lesson recommend?

    Answer & explanation

    Answer: C

    Writing to feeds no one reads is pure waste. Prioritizing by activity cuts most of the writes, and dormant users can have their feed computed when they come back. Switching a regular account to pull-only would slow every active follower's read for no reason.

  3. Q3/3 A pull-based feed for someone following 500 accounts takes 5 seconds, with each followee query taking about 10 ms. What change brings it down to roughly the time of the slowest single query?

    Answer & explanation

    Answer: D

    500 sequential fetches × 10 ms = 5 s. Running them concurrently (e.g. asyncio.gather) makes the total close to the slowest single fetch. Caching the following list saves one lookup, not the 500 post fetches that dominate the time.

Part IV · Stage 4 of 4 · 8 min

Part IV: Interview Preparation#

Chapter 8: Interview Tips#

❯ Say this

8.1 Key Phrases#

Key phrases
INTRODUCING THE PROBLEM

"The key challenge with feeds is the read/write trade-off. We can optimize for fast reads by pre-computing feeds, but that creates massive write amplification when someone with millions of followers posts."

EXPLAINING HYBRID APPROACH

"I'd use a hybrid model. Regular users with under 10,000 followers get fan-out on write—their posts are pushed to followers' feeds immediately. Celebrities get fan-out on read—their posts are pulled when followers request their feed. This solves the celebrity problem while keeping reads fast for most users."

DISCUSSING TRADE-OFFS

"The trade-off is storage versus compute. Fan-out on write uses more storage—we're caching feeds for every user—but reads are instant. Fan-out on read saves storage but every feed request requires computation. For a social network where reads vastly outnumber writes, the storage trade-off is usually worth it."

ON RANKING

"Modern feeds aren't purely chronological. I'd rank by a combination of recency, engagement, and user affinity. This means we fetch more candidates than we need and score them, returning the top N. The ranking happens at read time, using cached affinity scores."

8.2 Common Questions#

Question Good Answer
"What about consistency? User posts, immediately checks feed, doesn't see post?" "For fan-out on write, the post should appear almost instantly. For celebrities with async fan-out, there's a brief delay. We could add the user's own posts client-side immediately, or use read-your-writes consistency by checking the posts table for the requesting user's recent posts."
"How do you handle unfollows?" "When user A unfollows user B, we remove B's posts from A's feed cache. For active users, this is a small operation. For cached feeds of inactive users, we can clean up lazily on next access."
"What about deletions?" "When a post is deleted, we publish a deletion event. The feed service removes that post_id from feeds. Since feeds are stored as sorted sets of IDs, we just remove the ID—O(log N). We also mark the post as deleted in cache so it's filtered at read time."
"How do you handle someone following a new account?" "When user A follows user B, we backfill A's feed with B's recent posts. If B is a celebrity, those posts will be pulled anyway. If B is a regular user, we add their recent posts to A's feed cache, re-sorted by timestamp."

Chapter 9: Practice Problems#

Practice · problem 1

Problem 1: News Feed Design#

Setup: Design a news feed for a social network with 500 million users. Users follow accounts and see posts in reverse chronological order.

Requirements:

  • Average user follows 200 accounts
  • 1% of users have >100K followers (influencers)
  • Feed must load in <200ms
  • 10 billion feed requests per day

Questions:

  1. Push, pull, or hybrid?
  2. How do you store feeds?
  3. How do you handle influencers?
Click for solution outline

Strategy: Hybrid

Hybrid strategy
  • Regular users (< 100K followers): Fan-out on write
  • Influencers (>= 100K followers): Fan-out on read
Storage
  • Redis sorted sets for feed cache (per user)
  • Keep last 500 posts per feed
  • ~64 bytes per entry (post ID + score + sorted-set overhead)
  • 500M users × 500 × 64 B ≈ 16 TB
  • Use Redis Cluster with sharding
Feed retrieval
  1. Get pre-computed feed from Redis
  2. Get followed influencers
  3. Pull influencer posts
  4. Merge (already sorted in cache + sort influencer posts)
  5. Return top 50
Influencer handling
  • Store influencer posts in separate sorted set
  • Pull max 20 influencers per request
  • Cache influencer post lists aggressively

Practice · problem 2

Problem 2: Activity Feed with Reactions#

Setup: Design a LinkedIn-style activity feed where you see not just posts from connections, but also posts they reacted to.

Requirements:

  • User sees: direct posts, posts connections liked/commented
  • Must avoid showing same post twice
  • Must show "John liked this" attribution

Questions:

  1. How does this change the architecture?
  2. How do you track "2nd degree" content?
  3. How do you deduplicate?
Click for solution outline

This requires pull-based approach

Pull-based reaction feed
Why pull?
  • When John likes a post, it should appear in his connections' feeds
  • That's fan-out-on-reaction which multiplies write load
  • Better to compute at read time
Architecture — Feed request
  1. Get direct posts from connections (standard)
  2. Get recent reactions from connections
  3. For each reaction, fetch the post (if not already included)
  4. Add attribution ("John liked this")
  5. Merge, dedupe, rank
Deduplication
  • Track seen post_ids
  • If post appears via multiple paths (direct + reaction), keep direct
  • Add reaction info as metadata
Data model

reactions table: (user_id, post_id, reaction_type, created_at)

Query: Get posts my connections reacted to

sql6 lines
SELECT DISTINCT r.post_id, r.user_id, r.reaction_type, r.created_at
FROM reactions r
WHERE r.user_id IN (my_connections)
AND r.created_at > NOW() - INTERVAL '7 days'
ORDER BY r.created_at DESC
LIMIT 100

Cache reactions per user for fast lookup.

Practice · problem 3

Problem 3: Real-Time Feed Updates#

Setup: Users want to see new posts appear in their feed without refreshing. Design real-time feed updates.

Requirements:

  • New posts appear within 5 seconds
  • Don't overwhelm clients with updates
  • Handle users following many active accounts

Questions:

  1. Push to client or poll?
  2. How do you handle high-frequency posters?
  3. How do you scale WebSocket connections?
Click for solution outline

Approach: Hybrid push with rate limiting

DiagramHybrid push with rate limiting
%% title: Hybrid push with rate limiting
flowchart TD
  P@{ icon: "sd:user", label: "Post created" } --> FO["Fan-out service"]:::core
  FO --> FC@{ icon: "sd:cache", label: "Feed cache (as before)" }
  FO --> RT@{ icon: "sd:bell", label: "Real-time notification service" }
  RT --> L["For each online follower:<br/>Check rate limit"]
  L -->|"If OK"| WS@{ icon: "tech:websocket", label: "Push via WebSocket" }
  L -->|"If rate limited"| SK["Skip"]:::bad
  class P client
  class FC cache
  class RT service
  class WS ok

Rate limiting:

  • Max 1 push per user per 5 seconds
  • If multiple posts in 5s window, batch them
  • "3 new posts" indicator instead of individual pushes

WebSocket scaling:

  • Sticky sessions to WebSocket servers
  • Use Redis pub/sub for cross-server messaging
  • Connection servers subscribe to user channels

Client handling:

  • Receive push notification "new posts available"
  • Client decides when to fetch (immediate or on scroll-to-top)
  • Avoid jarring UI updates while reading

Fallback:

  • If WebSocket disconnects, fall back to polling
  • Poll every 30 seconds when app is in foreground

Chapter 10: Mock Interview#

Scenario: Design Instagram Feed#

Transcript
Interviewer

"Let's design the Instagram feed. Walk me through your approach."

You

"Sure. Let me start by understanding the scale and requirements.

Let's assume about 2 billion users, maybe 500 million daily active, with the average user following around 150 accounts. Users expect to see posts from everyone they follow, and the feed should load instantly.

The core challenge is: when someone posts, how do we get that post into the feeds of all their followers?"

Interviewer

"Exactly. How would you approach that?"

You

"I'd use a hybrid push/pull model. The key insight is that most users have relatively few followers, but some celebrities have millions. These need different strategies.

For regular users with under, say, 10,000 followers, I'd use fan-out on write. When they post, we immediately push that post to all their followers' feed caches. Since it's a small number of followers, this is fast—maybe a few hundred cache writes.

For celebrities with 10,000+ followers, I'd use fan-out on read. When they post, we just store the post and index it. When a follower requests their feed, we pull the celebrity posts on demand and merge them with the pre-computed feed.

Transcript
Interviewer

"How do you store the feeds?"

You

"I'd use Redis sorted sets. The key is feed:{user_id}, and the values are post IDs with timestamps as scores. This gives us efficient:

  • Adding posts: ZADD — O(log N)
  • Getting feed: ZREVRANGE — O(log N + M) for M posts
  • Trimming old posts: ZREMRANGEBYRANK — O(log N + M)

For 500 million users, each with a feed of 500 posts (just IDs), that's 250 billion entries; at roughly 64 bytes each with sorted-set overhead, about 16 TB. That's a lot of RAM, but Redis Cluster can handle it with sharding.

I'd shard by user_id, so each user's feed is on one shard. This keeps feed reads fast—just one shard hit.

Transcript
Interviewer

"Walk me through what happens when I open Instagram."

You

"When you open the app:

  1. Fetch pre-computed feed: We read your feed from Redis—ZREVRANGE feed:{your_id} 0 49. This is instant, maybe 2ms.

  2. Check for celebrity follows: We look up which accounts you follow are celebrities. This is cached.

  3. Pull celebrity posts: For each celebrity you follow, we fetch their recent posts. These are stored separately in celebrity_posts:{user_id}. We do this in parallel—maybe 20 celebrity accounts, all fetched at once.

  4. Merge and rank: We combine your pre-computed feed with the celebrity posts. Instagram doesn't use pure chronological order—there's a ranking algorithm considering engagement, your affinity with the poster, content type, etc.

  5. Return top 50: We return the top-ranked posts for your initial feed load.

Total time: maybe 50-100ms, well under the 200ms target.

Transcript
Interviewer

"What happens if a celebrity you follow posts while you have the app open?"

You

"Good question. There are a few approaches:

The simplest is polling. The app periodically checks for new posts—maybe every 30-60 seconds. The server returns a count of new posts, and the user can tap 'New Posts' to refresh.

For more real-time updates, we could use push notifications via WebSocket. When a celebrity posts, we notify online followers. But we'd rate-limit this—maybe batch notifications if multiple posts come in quickly.

The client shows a subtle 'New posts' indicator rather than immediately inserting posts into the feed, which would be jarring.

Transcript
Interviewer

"How do you handle someone unfollowing an account?"

You

"When user A unfollows user B:

  1. Update the follow relationship in the database.

  2. If B is a regular user, remove B's posts from A's feed cache. We can do this by scanning A's feed and removing posts where user_id = B. Or we track which posts came from whom.

  3. If B is a celebrity, nothing to do—their posts were being pulled dynamically anyway. Next feed request just won't include B.

  4. Invalidate the cached 'following' list for A.

The deletion doesn't need to be instant—it's okay if A sees one more post from B before the cache updates. That's an acceptable trade-off for simplicity."

Summary#

DAY 4 KEY TAKEAWAYS
THE FEED PROBLEM
  • Personalized feeds for millions of users
  • Posts must appear in followers' feeds quickly
  • Celebrity problem: 1 post → millions of feed updates
TWO FUNDAMENTAL APPROACHES
Fan-Out on Write (Push) Fan-Out on Read (Pull)
How When user posts, push to all followers' feeds When user reads feed, compute on demand
Pros Fast reads (just read cache) Fast writes (just save post) · No celebrity problem
Cons Write amplification · Celebrity problem (millions of writes per post) Slow reads (must query many sources)
Use for Regular users with <10K followers Celebrities, algorithmic feeds
HYBRID APPROACH (Production)
  • Regular users: Push (fan-out on write)
  • Celebrities: Pull (fan-out on read)
  • Merge at read time
  • Best of both worlds
ADVANCED PATTERNS
  • Ranked feeds (not just chronological)
  • Sharded storage for scale
  • Feed warming for active users
  • Activity-based fan-out (skip dormant users)
KEY DECISIONS
  • Celebrity threshold: 10K-100K followers
  • Feed cache size: 500-1000 posts
  • Parallel fetching for pull queries
  • Trim old posts to bound storage
INDUSTRY EXAMPLES
  • Twitter: Mostly push, with pull for very large accounts
  • Instagram: Mostly push (fewer celebrities)
  • LinkedIn: Pull (2nd degree content)

Further Reading#

Engineering Blogs#

Papers#

  • Read

    "Feeding Frenzy: Selectively Materializing Users' Event Feeds" — SIGMOD

  • Read

    "The Anatomy of a Large-Scale Social Search Engine" — WWW

Books#

  • Book

    "Designing Data-Intensive Applications" by Martin Kleppmann — Chapter 1 (the Twitter home-timeline fan-out example)

this lesson in shorts · 1

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 04 · Feed Caching
Finish Day 4Stages clear as you read them (0/4 so far). Skipping ahead doesn't count: marking complete unlocks after ~9 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

X (Twitter)Talk · ~38 min

Timelines at Scale

Raffi Krikorian · 2013
What
Twitter's head of platform explains how home timelines were built.
Key idea
Tweets are fanned out on write into per-user timelines in Redis so reads are cheap; accounts with huge follower counts make that fan-out expensive.
Why here
The push model of this lesson in production, and the reason the hybrid approach exists.
taught here: Fan-out on write
LinkedInBlog post · ~20 min

FollowFeed: LinkedIn's Feed Made Faster and Smarter

Ankit Gupta, Swapnil Ghike, Shubham Gupta · 2016
What
LinkedIn's replacement for its Sensei-based feed infrastructure.
Key idea
FollowFeed builds a member's feed at request time by aggregating recent activity from the entities they follow, then ranks it, cutting p99 latency fivefold.
Why here
The pull model of this lesson at scale: indexes per author make fan-out on read fast enough for a dense network.
taught here: Fan-out on read
MetaPaper · ~40 min

TAO: Facebook's Distributed Data Store for the Social Graph

Nathan Bronson, Zach Amsden et al. · 2013
What
The paper on TAO, the read-optimised cache and store behind Facebook's social graph.
Key idea
Objects and associations are cached in leader and follower tiers in every region, serving a billion reads a second with eventual consistency.
Why here
The data layer under a feed: building a feed is mostly association lookups, and TAO caches exactly those.
taught here: Graph-aware caching

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…