SystemDesign.website
Track 9 Β· Module 9.1System Design Trade-offs

Pull vs. Push Architectures & Fan-Out Systems

The core dichotomy of distributed data flow: Client Pull (Polling) vs Server Push (Streaming), Fan-Out-on-Write vs Fan-Out-on-Read, solving the Twitter Celebrity Problem with Hybrid Fan-Out, and backpressure management.

⏱️ Estimated Reading: 30 minsβ€’Prerequisites: Modules 2.1 & 6.2β€’Interactive Lab: Fan-Out & Celebrity Simulator
πŸ“¬ REAL-WORLD ANALOGY Β· NEWSPAPER VS DOORBELL

1. The First Principles of Data Flow: Who Initiates?

Every distributed communication pattern reduces to a simple fundamental question: Does the receiver ask for data when ready (Pull), or does the sender transmit data immediately upon creation (Push)?

πŸšΆβ€β™‚οΈ The Pull Model (Demand-Driven Polling)

Analogy: You walk out to your driveway every 60 seconds to check if the morning newspaper has arrived.

• Pros: Consumer controls pace; cannot be overwhelmed (built-in backpressure).
• Cons: High latency (up to polling interval); 99% of requests return empty 304s wasting CPU/network.

πŸ”” The Push Model (Event-Driven Streaming)

Analogy: The delivery courier rings your doorbell the exact millisecond the package drops on your porch.

• Pros: Real-time sub-millisecond delivery; zero wasted empty queries.
• Cons: Risk of consumer buffer overflow when sender produces faster than receiver consumes.
🐦 THE TWITTER / INSTAGRAM FEED DILEMMA

2. Fan-Out Models: How Social Timelines Are Built

When Alice posts a photo or tweet, how do her followers see it on their home feeds? There are two extreme approaches and one elegant hybrid solution:

Model 1: Fan-Out-on-Read (Pull / Lazy)

Write: When Alice posts, write 1 row to the tweets DB table. $O(1)$ fast write.

Read: When Bob opens his feed, query:

SELECT * FROM tweets 
WHERE author_id IN (SELECT followee_id FROM follows WHERE follower_id = 'bob')
ORDER BY created_at DESC LIMIT 20;
        
⚠️ Catastrophic Read Latency: If Bob follows 2,000 accounts, this query scans millions of rows on EVERY page refresh!

Model 2: Fan-Out-on-Write (Push / Eager)

Read: Bob has a pre-computed Redis timeline list (timeline:bob). Reading is an instantaneous $O(1)$ LRANGE 0 19 (~1ms).

Write: When Alice posts, a background worker looks up all of Alice's followers and inserts the tweet ID into every follower's Redis inbox.

⚠️ The Celebrity Bottleneck: What happens when Cristiano Ronaldo or Elon Musk (100M+ followers) posts?

πŸ’₯ The Celebrity Problem (Write Amplification Disaster)

If an account with 100,000,000 followers tweets in a pure Fan-Out-on-Write architecture:

  • The system must execute 100,000,000 Redis LPUSH operations for a single tweet.
  • Background Kafka worker queues back up by 15+ minutes.
  • Massive memory waste pushing tweets to inactive users who haven't opened the app in 6 months.

✨ The Solution: Hybrid Fan-Out Architecture

Twitter and Instagram solve this by segmenting users into two distinct tiers:

Standard Users (< 25,000 Followers)
Use Fan-Out-on-Write (Push). When they post, eagerly push into followers' Redis feeds. Fast and lightweight.
Celebrities (> 25,000 Followers)
Use Fan-Out-on-Read (Pull). Never fan out eagerly. When a user requests their feed, fetch the celebrity's latest tweets and merge in-memory!
Hybrid Fan-Out Architecture Data Flow
Alice (Standard)500 FollowersCelebrity (VIP)50M FollowersAsync Fan-Out QueueLPUSH to 500 Redis InboxesCelebrity Tweet StoreSingle Row Insert (No Fan-Out)Bob's Feed Service1. Fetch Redis Inbox (Alice)2. Pull Celebrity Tweets→ In-Memory K-Way Merge
πŸ§ͺ LIVE LOAD SIMULATOR

