Loading learning content...
When a user opens Twitter, they expect to see a timeline—a chronologically or algorithmically ordered stream of tweets from accounts they follow. This seemingly simple feature hides a profound distributed systems challenge: How do you efficiently merge content from hundreds or thousands of sources for millions of concurrent users?
Consider user Alice, who follows 500 accounts:
The approach you choose for generating this timeline fundamentally shapes your entire system architecture. There are three primary strategies, each with distinct trade-offs that we'll explore in depth.
By the end of this page, you'll understand three feed generation approaches: Pull-based (fanout on read), Push-based (fanout on write), and Hybrid. You'll know how to evaluate trade-offs for each approach and understand why Twitter evolved toward a hybrid model.
In the pull-based model, timeline generation happens at read time. When Alice requests her timeline, the system:
This is called fanout on read because the 'fan-out' operation (spreading to multiple data sources) occurs when reading, not when writing.
For pull-based timeline generation, we need:
1. Social Graph Store
(follower_id, followee_id)1234567891011121314151617
-- Follow relationship tableCREATE TABLE follows ( follower_id BIGINT NOT NULL, followee_id BIGINT NOT NULL, created_at TIMESTAMP DEFAULT NOW(), PRIMARY KEY (follower_id, followee_id)); -- Index for efficient "who do I follow?" queriesCREATE INDEX idx_follower ON follows(follower_id); -- Index for "who follows me?" queries CREATE INDEX idx_followee ON follows(followee_id); -- Query Alice's following listSELECT followee_id FROM follows WHERE follower_id = :alice_id;2. Tweet Store (Per-User Index)
1234567891011121314151617
-- Tweets table with author indexCREATE TABLE tweets ( id BIGINT PRIMARY KEY, author_id BIGINT NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP NOT NULL, -- ... other fields); -- Critical index: tweets by author, sorted by timeCREATE INDEX idx_author_time ON tweets(author_id, created_at DESC); -- Query a user's recent tweetsSELECT * FROM tweets WHERE author_id = :user_id ORDER BY created_at DESC LIMIT 20;3. Merge Algorithm
With tweets fetched from all followed accounts, we need an efficient merge. The classic approach is a K-way merge using a min-heap:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
import heapqfrom dataclasses import dataclassfrom typing import List, Iterator @dataclassclass Tweet: id: str author_id: str content: str created_at: datetime def merge_timelines( tweet_sources: List[Iterator[Tweet]], limit: int = 50) -> List[Tweet]: """ K-way merge of tweet streams from multiple users. Each source is assumed to be sorted by created_at DESC. Time complexity: O(N * log K) where: - K = number of sources (followed users) - N = limit (tweets to return) """ # Min-heap entries: (negative_timestamp, source_index, tweet) # Negative timestamp for max-heap behavior (most recent first) heap = [] # Initialize heap with first tweet from each source for idx, source in enumerate(tweet_sources): try: tweet = next(source) # Use negative timestamp for max-heap behavior heapq.heappush(heap, (-tweet.created_at.timestamp(), idx, tweet)) except StopIteration: continue # Source is empty result = [] while heap and len(result) < limit: neg_ts, source_idx, tweet = heapq.heappop(heap) result.append(tweet) # Try to get next tweet from same source try: next_tweet = next(tweet_sources[source_idx]) heapq.heappush(heap, (-next_tweet.created_at.timestamp(), source_idx, next_tweet)) except StopIteration: continue # Source exhausted return resultPull-based works well for systems with: (1) low follower variance (everyone follows ~similar number), (2) infrequent timeline access, (3) strong freshness requirements. It's less suitable for Twitter's scale and access patterns but might work for enterprise social networks or small communities.
In the push-based model, timeline generation happens at write time. When Bob posts a tweet:
This is called fanout on write because the 'fan-out' operation occurs when writing the tweet, distributing it to all followers immediately.
1. Timeline Cache Structure
Each user has a pre-computed timeline stored in a fast cache (typically Redis):
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
"""Timeline Cache Design using Redis Sorted Sets Each user's timeline is a sorted set where:- Member = tweet_id- Score = timestamp (for chronological ordering) This allows:- O(log N) insertion of new tweets- O(log N + M) retrieval of M tweets from any position- Automatic ordering by timestamp- Efficient range queries for pagination""" import redisfrom typing import List, Tuple class TimelineCache: def __init__(self, redis_client: redis.Redis): self.redis = redis_client self.MAX_TIMELINE_SIZE = 800 # Keep last 800 tweets per user def push_tweet(self, user_id: str, tweet_id: str, timestamp: float): """ Add a tweet to user's pre-computed timeline. Called during fanout when someone they follow tweets. """ key = f"timeline:{user_id}" # Add tweet with timestamp as score self.redis.zadd(key, {tweet_id: timestamp}) # Trim to keep only most recent tweets (memory management) self.redis.zremrangebyrank(key, 0, -self.MAX_TIMELINE_SIZE - 1) def get_timeline( self, user_id: str, offset: int = 0, limit: int = 50 ) -> List[str]: """ Retrieve user's timeline. O(log N + limit) operation. No merge required—timeline is pre-computed! """ key = f"timeline:{user_id}" # Get tweet IDs in reverse chronological order tweet_ids = self.redis.zrevrange( key, offset, offset + limit - 1 ) return tweet_ids def remove_tweet(self, user_id: str, tweet_id: str): """ Remove a tweet (e.g., when author deletes it). Must be called for ALL followers—expensive operation. """ key = f"timeline:{user_id}" self.redis.zrem(key, tweet_id)2. Fanout Service
The fanout service is the heart of the push-based model. It must handle massive parallelism efficiently:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
"""Fanout Service: Distributes tweets to followers' timelines Key design considerations:1. Async processing - don't block tweet creation2. Parallelism - fan out to many followers concurrently3. Backpressure - handle celebrity tweets without overload4. Idempotency - safe to retry on failure""" import asynciofrom dataclasses import dataclassfrom typing import Listimport aioredis @dataclassclass FanoutJob: tweet_id: str author_id: str timestamp: float follower_ids: List[str] class FanoutService: def __init__( self, redis_pool: aioredis.Redis, batch_size: int = 1000, max_concurrent: int = 100 ): self.redis = redis_pool self.batch_size = batch_size self.semaphore = asyncio.Semaphore(max_concurrent) async def fanout_tweet(self, job: FanoutJob): """ Push tweet to all followers' timelines. Batches operations for efficiency. """ # Process followers in batches for i in range(0, len(job.follower_ids), self.batch_size): batch = job.follower_ids[i:i + self.batch_size] await self._process_batch(job, batch) async def _process_batch(self, job: FanoutJob, follower_ids: List[str]): """Process a batch of followers concurrently.""" async with self.semaphore: # Use Redis pipeline for batch efficiency pipe = self.redis.pipeline() for follower_id in follower_ids: key = f"timeline:{follower_id}" pipe.zadd(key, {job.tweet_id: job.timestamp}) # Trim to prevent unbounded growth pipe.zremrangebyrank(key, 0, -801) await pipe.execute() def estimate_fanout_time(self, follower_count: int) -> float: """ Estimate time to complete fanout. Used for monitoring and capacity planning. """ batches = (follower_count + self.batch_size - 1) // self.batch_size time_per_batch_ms = 10 # Empirical estimate return batches * time_per_batch_ms / 1000 # secondsThe push-based model has an Achilles heel: celebrities. Let's calculate what happens when Elon Musk (170 million followers) tweets:
Celebrity Tweet Fanout Analysis================================ Elon Musk's followers: 170,000,000Tweet data to push: ~100 bytes (tweet_id + metadata) Operations required:- 170,000,000 Redis ZADD operations- Even at 100,000 ops/second, this takes ~28 minutes Storage impact:- 170,000,000 × 100 bytes = 17 GB of cache updates- Each tweet from Elon = 17 GB of write amplification If Elon tweets 20 times per day:- 20 × 17 GB = 340 GB of daily writes just for Elon- 20 × 28 minutes = 9+ hours of fanout processing Problem cascade:1. Tweet appears delayed for many followers2. Fanout queue backs up3. Regular users' tweets also delayed4. System-wide latency spike This is unsustainable at scale.Push-based models suffer from severe write amplification. If your top 100 accounts average 10 million followers and each tweets 10 times daily, you're looking at 100 × 10M × 10 = 10 billion fanout operations per day—just for 100 accounts. This doesn't scale.
Neither pure pull nor pure push works at Twitter's scale. The solution? A hybrid approach that applies different strategies to different user segments.
The Key Insight:
Most users have a reasonable number of followers (under 10,000). Push-based works great for them. But a small percentage of users (celebrities, brands, news accounts) have millions of followers. Pull-based works better for them.
The Hybrid Strategy:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
"""Hybrid Timeline Service Combines push-based (for regular users) and pull-based (for celebrities)to optimize both read and write paths.""" from dataclasses import dataclassfrom typing import List, Setimport asyncio @dataclassclass UserClassification: REGULAR_THRESHOLD = 10_000 # Users with > 10K followers are celebrities @staticmethod def is_celebrity(follower_count: int) -> bool: return follower_count > UserClassification.REGULAR_THRESHOLD class HybridTimelineService: def __init__( self, timeline_cache, # Redis for pre-computed timelines tweet_store, # Primary tweet storage social_graph, # Follow relationships user_service # User metadata (follower counts) ): self.timeline_cache = timeline_cache self.tweet_store = tweet_store self.social_graph = social_graph self.user_service = user_service async def on_tweet_posted(self, tweet_id: str, author_id: str): """ Called when a user posts a tweet. Decides whether to fanout based on author's follower count. """ follower_count = await self.user_service.get_follower_count(author_id) if UserClassification.is_celebrity(follower_count): # Celebrity: store only, no fanout # Their tweets will be pulled at read time return {"fanout": False, "reason": "celebrity"} # Regular user: fanout to all followers followers = await self.social_graph.get_followers(author_id) await self._fanout_to_followers(tweet_id, followers) return {"fanout": True, "count": len(followers)} async def get_home_timeline( self, user_id: str, limit: int = 50 ) -> List[dict]: """ Assemble user's home timeline using hybrid approach. 1. Get pre-computed timeline (regular users' tweets) 2. Get list of followed celebrities 3. Pull celebrities' recent tweets 4. Merge and return """ # Step 1: Get pre-computed timeline from cache cached_tweet_ids = await self.timeline_cache.get_timeline( user_id, limit=limit * 2 # Fetch extra for merging ) # Step 2: Identify followed celebrities following = await self.social_graph.get_following(user_id) celebrity_ids = await self._filter_celebrities(following) # Step 3: Pull recent tweets from celebrities celebrity_tweets = await self._fetch_celebrity_tweets( celebrity_ids, limit=50 # Recent tweets from each celebrity ) # Step 4: Merge cached and pulled tweets all_tweet_ids = set(cached_tweet_ids) | set(celebrity_tweets) # Fetch full tweet objects tweets = await self.tweet_store.get_tweets(list(all_tweet_ids)) # Sort by timestamp and return top N sorted_tweets = sorted( tweets, key=lambda t: t['created_at'], reverse=True ) return sorted_tweets[:limit] async def _filter_celebrities( self, user_ids: List[str] ) -> List[str]: """Filter list to only celebrity accounts.""" celebrity_ids = [] # Batch lookup for efficiency follower_counts = await self.user_service.get_follower_counts_batch( user_ids ) for user_id, count in zip(user_ids, follower_counts): if UserClassification.is_celebrity(count): celebrity_ids.append(user_id) return celebrity_ids async def _fetch_celebrity_tweets( self, celebrity_ids: List[str], limit: int ) -> List[str]: """Fetch recent tweets from celebrity accounts.""" # Fetch in parallel for speed tasks = [ self.tweet_store.get_user_tweets(uid, limit=limit) for uid in celebrity_ids ] results = await asyncio.gather(*tasks) # Flatten results all_tweet_ids = [] for tweets in results: all_tweet_ids.extend([t['id'] for t in tweets]) return all_tweet_idsThe hybrid model still requires fetching celebrity tweets at read time. We can optimize this with aggressive caching:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
"""Celebrity Tweet Cache Since celebrity tweets are requested by millions of users, we cache themaggressively. The cache key is just the celebrity's user_id. This transforms N individual database queries into N cache lookups,dramatically reducing read latency.""" class CelebrityTweetCache: def __init__(self, redis_client, ttl_seconds: int = 60): self.redis = redis_client self.ttl = ttl_seconds async def get_recent_tweets( self, celebrity_id: str, limit: int = 50 ) -> List[str]: """ Get celebrity's recent tweets, with caching. Cache strategy: - TTL of 60 seconds (tweets are near-real-time) - Store as sorted set for efficient range queries - Refresh cache on miss """ cache_key = f"celebrity_tweets:{celebrity_id}" # Try cache first cached = await self.redis.zrevrange(cache_key, 0, limit - 1) if cached: return cached # Cache miss: fetch from database tweets = await self.tweet_store.get_user_tweets( celebrity_id, limit=100 # Cache more than needed ) # Populate cache if tweets: pipe = self.redis.pipeline() for tweet in tweets: pipe.zadd(cache_key, {tweet['id']: tweet['created_at']}) pipe.expire(cache_key, self.ttl) await pipe.execute() return [t['id'] for t in tweets[:limit]] async def invalidate_on_new_tweet(self, celebrity_id: str, tweet: dict): """ When celebrity posts, update their cache. Called synchronously after tweet storage. """ cache_key = f"celebrity_tweets:{celebrity_id}" # Add new tweet to cache (don't wait for TTL) await self.redis.zadd(cache_key, {tweet['id']: tweet['created_at']}) # Trim to maintain cache size await self.redis.zremrangebyrank(cache_key, 0, -101)A celebrity with 100M followers means 100M users might request their tweets. By caching celebrity tweets with a 60-second TTL, a single cache entry serves millions of requests. The cache hit ratio for celebrity tweets approaches 99.99%+.
Real-world systems apply additional optimizations beyond the basic hybrid model:
Not all users open Twitter equally often. We can optimize fanout based on user activity:
1234567891011121314151617181920212223242526272829303132333435363738394041424344
"""Active User Priority Fanout Instead of fanning out to ALL followers equally, prioritize active users.Inactive users' timelines can be lazily computed on their next login.""" class ActiveUserFanout: ACTIVE_THRESHOLD_DAYS = 7 # Users active in last 7 days async def smart_fanout( self, tweet_id: str, author_id: str, follower_ids: List[str] ): # Partition followers by activity active_followers = [] inactive_followers = [] activity_data = await self.user_service.get_last_active_batch( follower_ids ) now = datetime.utcnow() threshold = now - timedelta(days=self.ACTIVE_THRESHOLD_DAYS) for follower_id, last_active in zip(follower_ids, activity_data): if last_active > threshold: active_followers.append(follower_id) else: inactive_followers.append(follower_id) # Fanout immediately to active users await self._fanout_batch(tweet_id, active_followers) # Queue inactive users for background processing # (or skip entirely—rebuild timeline on next login) if len(inactive_followers) > 10000: # Too many inactive: skip fanout, rebuild on login await self._mark_for_rebuild(inactive_followers) else: # Manageable: slow background fanout await self._queue_background_fanout(tweet_id, inactive_followers)When a user logs in after inactivity, their cached timeline may be empty or stale. We need a strategy to rebuild it:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
"""Timeline Cache Warming When a user's timeline cache is cold (empty or stale), we need torebuild it. This happens on first login or after extended inactivity.""" class TimelineWarmer: async def ensure_timeline_ready(self, user_id: str) -> bool: """ Check if user's timeline cache is valid. If not, trigger rebuild. """ cache_key = f"timeline:{user_id}" cache_meta_key = f"timeline_meta:{user_id}" # Check cache freshness meta = await self.redis.hgetall(cache_meta_key) if not meta: # No timeline exists - full rebuild await self._full_timeline_rebuild(user_id) return True last_updated = datetime.fromisoformat(meta['last_updated']) gap_duration = datetime.utcnow() - last_updated if gap_duration > timedelta(hours=24): # Stale timeline - incremental update await self._incremental_timeline_update( user_id, since=last_updated ) return True async def _full_timeline_rebuild(self, user_id: str): """ Rebuild timeline from scratch. Uses pull-based approach for all followed accounts. """ following = await self.social_graph.get_following(user_id) # Fetch recent tweets from all followed accounts all_tweets = [] for followee_id in following: tweets = await self.tweet_store.get_user_tweets( followee_id, limit=20 # Recent tweets only ) all_tweets.extend(tweets) # Sort and take top 800 sorted_tweets = sorted( all_tweets, key=lambda t: t['created_at'], reverse=True )[:800] # Populate cache await self._populate_timeline_cache(user_id, sorted_tweets) async def _incremental_timeline_update( self, user_id: str, since: datetime ): """ Update timeline with tweets since last update. More efficient than full rebuild for short gaps. """ following = await self.social_graph.get_following(user_id) new_tweets = [] for followee_id in following: tweets = await self.tweet_store.get_user_tweets_since( followee_id, since=since ) new_tweets.extend(tweets) # Merge into existing cache for tweet in new_tweets: await self.timeline_cache.push_tweet( user_id, tweet['id'], tweet['created_at'] )The 10K follower threshold is just a starting point. Real systems use more nuanced segmentation:
| Segment | Follower Count | Strategy | Rationale |
|---|---|---|---|
| Regular | < 1,000 | Full push, immediate | Minimal fanout cost |
| Micro-influencer | 1K - 10K | Push to active users | Moderate fanout, optimize for active |
| Influencer | 10K - 100K | Push to engaged users | Fanout based on engagement history |
| Macro-influencer | 100K - 1M | Hybrid: push + pull | Heavy caching of their tweets |
| Celebrity | 1M | Pull only | Cache tweets, pull at read time |
These thresholds aren't static. Real systems continuously analyze fanout costs, read patterns, and system load to dynamically adjust thresholds. Machine learning models can predict optimal fanout strategies based on historical patterns.
Let's consolidate our understanding with a comparison framework to help you choose the right approach for your system:
| Criterion | Pull (Fanout on Read) | Push (Fanout on Write) | Hybrid |
|---|---|---|---|
| Read Latency | High (N queries) | Low (single lookup) | Low-Medium |
| Write Latency | Low (single insert) | High (N insertions) | Low-Medium |
| Storage Cost | Low (no duplication) | High (extreme duplication) | Medium |
| Celebrity Support | Excellent | Poor (system killer) | Excellent |
| Freshness | Guaranteed | Delayed during fanout | Good |
| Complexity | Simple | Moderate | High |
| Best For | Low traffic, high freshness | High read, uniform followers | Twitter-scale systems |
Use this framework when deciding on a feed generation approach:
In system design interviews, always start with the simpler approach (pull-based) and explain its limitations. Then introduce push-based and its celebrity problem. Finally, propose hybrid as the production solution. This demonstrates your ability to evolve a design based on constraints.
We've explored the fundamental architectural patterns for feed generation. Here's what you've learned:
What's Next:
Now that you understand the high-level approaches, the next page dives deeper into Fanout on Write vs Fanout on Read. We'll examine implementation details, message queue architectures for fanout, handling edge cases (deletes, blocks, protected accounts), and performance optimization techniques used by Twitter engineering.
You now understand the three fundamental approaches to feed generation and can evaluate trade-offs between them. You've seen implementation patterns for each approach and understand why hybrid architectures are necessary at Twitter-scale. Next, we'll deep-dive into the specific mechanisms of fanout strategies.