Loading learning content...
When you open Facebook, your feed loads in under a second. But consider what happens behind the scenes: the system must collect posts from your 350+ friends, hundreds of pages, dozens of groups, extract relevant ads, and bring them together for ranking—all before you can scroll.
This is the content aggregation problem, and it's one of the most challenging aspects of feed system design. The numbers are staggering:
The naive approach—query all friends' posts on demand—would take minutes. The alternative—precompute all feeds—would require impossible storage. Feed systems must find a middle path that balances computation, storage, and latency.
By the end of this page, you will understand the fan-out problem and its implications, master push vs pull aggregation strategies, learn hybrid approaches used at Facebook scale, and explore the distributed systems patterns that make content aggregation tractable.
Fan-out describes how many operations a single action triggers. In feed systems, we encounter two types of fan-out that create fundamentally different challenges:
Let's quantify these challenges with realistic numbers:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
# Fan-out on Write Analysis# ========================= # Average caseavg_followers = 350posts_per_user_per_day = 2total_users = 2_000_000_000 daily_posts = total_users * posts_per_user_per_day# = 4 billion posts/day daily_fanout_writes = daily_posts * avg_followers# = 4B * 350 = 1.4 trillion feed cache writes/day# = 16.2 million writes/second (average) # Celebrity case (problematic)celebrity_followers = 10_000_000celebrity_posts_per_day = 5 celebrity_fanout_per_post = celebrity_followers# = 10 million writes per single post# Must complete before post appears "live" # If each write takes 1ms, 10M writes = 167 minutes (unacceptable)# Need 10,000+ parallel writers to complete in 1 minute # Fan-out on Read Analysis# ======================== # Average caseavg_friends = 350avg_pages_followed = 100avg_groups = 20 feed_sources_per_user = avg_friends + avg_pages_followed + avg_groups# = 470 sources to query daily_feed_requests = 2_000_000_000 * 8 # 8 sessions/user/day# = 16 billion feed requests/day total_source_queries = daily_feed_requests * feed_sources_per_user# = 16B * 470 = 7.5 trillion queries/day# = 86.8 million queries/second # Each query taking 5ms = 470 * 5ms = 2.35 seconds per feed (unacceptable)# Need heavy parallelization or pre-aggregation| Dimension | Fan-out on Write | Fan-out on Read |
|---|---|---|
| Write Latency | High (must update many caches) | Low (single write) |
| Read Latency | Low (pre-computed feeds) | High (aggregate on demand) |
| Storage Cost | High (feed cached per user) | Low (posts stored once) |
| Freshness | Eventual (propagation delay) | Real-time (current data) |
| Consistency | Complex (distributed updates) | Simple (single source) |
| Celebrity Posts | Extremely expensive | Same as average user |
| Inactive Users | Wasteful (feed never read) | No waste |
Pure push fails for celebrities (10M writes per post is unsustainable). Pure pull fails for latency (470 queries per feed request is too slow). Real systems use hybrid approaches that combine benefits of both.
Facebook's solution is a hybrid push-pull model that uses different strategies based on user type and access patterns. The key insight: optimize for the common case while having fallbacks for edge cases.
The system classifies users based on their follower count and posting behavior:
| User Type | Follower Threshold | Write Strategy | Read Strategy |
|---|---|---|---|
| Regular User | < 10K followers | Push to all followers | Read from cache |
| Micro-celebrity | 10K - 100K | Push to active followers only | Hybrid cache + pull |
| Celebrity | 100K - 1M | Push to top 5K, index for rest | Pull from celebrity index |
| Mega-celebrity | 1M followers | No push, index only | Pure pull from index |
| Pages (Brands) | Varies | Push to highly engaged followers | Pull for casual followers |
The feed cache stores pre-aggregated content IDs (not full content) for each user. This dramatically reduces storage while enabling fast reads.
12345678910111213141516171819202122232425262728293031
// Feed Cache Entry (stored per user)interface FeedCacheEntry { userId: string; // Sorted list of post references (not full posts) posts: PostReference[]; // Metadata for cache management lastUpdated: timestamp; cacheVersion: number;} interface PostReference { postId: string; // Reference to actual post authorId: string; // For fast filtering createdAt: timestamp; // For time decay estimatedScore: number; // Pre-computed score (may be stale) contentType: ContentType; // For diversity filtering} // Storage calculationconst AVERAGE_POST_REFERENCES = 1500; // Posts per user cacheconst REFERENCE_SIZE_BYTES = 64; // Compact encodingconst USERS_TO_CACHE = 2_000_000_000; // All active users // Total storageconst feedCacheStorage = AVERAGE_POST_REFERENCES * REFERENCE_SIZE_BYTES * USERS_TO_CACHE;// = 1500 * 64 * 2B = 192 TB // This is feasible for distributed cache (Redis cluster)// Full post content would be: 1500 * 50KB * 2B = 150 EB (impossible)For high-follower accounts, posts are indexed centrally rather than pushed to followers.
123456789101112131415161718192021222324252627282930313233343536
// Celebrity Index Serviceinterface CelebrityIndex { // Sharded by celebrity ID for parallel access shardKey: string; // Recent posts per celebrity (rolling window) recentPosts: Map<CelebrityId, PostReference[]>; // Maximum posts stored per celebrity maxPostsPerCelebrity: 200; // ~7 days of posts} // Query pattern: Get posts from celebrities I followasync function getCelebrityPosts( userId: string, followedCelebrities: string[]): Promise<PostReference[]> { // Batch query across shards const shardQueries = groupByShards(followedCelebrities); const results = await Promise.all( shardQueries.map(async ([shard, celebIds]) => { return celebrityIndex.shard(shard).batchGet(celebIds); }) ); // Flatten and merge const allCelebrityPosts = results.flat(); // Sort by recency or estimated score return allCelebrityPosts.sort((a, b) => b.createdAt - a.createdAt);} // This turns O(N followers) writes into O(1) index update// At read time: O(K celebrities followed) queries (typically < 100)Approximately 0.1% of users (celebrities) cause 99% of fan-out problems. By handling this tiny fraction differently, the system becomes tractable. This pattern—special-casing outliers—appears throughout large-scale system design.
With content references aggregated, the system needs to efficiently retrieve full post content for rendering. This involves a multi-tier storage architecture optimized for different access patterns.
| Tier | Latency | Capacity | Cost | Typical Content |
|---|---|---|---|---|
| Hot (Memory) | < 1ms | ~50 TB | $$$$$ | Last hour's viral posts |
| Warm (SSD) | 1-5ms | ~10 PB | $$$ | Week's active content |
| Cold (HDD) | 10-50ms | ~500 PB | $$ | Historical posts |
| Archive | 100ms - minutes | Unlimited | $ | Old posts, rarely accessed |
Rather than wait for cache misses, the system proactively prefetches content likely to be needed.
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// Prefetch Managerclass ContentPrefetcher { // Strategy 1: Prefetch on aggregation // When building feed candidate list, prefetch content in parallel async onFeedRequest(userId: string): Promise<void> { const candidates = await getCandidatePostIds(userId); // Speculatively prefetch top candidates into hot cache const topCandidates = candidates.slice(0, 100); // Non-blocking prefetch (don't wait for completion) this.prefetchBatch(topCandidates); } // Strategy 2: Prefetch on trending // When post starts going viral, proactively cache async onTrendingDetected(postId: string): Promise<void> { const post = await coldStorage.get(postId); // Promote to all tiers await Promise.all([ hotCache.set(postId, post), warmCache.set(postId, post), ]); // Prefetch author's other recent posts (likely to be accessed) const authorPosts = await getAuthorRecentPosts(post.authorId, 10); this.prefetchBatch(authorPosts.map(p => p.id)); } // Strategy 3: Prefetch on session start // When user opens app, predict content needs based on patterns async onSessionStart(userId: string): Promise<void> { // Get user's typical engagement patterns const patterns = await getUserPatterns(userId); // Prefetch content from frequently engaged authors const topAuthors = patterns.topEngagedAuthors.slice(0, 20); for (const authorId of topAuthors) { const recentPosts = await getAuthorRecentPosts(authorId, 5); this.prefetchBatch(recentPosts.map(p => p.id)); } }}Feed caches store post IDs; full content must be 'hydrated' before serving.
12345678910111213141516171819202122232425262728293031323334353637
// Hydration Serviceinterface HydratedPost { id: string; author: UserProfile; // Hydrated from user service content: PostContent; // Text, media URLs, etc. engagement: EngagementCounts; // Like, comment, share counts socialContext: SocialContext; // Friends who engaged media: MediaAssets[]; // Image/video URLs from CDN} async function hydratePostBatch( postIds: string[], viewerId: string): Promise<HydratedPost[]> { // Parallel fetches from multiple services const [posts, authors, engagement, socialContext] = await Promise.all([ postStore.batchGet(postIds), userService.batchGetProfiles(getAuthorIds(postIds)), engagementService.batchGetCounts(postIds), socialService.getSocialContext(postIds, viewerId), ]); // Merge into hydrated posts return postIds.map(id => ({ id, author: authors.get(posts.get(id).authorId), content: posts.get(id).content, engagement: engagement.get(id), socialContext: socialContext.get(id), media: generateCDNUrls(posts.get(id).mediaIds), }));} // Batching is critical: 50 individual requests = ~500ms// Single batch request = ~50ms// Always batch across services!Content hydration often dominates feed latency. Each post requires data from 4-5 services (posts, users, engagement, social, media). Aggressive batching, parallel fetching, and caching at every layer are essential. Facebook uses custom RPC frameworks optimized for this pattern.
Content aggregation fundamentally depends on the social graph—the edges that connect users to friends, pages, and groups. This graph must support fast lookups while handling Facebook's massive scale.
123456789101112131415161718192021222324252627
// Social Graph Edgesinterface GraphEdge { sourceId: string; // User creating the edge destinationType: 'user' | 'page' | 'group'; targetId: string; // Friend, page, or group edgeType: EdgeType; // friend, follow, member, etc. // Edge metadata for feed ranking createdAt: timestamp; interactionCount: number; // Engagements on target's content lastInteraction: timestamp; relationship: RelationshipStrength; // close_friend, acquaintance, etc.} enum EdgeType { FRIEND = 'friend', // Bidirectional friendship FOLLOW = 'follow', // Unidirectional follow PAGE_LIKE = 'page_like', // Following a page GROUP_MEMBER = 'group_member', // Group membership BLOCKED = 'blocked', // Negative edge (filter content)} // Graph statistics// - 2 billion+ nodes (users)// - Average edges per user: ~500 (350 friends + pages + groups)// - Total edges: ~1 trillion// - Edge updates per second: ~1 million (friendships, follows)Facebook built TAO (The Associations and Objects) specifically for social graph access patterns.
123456789101112131415161718192021222324252627282930313233343536373839404142
// Common TAO query patterns for feed aggregation // Get all friends of a userconst friends = await tao.assocRange( userId, // Source node 'friend', // Edge type 0, // Offset 1000 // Limit); // Get all pages a user followsconst pages = await tao.assocRange( userId, 'page_like', 0, 500); // Get all groups a user is member ofconst groups = await tao.assocRange( userId, 'group_member', 0, 100); // Get mutual friends with another userconst mutualFriends = await tao.assocIntersect( userId, targetUserId, 'friend'); // Check if user is blockedconst isBlocked = await tao.assocGet( userId, targetUserId, 'blocked'); // These operations are O(1) or O(log N) due to indexing// Sub-millisecond latency from cache layerNot all connections are equal. The system computes relationship strength to prioritize content from close connections.
123456789101112131415161718192021222324252627282930313233343536373839
def compute_relationship_strength(user_id, connection_id): """ Compute affinity score between user and a connection. Higher score = closer relationship = higher feed priority. """ signals = {} # Explicit signals signals['is_close_friend'] = is_marked_close_friend(user_id, connection_id) signals['is_family'] = is_marked_family(user_id, connection_id) # Engagement signals (past 30 days) signals['comment_count'] = count_comments_on_connection(user_id, connection_id) signals['reaction_count'] = count_reactions_on_connection(user_id, connection_id) signals['message_count'] = count_messages_with_connection(user_id, connection_id) signals['profile_views'] = count_profile_views(user_id, connection_id) signals['tag_count'] = count_mutual_tags(user_id, connection_id) signals['photo_views'] = count_photo_album_views(user_id, connection_id) # Recency signals signals['days_since_last_interaction'] = days_since_interaction(user_id, connection_id) signals['days_since_friendship'] = days_since_connected(user_id, connection_id) # Network signals signals['mutual_friend_count'] = count_mutual_friends(user_id, connection_id) signals['mutual_group_count'] = count_mutual_groups(user_id, connection_id) # Weighted combination (weights learned from engagement data) weights = load_affinity_weights() score = sum( weights[signal] * value for signal, value in signals.items() ) # Normalize to 0-1 range return sigmoid(score) # This score is precomputed and stored with edge metadata# Updated periodically (daily) or on significant interactionsWith relationship strength precomputed, the aggregation step can quickly prune low-affinity connections. Instead of fetching posts from 500 friends, the system might only fetch from the top 100 by affinity, dramatically reducing query load while maintaining feed quality.
Putting it all together, the aggregation pipeline orchestrates multiple services across data centers to assemble feed candidates within latency budgets.
| Stage | Budget | Parallelism | Notes |
|---|---|---|---|
| Network (client → edge) | 50ms | Sequential | CDN/edge routing |
| Graph lookup | 30ms | Parallel | TAO cache hit |
| Feed cache read | 20ms | Parallel | Redis cluster |
| Celebrity index query | 40ms | Parallel | Sharded queries |
| Freshness query | 30ms | Parallel | Recent posts only |
| Candidate merging | 10ms | Sequential | In-memory |
| Ranking inference | 50ms | Sequential | ML model |
| Content hydration | 80ms | Parallel | Batch fetches |
| Serialization | 20ms | Sequential | Protobuf |
| Network (edge → client) | 50ms | Sequential | Response delivery |
| Total | ~380ms | With buffer for variability |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// Degradation controller for feed aggregationclass FeedDegradationController { async aggregateWithDegradation(userId: string): Promise<FeedResponse> { const startTime = Date.now(); const budget = 400; // ms before degradation let candidates: PostReference[] = []; let degradationFlags: string[] = []; // Stage 1: Try cache (always fast) try { const cached = await withTimeout(feedCache.get(userId), 50); candidates.push(...cached); } catch (e) { degradationFlags.push('cache_miss'); } // Stage 2: Social graph (critical path) let connections; try { connections = await withTimeout(tao.getConnections(userId), 50); } catch (e) { // Fall back to cached connections connections = await localCache.getConnections(userId); degradationFlags.push('stale_graph'); } // Stage 3: Celebrity content (non-critical) const remainingBudget = budget - (Date.now() - startTime); if (remainingBudget > 100) { try { const celebPosts = await withTimeout( celebrityIndex.getPosts(connections.celebrities), Math.min(remainingBudget / 2, 100) ); candidates.push(...celebPosts); } catch (e) { degradationFlags.push('no_celebrity_content'); } } // Stage 4: Ranking (fallback to heuristic) let rankedFeed; try { rankedFeed = await withTimeout(rankingService.rank(candidates), 80); } catch (e) { rankedFeed = heuristicRank(candidates); degradationFlags.push('heuristic_ranking'); } return { posts: rankedFeed, degraded: degradationFlags.length > 0, degradationFlags, }; }}A slightly worse feed that loads in 300ms is better than a perfect feed that times out. Design degradation paths that preserve user experience even when subsystems fail. Users tolerate stale data far better than loading spinners.
We've explored the infrastructure that aggregates content for feed generation. Let's consolidate the key concepts:
What's Next:
With content aggregated and ranked, the next page explores Real-time Updates—how the feed stays fresh with new content and engagement updates without requiring full page refreshes.
You now understand how content is aggregated from distributed sources for feed generation. The hybrid push-pull model, tiered caching, and graceful degradation patterns form the backbone of scalable feed infrastructure.