3. Interactive Fan-Out & Celebrity Load Simulator

Simulate posting a tweet with different follower counts. Compare how Pure Push, Pure Pull, and Hybrid Fan-Out perform in write load, read latency, and memory footprint.

Author Follower Count:100,000
Standard (100)Micro-Influencer (50K)Celebrity (5M)
Fan-Out Strategy:Hybrid Fan-Out
Write Amplification
1 ops
Celebrity bypasses fan-out queue.
Feed Read Latency
4.2 ms
Redis inbox + in-memory VIP merge.
Queue Backlog Delay
0.0 sec
Workers idle and responsive.
System Health Verdict
OPTIMAL
Zero bottlenecks detected.
πŸ’» PRODUCTION CODE PATTERNS

4. Hybrid Fan-Out Implementation in 5 Languages

Production implementation of a hybrid feed generator performing asynchronous queue dispatch for standard users and in-memory k-way merges for celebrity followers.

hybrid_feed_service
import heapq
from typing import List, Dict, Any

CELEBRITY_THRESHOLD = 25000

class HybridFeedService:
    def __init__(self, redis_client, db_client):
        self.redis = redis_client
        self.db = db_client

    def post_tweet(self, author_id: str, tweet_id: str, follower_count: int):
        # Save master record to relational/document DB
        self.db.save_tweet(author_id, tweet_id)

        if follower_count >= CELEBRITY_THRESHOLD:
            # VIP path: Do NOT fan out. Write only to celebrity's personal tweet list
            self.redis.lpush(f"celebrity_tweets:{author_id}", tweet_id)
        else:
            # Standard path: Enqueue async task to fan-out to all followers' Redis inboxes
            self.enqueue_fanout_task(author_id, tweet_id)

    def get_user_home_feed(self, user_id: str, followed_celebrity_ids: List[str], limit: int = 20) -> List[str]:
        # 1. Fetch pre-computed inbox from Redis (Standard users fan-out)
        inbox_tweets = self.redis.lrange(f"timeline:{user_id}", 0, limit)

        # 2. Fetch latest tweets for followed celebrities (Pull on read)
        celebrity_tweets = []
        for celeb_id in followed_celebrity_ids:
            celeb_recent = self.redis.lrange(f"celebrity_tweets:{celeb_id}", 0, limit)
            celebrity_tweets.extend(celeb_recent)

        # 3. In-memory k-way merge sorted by timestamp
        combined = heapq.nlargest(limit, inbox_tweets + celebrity_tweets, key=lambda t: t['timestamp'])
        return combined
        
interface Tweet {
  id: string;
  authorId: string;
  timestamp: number;
}

const CELEBRITY_THRESHOLD = 25_000;

export class HybridFeedService {
  async postTweet(authorId: string, tweet: Tweet, followerCount: number): Promise {
    await db.saveTweet(tweet);

    if (followerCount >= CELEBRITY_THRESHOLD) {
      // Lazy Pull: write only to celebrity list
      await redis.lpush(`celeb:${authorId}`, JSON.stringify(tweet));
    } else {
      // Eager Push: dispatch background fan-out job
      await queue.enqueue('fanout_job', { authorId, tweet });
    }
  }

  async getHomeFeed(userId: string, followedCelebs: string[], limit = 20): Promise {
    // 1. Fetch pre-computed standard user feed
    const rawInbox = await redis.lrange(`feed:${userId}`, 0, limit - 1);
    const standardTweets: Tweet[] = rawInbox.map(s => JSON.parse(s));

    // 2. Fetch followed celebrities
    const celebTweets: Tweet[] = [];
    for (const celebId of followedCelebs) {
      const raw = await redis.lrange(`celeb:${celebId}`, 0, limit - 1);
      celebTweets.push(...raw.map(s => JSON.parse(s)));
    }

    // 3. In-memory merge and sort
    return [...standardTweets, ...celebTweets]
      .sort((a, b) => b.timestamp - a.timestamp)
      .slice(0, limit);
  }
}
        
package main

import (
	"sort"
)

const CelebrityThreshold = 25000

