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.
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.
π The Push Model (Event-Driven Streaming)
Analogy: The delivery courier rings your doorbell the exact millisecond the package drops on your porch.
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;
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 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:
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.
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.
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;
}
};