type Tweet struct {
	ID        string `json:"id"`
	AuthorID  string `json:"author_id"`
	Timestamp int64  `json:"timestamp"`
}

type FeedService struct {
	redisClient RedisClient
}

func (s *FeedService) PostTweet(authorID string, tweet Tweet, followerCount int) {
	if followerCount >= CelebrityThreshold {
		// VIP: Write to celebrity timeline only
		s.redisClient.LPush("celeb:"+authorID, tweet)
	} else {
		// Normal: Async worker fan-out to followers
		go s.fanOutToFollowers(authorID, tweet)
	}
}

func (s *FeedService) GetFeed(userID string, followedCelebs []string, limit int) []Tweet {
	inbox := s.redisClient.LRange("feed:"+userID, 0, int64(limit-1))
	for _, celebID := range followedCelebs {
		celebTweets := s.redisClient.LRange("celeb:"+celebID, 0, int64(limit-1))
		inbox = append(inbox, celebTweets...)
	}

	sort.Slice(inbox, func(i, j int) bool {
		return inbox[i].Timestamp > inbox[j].Timestamp
	})

	if len(inbox) > limit {
		return inbox[:limit]
	}
	return inbox
}
        
import java.util.*;
import java.util.stream.Collectors;

public class HybridFeedService {
    private static final int CELEBRITY_THRESHOLD = 25_000;

    public record Tweet(String id, String authorId, long timestamp) {}

    public void postTweet(String authorId, Tweet tweet, int followerCount) {
        if (followerCount >= CELEBRITY_THRESHOLD) {
            // Lazy path: store in celebrity list
            redis.lpush("celeb:" + authorId, tweet);
        } else {
            // Eager path: queue fan-out
            taskQueue.enqueueFanout(authorId, tweet);
        }
    }

    public List<Tweet> getFeed(String userId, List<String> followedCelebs, int limit) {
        List<Tweet> feed = new ArrayList<>(redis.lrange("feed:" + userId, 0, limit - 1));

        for (String celebId : followedCelebs) {
            feed.addAll(redis.lrange("celeb:" + celebId, 0, limit - 1));
        }

        return feed.stream()
            .sorted(Comparator.comparingLong(Tweet::timestamp).reversed())
            .limit(limit)
            .collect(Collectors.toList());
    }
}
        
#include <string>
#include <vector>
#include <algorithm>

struct Tweet {
    std::string id;
    std::string author_id;
    int64_t timestamp;
};

const int CELEBRITY_THRESHOLD = 25000;

class HybridFeedService {
public:
    void post_tweet(const std::string& author_id, const Tweet& tweet, int follower_count) {
        if (follower_count >= CELEBRITY_THRESHOLD) {
            // VIP: Single Redis list insert
            redis_lpush("celeb:" + author_id, tweet);
        } else {
            // Eager fan-out to followers
            enqueue_fanout(author_id, tweet);
        }
    }

    std::vector<Tweet> get_feed(const std::string& user_id, const std::vector<std::string>& followed_celebs, size_t limit) {
        std::vector<Tweet> feed = redis_lrange("feed:" + user_id, 0, limit - 1);

        for (const auto& celeb_id : followed_celebs) {
            auto celeb_tweets = redis_lrange("celeb:" + celeb_id, 0, limit - 1);
            feed.insert(feed.end(), celeb_tweets.begin(), celeb_tweets.end());
        }

        std::sort(feed.begin(), feed.end(), [](const Tweet& a, const Tweet& b) {
            return a.timestamp > b.timestamp;
        });

        if (feed.size() > limit) feed.resize(limit);
        return feed;
    }
};
        
🎯 INTERVIEW SCENARIOS

5. Architectural Trade-off Quizzes

Scenario 1: You are designing an IoT sensor ingestion system where 100,000 temperature sensors emit readings every 5 seconds. The monitoring dashboard is viewed by 2 engineers once per hour. Should you use a Push or Pull architecture for timeline aggregation?

Scenario 2: Why does Prometheus use a **Pull** model (scraping `/metrics`) while StatsD uses a **Push** model (sending UDP packets)?

β—‹

Completed this module?

Mark as finished to update your curriculum progress tracker.