system-design64 min read

How to Design a Social Media Feed System — A Senior+ Guide | Ayodhyya

How to Design a Social Media Feed System

Building a Twitter/Instagram-Scale News Feed — Fan-out, Caching, Ranking, Real-time Delivery

Senior+ System Design Guide 10,000+ Words 20 Deep-Dive Sections C# · Mermaid · Real-World Case Studies

1. Introduction & Why Social Media Feed is Hard

Designing a social media feed system — the kind that powers Twitter, Instagram, Facebook, and TikTok — is one of the most classic and challenging problems in distributed systems engineering. At its surface, the concept appears deceptively simple: users create posts, and their followers see those posts in a personalized timeline. Under the hood, however, the system must handle billions of events per day, deliver content with sub-second latency, rank hundreds of candidate posts per request, and gracefully handle celebrity users whose single post can generate millions of fan-out events.

The social media feed problem is a cornerstone of system design interviews at every major tech company, from FAANG startups to mid-size platforms scaling their first million users. It touches virtually every distributed systems concept: consistent hashing, eventual consistency, write-ahead logs, message queues, caching hierarchies, graph databases, machine learning inference pipelines, and real-time streaming. As a senior or staff engineer, you are expected to navigate the full spectrum of these trade-offs while making pragmatic architectural choices that match your scale constraints.

Why This Problem Is Uniquely Challenging

The core tension in a feed system is the conflict between three competing objectives that cannot all be simultaneously maximized:

  • Low latency: Users expect their feed to load in under 200ms, even on mobile networks with high latency.
  • Freshness: When a user's friend posts something important, it should appear in their feed within seconds, not minutes.
  • Ranking quality: A chronological feed is trivial but results in poor engagement. A ranked feed requires expensive ML inference per request.

The "celebrity problem" is what truly separates feed systems from simpler content distribution systems. When a user with 100 million followers posts a tweet, a naive fan-out-on-write approach would attempt to write that post into 100 million timeline caches simultaneously. This single operation would generate more write load than most database clusters can handle in a day. The solution requires a hybrid approach that balances precomputation with on-demand assembly, and this hybrid is the heart of every major social media feed architecture.

Key Insight: Twitter's original architecture used pure fan-out-on-write, which worked until they hit the celebrity scaling wall around 2010. They then migrated to a hybrid model where normal users get fan-out-on-write and celebrity accounts use fan-out-on-read at query time. Instagram uses a similar hybrid but with more aggressive caching and a heavier reliance on ranked feeds to reduce the total number of posts any user needs to see.

The Real-World Scale

Consider the numbers that define the problem space at Twitter-scale: approximately 400 million daily active users, 500 million tweets posted per day, 1.2 billion timeline requests per day, and an average of 200 milliseconds to serve a timeline. Every design decision — from the choice of storage engine to the caching topology — must be evaluated against these concrete throughput and latency requirements.

Instagram faces a similar challenge but with a media-heavy twist: every post contains at least one image or video, which means the feed system must seamlessly integrate with a content delivery network and media processing pipeline that can handle petabytes of image uploads daily. TikTok adds another dimension with its algorithm-first approach, where the feed is entirely ranked by engagement prediction models, making the ML inference pipeline a critical path component rather than an enhancement.

What Makes a Great Feed System Design

A great social media feed system design demonstrates mastery of several engineering principles: choosing the right consistency model for each component, designing for graceful degradation under load, building observability into every layer, and understanding the economic trade-offs of every architectural decision. The best designs also acknowledge what they choose not to do — the scope boundaries that keep the system manageable as it scales from millions to billions of users.

This guide walks through every major component of a social media feed system, from the data model that stores follows and posts, through the fan-out engines that precompute timelines, to the ranking models that determine what users actually see. Each section includes production-grade C# code, Mermaid architecture diagrams, and real-world lessons learned from companies operating at massive scale.

2. Functional & Non-Functional Requirements

Functional Requirements

The functional requirements define what the system must do from the user's perspective. For a social media feed system, these requirements break down into several distinct capability areas that must work together seamlessly.

Capability Description Priority
Create Post Users can create text posts, posts with images, posts with videos, and posts with link previews. Maximum 280 characters for text-only posts. P0
View Feed Users see a ranked timeline of posts from accounts they follow, plus recommended content. Feed must load in under 200ms. P0
Follow/Unfollow Users can follow and unfollow other accounts. Follow relationships affect what appears in the user's feed. P0
Like/Retweet/Reply Users can interact with posts via likes, retweets, and replies. Interactions are visible to the post author and affect ranking. P0
Real-time Updates New posts from followed accounts appear in the feed within 5 seconds without requiring a page refresh. P1
Trending Topics Users can view currently trending hashtags and topics, updated every few minutes. P1
Search Users can search for posts, users, and hashtags with full-text search capabilities. P1
Notifications Users receive notifications for likes, follows, replies, mentions, and trending posts from followed accounts. P1
Content Moderation Automated and manual moderation to remove spam, abuse, and policy-violating content. P1
Media Processing Image resizing, video transcoding, thumbnail generation, and content delivery via CDN. P1

Non-Functional Requirements

The non-functional requirements define the quality attributes that the system must satisfy. These requirements often drive the architectural decisions more than the functional requirements, because they define the constraints within which all design choices must be made.

Attribute Target Rationale
Availability 99.99% (52 min downtime/year) Social media is a global utility; downtime directly impacts revenue and user trust.
Latency (Feed) P50 < 100ms, P99 < 300ms Users abandon feeds that take more than 1 second to load. Mobile users are especially latency-sensitive.
Latency (Post Creation) P95 < 500ms Post creation can tolerate slightly higher latency since users expect a brief processing delay.
Throughput 600K feed reads/sec, 10K posts/sec Based on 500M DAU with an average of 5 feed loads per day and 1 post per user per day.
Consistency Eventual consistency (5s staleness) Feed systems can tolerate slight staleness. Strong consistency is only needed for follow/unfollow operations.
Durability 99.999999999% (11 nines) Posts are user-generated content and must never be lost. Use multi-region replication.
Scalability 2x growth/year for 3 years Architecture must accommodate rapid user growth without rewrites.
Important: In a system design interview, always clarify whether the feed should be chronological or ranked. This single decision dramatically changes the architecture. A chronological feed can use simple precomputation, while a ranked feed requires an ML inference pipeline that adds significant latency and complexity to the read path.

Scope Boundaries

A critical aspect of any system design is clearly defining what is out of scope. For this design, we explicitly exclude: direct messaging (a separate real-time system), live streaming (requires dedicated media infrastructure), advertising delivery (a complex auction and targeting system), and content recommendation for new users (a cold-start ML problem). These are all important subsystems that would each warrant their own deep-dive design document.

3. Capacity Estimation & Back-of-Envelope

Capacity estimation grounds the design in concrete numbers and prevents over-engineering. Every architectural choice should trace back to a specific throughput or storage requirement. We begin by establishing baseline traffic assumptions and then derive storage, bandwidth, and compute requirements from those baselines.

Assumptions

Metric Value Notes
Daily Active Users (DAU) 300 million Approximately 50% of registered users
Posts Created Per Day 500 million Average 1.67 posts per active user per day
Average Follows Per User 200 Median is closer to 100, but heavy users pull the average up
Average Followers Per User 200 Highly skewed: top 1% of users have 80% of followers
Feed Reads Per User Per Day 10 Includes app opens, pull-to-refresh, and background refreshes
Average Post Size 300 bytes (metadata) + 500 bytes (text) = 800 bytes Excluding media, which is stored separately

Throughput Calculations

Feed reads represent the dominant workload. With 300 million DAU making 10 feed requests per day, we need to serve 3 billion feed reads per day. That translates to approximately 35,000 feed reads per second at the average, with peak traffic reaching 3x that at roughly 100,000 reads per second during evening hours in major time zones. Each feed request returns approximately 50-100 posts, meaning we must assemble and rank up to 10 million post objects per second at peak.

Post creation generates approximately 500 million posts per day, or about 5,800 posts per second on average. Peak post creation occurs around lunchtime and evening hours, reaching approximately 15,000 posts per second. Each post creation triggers a fan-out operation that must be processed within seconds, so the fan-out pipeline must handle bursts of 15,000 posts per second multiplied by the average fan-out count.

Storage Calculations

Post metadata (excluding media) requires approximately 800 bytes per post. With 500 million posts per day, that is 400 GB per day or approximately 146 TB per year of post data alone. The follow graph, assuming an average of 200 follows per user across 600 million users, stores 120 billion follow edges at approximately 32 bytes per edge, totaling about 3.8 TB. Timeline caches, storing the precomputed timeline of 50 post IDs per user for 300 million active users, require approximately 480 GB. With replication factor of 3 for durability, total cache storage reaches 1.44 TB.

C#
public class CapacityEstimator
{
    public static FeedCapacity Estimate(PlatformConfig config)
    {
        var dailyFeedReads = config.DailyActiveUsers * config.FeedReadsPerUserPerDay;
        var avgFeedReadsPerSecond = dailyFeedReads / 86400.0;
        var peakFeedReadsPerSecond = avgFeedReadsPerSecond * config.PeakMultiplier;

        var dailyPosts = config.DailyActiveUsers * config.AvgPostsPerUserPerDay;
        var avgPostsPerSecond = dailyPosts / 86400.0;
        var peakPostsPerSecond = avgPostsPerSecond * config.PeakMultiplier;

        var postsPerFeed = 50;
        var candidatePoolMultiplier = 10.0;
        var candidatesPerFeedRequest = postsPerFeed * candidatePoolMultiplier;

        var postsToRankPerSecond = peakFeedReadsPerSecond * candidatesPerFeedRequest;

        var postMetadataBytes = 800;
        var storagePerDayBytes = dailyPosts * postMetadataBytes;
        var storagePerYearBytes = storagePerDayBytes * 365;

        return new FeedCapacity
        {
            AvgFeedReadsPerSecond = (long)avgFeedReadsPerSecond,
            PeakFeedReadsPerSecond = (long)peakFeedReadsPerSecond,
            AvgPostsPerSecond = (long)avgPostsPerSecond,
            PeakPostsPerSecond = (long)peakPostsPerSecond,
            PostsToRankPerSecond = (long)postsToRankPerSecond,
            DailyStorageBytes = storagePerDayBytes,
            YearlyStorageBytes = storagePerYearBytes,
            StoragePerYearTB = storagePerYearBytes / (1024.0 * 1024.0 * 1024.0 * 1024.0)
        };
    }
}

Bandwidth Calculations

Inbound bandwidth for post creation is relatively modest: 15,000 posts per second at 800 bytes each equals approximately 12 MB/s. However, media uploads dominate: if 40% of posts include an average 2 MB image, that is 6,000 image uploads per second at 2 MB each, totaling 12 GB/s of inbound media bandwidth. Outbound bandwidth for feed serving is more significant: 100,000 feed requests per second, each returning approximately 40 KB of metadata plus 10 media URLs (each resolving to a 200 KB thumbnail via CDN), totals approximately 24 GB/s of CDN-served content at peak.

Sanity Check: If your numbers feel unreasonable, compare them against real-world benchmarks. Twitter publishes some of its infrastructure metrics, and Netflix's tech blog regularly shares CDN bandwidth numbers. Your estimates should be within an order of magnitude of published figures.

Cost Implications

At 146 TB/year of raw post storage with 3x replication, we need approximately 438 TB/year of database storage. Using cloud storage at roughly $0.023/GB/month for standard storage, that is approximately $121,000/month or $1.45 million/year just for post data storage. Adding timeline caches, social graph storage, and search indexes pushes total storage costs to approximately $3-5 million per year. Compute costs for the ranking pipeline, fan-out workers, and API servers typically run 3-5x storage costs, putting total infrastructure spend in the $15-25 million per year range. These numbers align with what companies like Twitter and Pinterest have publicly disclosed about their infrastructure budgets.

4. Data Model & Storage Schema

The data model is the foundation of the entire system. Every query pattern, every cache key, and every index must be designed to support the specific access patterns of a social media feed. The two dominant data models in this space are the relational model (used by early Twitter) and the graph-native model (used by Facebook). Most modern systems use a hybrid, combining relational stores for transactional data with specialized stores for different query patterns.

Core Entities

C#
public class User
{
    public long UserId { get; set; }
    public string Username { get; set; }
    public string DisplayName { get; set; }
    public string Email { get; set; }
    public string ProfileImageUrl { get; set; }
    public string Bio { get; set; }
    public int FollowerCount { get; set; }
    public int FollowingCount { get; set; }
    public int PostCount { get; set; }
    public bool IsVerified { get; set; }
    public bool IsPrivate { get; set; }
    public UserTier Tier { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime LastActiveAt { get; set; }
}

public enum UserTier
{
    Normal = 0,
    Power = 1,
    Celebrity = 2
}

public class Post
{
    public long PostId { get; set; }
    public long AuthorId { get; set; }
    public string Content { get; set; }
    public PostType Type { get; set; }
    public long? ReplyToPostId { get; set; }
    public long? RepostOfPostId { get; set; }
    public List<MediaAttachment> Media { get; set; }
    public List<string> Hashtags { get; set; }
    public List<long> MentionedUserIds { get; set; }
    public int LikeCount { get; set; }
    public int RetweetCount { get; set; }
    public int ReplyCount { get; set; }
    public int ViewCount { get; set; }
    public DateTime CreatedAt { get; set; }
    public ContentVisibility Visibility { get; set; }
    public double? RankingScore { get; set; }
}

public enum PostType
{
    Original = 0,
    Reply = 1,
    Repost = 2,
    QuoteRepost = 3
}

public enum ContentVisibility
{
    Public = 0,
    FollowersOnly = 1,
    MentionedOnly = 2,
    Private = 3
}

public class MediaAttachment
{
    public long MediaId { get; set; }
    public MediaType Type { get; set; }
    public string OriginalUrl { get; set; }
    public string ThumbnailUrl { get; set; }
    public string MediumUrl { get; set; }
    public int Width { get; set; }
    public int Height { get; set; }
    public int DurationMs { get; set; }
}

public enum MediaType
{
    Image = 0,
    Video = 1,
    Gif = 2
}

Social Graph Model

C#
public class FollowRelationship
{
    public long FollowerId { get; set; }
    public long FolloweeId { get; set; }
    public DateTime FollowedAt { get; set; }
    public FollowStatus Status { get; set; }
    public bool Muted { get; set; }
    public bool NotificationsEnabled { get; set; }
}

public enum FollowStatus
{
    Active = 0,
    Blocked = 1,
    Muted = 2
}

public class TimelineEntry
{
    public long UserId { get; set; }
    public long PostId { get; set; }
    public long AuthorId { get; set; }
    public DateTime PublishedAt { get; set; }
    public double RankingScore { get; set; }
    public TimelineSource Source { get; set; }
}

public enum TimelineSource
{
    Follow = 0,
    Recommended = 1,
    Trending = 2,
    Promoted = 3
}

Storage Choices

graph TB subgraph "Storage Architecture" subgraph "Primary Data Stores" PG[(PostgreSQL Cluster - Users, Posts, Follows)] Redis[(Redis Cluster - Timelines, Counters, Sessions)] ES[(Elasticsearch - Search, Hashtags)] end subgraph "Specialized Stores" GDB[(Neo4j - Social Graph Queries)] S3[(Object Storage S3 - Media Files)] Kafka[(Kafka - Event Stream)] end end PG -->|"CDC Stream"| Kafka Kafka -->|"Index Sync"| ES S3 -->|"CDN Origin"| CDN[(CDN Edge)]

The storage architecture uses PostgreSQL as the primary transactional store for user profiles, posts, and follow relationships. PostgreSQL is chosen for its strong consistency guarantees, support for complex queries, and mature replication story. Redis serves as the caching and precomputation layer for timelines, view counters, and follower counts. Elasticsearch powers full-text search across posts and hashtags. A graph database like Neo4j or JanusGraph handles complex social graph queries such as "friends of friends" and "who you may know" recommendations. Object storage (S3) holds all media files, fronted by a CDN for global distribution.

Sharding Strategy: For the PostgreSQL cluster, shard by user ID using consistent hashing. Posts are sharded by author ID, which means all posts by a single user live on the same shard. Follow relationships are sharded by follower ID, so fetching a user's follow list is a single-shard query. Timeline caches in Redis are sharded by user ID. This sharding scheme ensures that the most common query patterns — fetching a user's posts, fetching a user's follows, and fetching a user's timeline — are all single-shard operations.

Schema Design for Key Tables

The posts table uses a composite primary key of (author_id, post_id) to cluster posts by author on disk, which optimizes the "show me all posts by this user" query. A secondary index on post_id supports direct post lookups. The follows table uses a composite primary key of (follower_id, followee_id) to optimize the "who does this user follow" query, with a secondary index on (followee_id, follower_id) to support the reverse lookup "who follows this user." The timeline table is a Redis sorted set keyed by user_id, with the score being the post timestamp or ranking score and the value being the post_id. This allows efficient range queries for pagination.

5. High-Level Architecture Overview

The high-level architecture of a social media feed system comprises five major subsystems: the API Gateway layer that handles authentication and request routing, the Post Service that manages content creation and storage, the Fan-out Service that propagates posts to follower timelines, the Feed Service that assembles and ranks timelines on read, and the Media Service that handles upload, processing, and delivery of images and videos. These subsystems communicate through a combination of synchronous REST/gRPC APIs for user-facing operations and asynchronous event streams via Kafka for background processing.

graph TB Client[Mobile / Web Client] -->|HTTPS| LB[Load Balancer] LB -->|Route| AG[API Gateway] AG -->|Auth + Rate Limit| PS[Post Service] AG -->|Auth + Rate Limit| FS[Feed Service] AG -->|Auth + Rate Limit| US[User Service] AG -->|Auth + Rate Limit| NS[Notification Service] PS -->|Publish Event| Kafka[(Kafka Event Stream)] Kafka -->|Fan-out Worker| FO[Fan-out Service] Kafka -->|Index Worker| ES[Elasticsearch] Kafka -->|Trend Worker| TS[Trending Service] Kafka -->|Notif Worker| NS FO -->|Write Timelines| Redis[(Redis Timeline Cache)] FO -->|Persist| PG[(PostgreSQL)] FS -->|Read Timeline| Redis FS -->|Enrich Posts| PG FS -->|Rank| ML[ML Ranking Service] MS[Media Service] -->|Upload| S3[(Object Storage)] S3 -->|CDN| CDN[CDN Edge Nodes] US -->|User Data| PG US -->|Cache| Redis

Request Flow: Viewing a Feed

When a user opens their app and requests their feed, the following sequence unfolds in approximately 100-200 milliseconds: The client sends a GET request to the API Gateway, which authenticates the user via a session token stored in Redis and applies rate limiting. The request is routed to the Feed Service, which first checks if a precomputed timeline exists in Redis for this user. If it does, the Feed Service retrieves the top 100 post IDs from the sorted set. If the precomputed timeline is stale or missing, the Feed Service falls back to an on-demand assembly by querying the follow graph and merging recent posts from followed users.

With the post IDs in hand, the Feed Service performs a batch fetch of full post objects from PostgreSQL or a post cache. The batch fetch is critical for performance — fetching 100 posts individually would require 100 round trips, but a single multi-get operation retrieves them all in one network hop. The posts are then passed to the ML Ranking Service, which scores each post based on engagement predictions, recency, and user preferences. The ranked posts are returned to the client along with pre-signed CDN URLs for any media attachments.

Request Flow: Creating a Post

When a user creates a post, the client sends a POST request with the text content and any media references. The Post Service validates the content (character count, banned words, rate limits), persists the post to PostgreSQL, and publishes a PostCreated event to Kafka. The event triggers multiple parallel processing pipelines: the Fan-out Service writes the post ID into the timelines of all followers (for non-celebrity users), the Trending Service updates hashtag and topic counters, the Notification Service sends push notifications to mentioned users, and the Media Service processes any attached images or videos.

Service Responsibilities

Service Responsibility Storage Latency Target
API Gateway Authentication, rate limiting, request routing, TLS termination Redis (sessions) < 5ms
Post Service CRUD for posts, content validation, event publishing PostgreSQL, Redis cache < 50ms
Feed Service Timeline assembly, post enrichment, ranking, pagination Redis (timelines), PostgreSQL (posts) < 150ms
Fan-out Service Post propagation to follower timelines, fan-out orchestration Redis (timelines), Kafka (events) < 2s (async)
User Service User profiles, follow/unfollow, user search PostgreSQL, Redis, Elasticsearch < 50ms
Media Service Image/video upload, transcoding, thumbnail generation, CDN management S3, FFmpeg workers < 2s (async)
Notification Service Push notifications, in-app notifications, email digests PostgreSQL, FCM/APNs < 5s (async)
ML Ranking Service Feed ranking, content recommendations, engagement prediction Feature store, model registry < 50ms
Trending Service Hashtag counting, topic detection, trending score calculation Redis (counters), PostgreSQL (trends) < 5min (async)
Moderation Service Spam detection, content classification, abuse reporting Kafka, ML models, review queue < 30s (async)
Design Principle: Each service owns its data and exposes it only through well-defined APIs. No service directly queries another service's database. This isolation enables independent scaling, technology choices, and deployment cycles. The trade-off is the need for data duplication and eventual consistency, which we manage through event-driven synchronization via Kafka.

6. Fan-out on Write vs Fan-out on Read

The fan-out strategy is the single most consequential architectural decision in a social media feed system. It determines how posts propagate from authors to their followers' timelines, and it directly impacts write amplification, read latency, storage costs, and system complexity. Understanding the trade-offs between fan-out-on-write, fan-out-on-read, and the hybrid approach used by production systems is essential for any engineer designing a feed system.

Fan-out on Write

In fan-out-on-write (also called push-based), when a user publishes a post, the system immediately writes the post ID into the timeline cache of every follower. When a follower requests their feed, the system simply reads their precomputed timeline from the cache — a fast, simple O(1) operation. The write path is expensive (amplified by the number of followers), but the read path is extremely cheap.

C#
public class FanoutOnWriteService
{
    private readonly IFollowRepository _followRepo;
    private readonly ITimelineCache _timelineCache;
    private readonly IPostRepository _postRepo;
    private readonly ILogger<FanoutOnWriteService> _logger;

    public async Task FanoutPostAsync(Post post)
    {
        var followerIds = await _followRepo.GetFollowerIdsAsync(post.AuthorId);

        if (followerIds.Count > 10_000)
        {
            await HandleCelebrityPostAsync(post, followerIds);
            return;
        }

        var timelineEntry = new TimelineEntry
        {
            PostId = post.PostId,
            AuthorId = post.AuthorId,
            PublishedAt = post.CreatedAt,
            Source = TimelineSource.Follow
        };

        var batchSize = 1000;
        var batches = followerIds.Chunk(batchSize);

        foreach (var batch in batches)
        {
            var tasks = batch.Select(followerId =>
                _timelineCache.AppendToTimelineAsync(followerId, timelineEntry));

            await Task.WhenAll(tasks);
        }

        _logger.LogInformation(
            "Fan-out complete for post {PostId}: {Count} timelines updated",
            post.PostId, followerIds.Count);
    }

    private async Task HandleCelebrityPostAsync(Post post, List<long> followerIds)
    {
        await _timelineCache.StorePostDirectlyAsync(post.PostId, post.AuthorId);
        _logger.LogInformation(
            "Celebrity post {PostId} stored for fan-out-on-read ({Count} followers)",
            post.PostId, followerIds.Count);
    }
}

Fan-out on Read

In fan-out-on-read (also called pull-based), posts are stored only with their author. When a user requests their feed, the system queries the recent posts of every user they follow and merges them into a sorted timeline on the fly. The write path is simple (one write per post), but the read path is expensive (must query N users and merge N result sets, where N is the number of accounts the user follows).

C#
public class FanoutOnReadService
{
    private readonly IFollowRepository _followRepo;
    private readonly IPostRepository _postRepo;
    private readonly IMLRankingService _rankingService;

    public async Task<FeedResponse> GetFeedAsync(long userId, int limit, string cursor)
    {
        var followingIds = await _followRepo.GetFollowingIdsAsync(userId);

        var candidateTasks = followingIds.Select(followingId =>
            _postRepo.GetRecentPostsAsync(followingId, limit: 50));

        var authorPosts = await Task.WhenAll(candidateTasks);

        var allCandidates = authorPosts
            .SelectMany(posts => posts)
            .Where(p => p.Visibility == ContentVisibility.Public ||
                       p.Visibility == ContentVisibility.FollowersOnly)
            .ToList();

        var rankedCandidates = await _rankingService.RankPostsAsync(
            userId, allCandidates, limit);

        var feedPosts = rankedCandidates
            .OrderByDescending(p => p.RankingScore ?? p.CreatedAt.Ticks)
            .Take(limit)
            .ToList();

        return new FeedResponse
        {
            Posts = feedPosts,
            HasMore = feedPosts.Count == limit,
            NextCursor = feedPosts.LastOrDefault()?.PostId.ToString()
        };
    }
}

The Hybrid Approach

Production systems like Twitter, Instagram, and Facebook use a hybrid approach that combines the strengths of both strategies while mitigating their weaknesses. The hybrid model works as follows: for users with fewer than 10,000 followers (the vast majority of users), use fan-out-on-write. When a normal user posts, immediately write the post ID into the timelines of all their followers. For users with more than 10,000 followers (celebrities, brands, news accounts), use fan-out-on-read. When a celebrity posts, do not fan out to anyone. Instead, at feed read time, the system checks if any followed celebrities have posted recently and merges those posts into the precomputed timeline.

C#
public class HybridFanoutService
{
    private const long CelebrityThreshold = 10_000;

    private readonly IFollowRepository _followRepo;
    private readonly ITimelineCache _timelineCache;
    private readonly IPostRepository _postRepo;
    private readonly ICelebrityRegistry _celebrityRegistry;

    public async Task<FeedResponse> GetFeedAsync(long userId, int limit, string cursor)
    {
        var precomputedTimeline = await _timelineCache
            .GetTimelineAsync(userId, 0, limit * 2);

        var celebrityPosts = await GetCelebrityPostsAsync(userId, limit);
        var allCandidates = MergeTimelines(precomputedTimeline, celebrityPosts);

        return new FeedResponse
        {
            Posts = allCandidates.Take(limit).ToList(),
            HasMore = allCandidates.Count > limit,
            NextCursor = allCandidates.Skip(limit).FirstOrDefault()?.PostId.ToString()
        };
    }

    private async Task<List<TimelineEntry>> GetCelebrityPostsAsync(
        long userId, int limit)
    {
        var followingIds = await _followRepo.GetFollowingIdsAsync(userId);
        var celebrityIds = followingIds
            .Where(id => _celebrityRegistry.IsCelebrity(id))
            .ToList();

        if (!celebrityIds.Any())
            return new List<TimelineEntry>();

        var since = DateTime.UtcNow.AddHours(-24);
        var tasks = celebrityIds.Select(id =>
            _postRepo.GetPostsSinceAsync(id, since, limit: 20));
        var results = await Task.WhenAll(tasks);

        return results
            .SelectMany(posts => posts.Select(p => new TimelineEntry
            {
                PostId = p.PostId,
                AuthorId = p.AuthorId,
                PublishedAt = p.CreatedAt,
                Source = TimelineSource.Follow
            }))
            .OrderByDescending(e => e.PublishedAt)
            .ToList();
    }

    private List<TimelineEntry> MergeTimelines(
        List<TimelineEntry> precomputed,
        List<TimelineEntry> celebrity)
    {
        var merged = new List<TimelineEntry>(precomputed.Count + celebrity.Count);
        merged.AddRange(precomputed);
        merged.AddRange(celebrity);
        return merged.DistinctBy(e => e.PostId)
                     .OrderByDescending(e => e.PublishedAt)
                     .ToList();
    }
}
Strategy Write Cost Read Cost Staleness Best For
Fan-out on Write O(followers) per post O(1) - just read cache None (precomputed) Normal users (< 10K followers)
Fan-out on Read O(1) per post O(following) queries + merge None (real-time) Celebrity users (> 10K followers)
Hybrid O(min(followers, threshold)) O(1) + O(celebrity following) Minimal for celebrities Production systems at scale
Failure Mode: If your celebrity detection threshold is too low, you will create an enormous fan-out-on-read load at feed read time. If it is too high, you will create fan-out-on-write storms when popular accounts post. Twitter has tuned this threshold multiple times as their user base has grown — it is not a set-and-forget parameter. Monitor fan-out latency and feed read latency continuously, and adjust the threshold based on observed percentiles.

7. News Feed Generation

News feed generation is the process of assembling a personalized timeline for a user when they open their app or refresh their feed. This is the most latency-sensitive operation in the entire system, as it sits on the critical path of the user experience. The process involves four distinct phases: candidate selection, candidate retrieval, ranking, and presentation. Each phase must be optimized independently to achieve the end-to-end latency target.

Phase 1: Candidate Selection

Candidate selection determines which pools of posts to consider for inclusion in the feed. For a user following 500 accounts, we cannot fetch the most recent 100 posts from each of those 500 accounts — that would be 50,000 posts to evaluate per feed request. Instead, we use the precomputed timeline as the primary candidate source, which already contains the most relevant posts from followed accounts. The precomputed timeline typically holds the 800 most recent post IDs, scored by recency and engagement signals.

In addition to the precomputed timeline, we pull candidates from several supplementary sources: posts from followed accounts that were posted since the last feed refresh (for freshness), posts that received high engagement from accounts with similar interests (for discovery), promoted content from the advertising system (for monetization), and posts from followed accounts that the ranking model previously scored highly but were not included in the top positions (for diversity).

Phase 2: Candidate Retrieval

Candidate retrieval fetches the full post objects for the selected candidates. This phase must handle several failure modes gracefully. Posts may have been deleted since they were added to the timeline cache. Posts may have been hidden by the user (muted words, blocked accounts). Posts may violate content policies and been removed by the moderation system. The retrieval phase must filter all of these cases while maintaining low latency.

C#
public class NewsFeedGenerator
{
    private readonly ITimelineCache _timelineCache;
    private readonly IPostRepository _postRepo;
    private readonly IUserBlockList _blockList;
    private readonly IContentFilter _contentFilter;
    private readonly IMLRankingService _rankingService;
    private readonly ICelebrityRegistry _celebrityRegistry;

    public async Task<FeedPage> GenerateFeedAsync(
        long userId, int pageSize, string? cursor)
    {
        var candidatePostIds = await RetrieveCandidateIdsAsync(userId, cursor);

        var posts = await BatchFetchPostsAsync(candidatePostIds);

        posts = ApplyFilters(userId, posts);

        var rankedPosts = await _rankingService.RankPostsAsync(userId, posts, pageSize);

        return new FeedPage
        {
            Posts = rankedPosts,
            NextCursor = rankedPosts.LastOrDefault()?.PostId.ToString(),
            HasMore = candidatePostIds.Count > pageSize
        };
    }

    private async Task<List<long>> RetrieveCandidateIdsAsync(
        long userId, string? cursor)
    {
        var precomputed = await _timelineCache
            .GetTimelineAsync(userId, 0, 800);

        var recentFromFollowing = await GetRecentFromFollowingAsync(userId);

        var allCandidateIds = precomputed
            .Select(e => e.PostId)
            .Union(recentFromFollowing)
            .Distinct()
            .ToList();

        return allCandidateIds;
    }

    private List<Post> ApplyFilters(long userId, List<Post> posts)
    {
        return posts.Where(p =>
            p.Visibility != ContentVisibility.Private &&
            !_blockList.IsBlocked(userId, p.AuthorId) &&
            !_contentFilter.ContainsViolations(p) &&
            p.CreatedAt > DateTime.UtcNow.AddDays(-7)
        ).ToList();
    }
}

Phase 3: Ranking

Ranking is the most computationally expensive phase, but it is also the most impactful for user engagement. The ranking model takes as input a set of candidate posts and user features, and outputs a relevance score for each post. The model considers hundreds of signals: the user's historical engagement with the author, the post's engagement velocity (likes per minute), the content type (text, image, video, link), the time of day, the user's recent browsing patterns, and the semantic similarity between the post content and the user's interests.

Phase 4: Presentation

The presentation phase takes the ranked posts and formats them for the client. This includes generating pre-signed CDN URLs for media attachments, computing relative timestamps ("2 hours ago"), aggregating engagement counts, and assembling the response payload. The response must be compact enough to transfer over mobile networks quickly while containing enough information for the client to render a rich feed experience.

Optimization: Implement a multi-tier candidate retrieval strategy. First, check an in-memory L1 cache (Caffeine/Guava) for the hottest 10% of users. Then fall back to Redis for the next 40%. Finally, query PostgreSQL only for cold users. This tiered approach reduces database load by over 80% while maintaining sub-100ms latency for the vast majority of feed requests.

8. Post Creation & Publishing

Post creation is the write-heavy path of the feed system. While it receives less traffic than feed reads (roughly 1/60th), it has much higher complexity because it must validate content, persist data durably, trigger asynchronous processing pipelines, and return a response to the user quickly. The post creation flow must also handle idempotency to prevent duplicate posts from network retries, and it must support offline-first clients that may submit posts when the device is not connected to the network.

Post Creation Pipeline

graph LR A[Client] -->|POST /api/posts| B[API Gateway] B -->|Rate Limit Check| C[Post Service] C -->|1. Validate| D[Content Validator] C -->|2. Check Limits| E[Rate Limiter] C -->|3. Persist| F[(PostgreSQL)] C -->|4. Publish Event| G[Kafka] C -->|5. Return 201| A G -->|Fan-out| H[Fan-out Worker] G -->|Index| I[Search Indexer] G -->|Trending| J[Trending Counter] G -->|Notify| K[Notification Service] G -->|Moderate| L[Content Moderator]
C#
public class PostCreationService
{
    private readonly IPostRepository _postRepo;
    private readonly IEventPublisher _eventPublisher;
    private readonly IContentValidator _validator;
    private readonly IRateLimiter _rateLimiter;
    private readonly IIdempotencyStore _idempotencyStore;
    private readonly IMediaService _mediaService;

    public async Task<PostCreationResult> CreatePostAsync(
        CreatePostRequest request, long authorId)
    {
        if (!await _idempotencyStore.IsNewRequestAsync(request.IdempotencyKey))
        {
            return await _idempotencyStore.GetCachedResultAsync(request.IdempotencyKey);
        }

        var rateLimitResult = await _rateLimiter.CheckLimitAsync(
            authorId, RateLimitType.PostCreation, windowMinutes: 60, maxPosts: 30);

        if (!rateLimitResult.IsAllowed)
        {
            throw new RateLimitExceededException(
                $"Post rate limit exceeded. Try again in {rateLimitResult.ResetAt}.");
        }

        var validation = _validator.Validate(request.Content, request.MediaIds);
        if (!validation.IsValid)
        {
            throw new ContentValidationException(validation.Errors);
        }

        var post = new Post
        {
            PostId = IdGenerator.Generate(),
            AuthorId = authorId,
            Content = request.Content?.Trim(),
            Type = DeterminePostType(request),
            ReplyToPostId = request.ReplyToPostId,
            RepostOfPostId = request.RepostOfPostId,
            Media = await ResolveMediaAttachmentsAsync(request.MediaIds),
            Hashtags = ExtractHashtags(request.Content),
            MentionedUserIds = ExtractMentions(request.Content),
            CreatedAt = DateTime.UtcNow,
            Visibility = request.Visibility ?? ContentVisibility.Public
        };

        await _postRepo.InsertAsync(post);

        await _eventPublisher.PublishAsync(new PostCreatedEvent
        {
            PostId = post.PostId,
            AuthorId = authorId,
            CreatedAt = post.CreatedAt,
            Hashtags = post.Hashtags,
            MentionedUserIds = post.MentionedUserIds,
            Type = post.Type
        });

        await _idempotencyStore.StoreAsync(
            request.IdempotencyKey, post, TimeSpan.FromHours(24));

        return new PostCreationResult
        {
            Post = post,
            Status = PostStatus.Published
        };
    }

    private PostType DeterminePostType(CreatePostRequest request)
    {
        if (request.ReplyToPostId.HasValue) return PostType.Reply;
        if (request.RepostOfPostId.HasValue)
        {
            return string.IsNullOrEmpty(request.Content)
                ? PostType.Repost
                : PostType.QuoteRepost;
        }
        return PostType.Original;
    }

    private List<string> ExtractHashtags(string? content)
    {
        if (string.IsNullOrEmpty(content)) return new List<string>();
        return Regex.Matches(content, @"#(\w+)")
            .Select(m => m.Groups[1].Value.ToLowerInvariant())
            .ToList();
    }

    private List<long> ExtractMentions(string? content)
    {
        if (string.IsNullOrEmpty(content)) return new List<long>();
        return Regex.Matches(content, @"@(\w+)")
            .Select(m => m.Groups[1].Value)
            .Select(username => _userService.LookupUserId(username))
            .Where(id => id.HasValue)
            .Select(id => id.Value)
            .ToList();
    }
}

Idempotency and Deduplication

Network unreliability means clients will retry failed requests. Without idempotency, a user might accidentally create duplicate posts. The solution is an idempotency key: the client generates a UUID for each post creation attempt and includes it in the request. The Post Service checks if this key has been processed before. If so, it returns the cached result instead of creating a duplicate post. The idempotency store is backed by Redis with a 24-hour TTL, which is long enough to handle client retries but short enough to avoid unbounded storage growth.

Optimistic UI Updates

To provide a responsive user experience, the client should optimistically add the new post to the user's feed before the server confirms it. The client generates a temporary post ID, renders the post in the feed with a "posting..." indicator, and replaces it with the server-assigned post ID once the creation succeeds. If the creation fails, the client removes the optimistic post and shows an error. This pattern is essential for perceived performance on mobile networks where round-trip times can exceed 500ms.

Event Sourcing Pattern: Consider using event sourcing for post mutations. Instead of directly updating the post record, publish events like PostCreated, PostLiked, PostDeleted. This creates an audit log, enables replay for debugging, and makes it trivial to add new consumers that react to post events without modifying the post service.

9. Real-time Updates (WebSocket & SSE)

Real-time updates are what transform a feed from a static page into a living, breathing experience. When a user's friend posts something, they should see it appear in their feed within seconds without manually refreshing. Implementing real-time updates at scale requires careful consideration of transport protocols, connection management, message serialization, and failure handling. The two primary technologies for real-time updates in web applications are WebSockets and Server-Sent Events (SSE).

WebSocket vs SSE Comparison

Feature WebSocket SSE (Server-Sent Events)
Direction Full duplex (client and server) Server to Client only
Protocol ws:// or wss:// HTTP/HTTPS
Auto-reconnect Manual implementation required Built into browser EventSource API
Binary data Native support Text only (base64 encode binary)
Firewall compatibility May be blocked by corporate proxies Works through all HTTP infrastructure
Connection limit 6 per domain (HTTP/1.1) 6 per domain (HTTP/1.1), unlimited with HTTP/2
Use case Chat, gaming, bidirectional streaming Feed updates, notifications, dashboards

For a social media feed system, SSE is often the better choice for feed updates because the communication is naturally unidirectional (server pushes new posts to the client). However, WebSockets are necessary when the client also needs to send real-time signals back to the server, such as typing indicators in direct messages or live reactions during a live stream. Many production systems use SSE for feed updates and WebSockets for chat and interactive features.

WebSocket Connection Management

C#
public class FeedWebSocketHandler
{
    private static readonly ConcurrentDictionary<long, WebSocketConnection>
        _connections = new();

    private readonly ITimelineCache _timelineCache;
    private readonly ILogger<FeedWebSocketHandler> _logger;

    public async Task HandleConnectionAsync(HttpContext context, long userId)
    {
        var socket = await context.WebSockets.AcceptWebSocketAsync();

        var connection = new WebSocketConnection
        {
            UserId = userId,
            Socket = socket,
            ConnectedAt = DateTime.UtcNow,
            LastHeartbeat = DateTime.UtcNow
        };

        _connections[userId] = connection;

        try
        {
            await SendInitialFeedAsync(connection);

            var receiveBuffer = new byte[1024];
            while (socket.State == WebSocketState.Open)
            {
                var result = await socket.ReceiveAsync(
                    new ArraySegment<byte>(receiveBuffer),
                    CancellationToken.None);

                if (result.MessageType == WebSocketMessageType.Close)
                {
                    await socket.CloseAsync(
                        WebSocketCloseStatus.NormalClosure,
                        "Client closed",
                        CancellationToken.None);
                    break;
                }

                if (result.MessageType == WebSocketMessageType.Text)
                {
                    await HandleClientMessageAsync(connection,
                        Encoding.UTF8.GetString(receiveBuffer, 0, result.Count));
                }
            }
        }
        finally
        {
            _connections.TryRemove(userId, out _);
        }
    }

    private async Task HandleClientMessageAsync(
        WebSocketConnection connection, string message)
    {
        var doc = JsonDocument.Parse(message);
        var type = doc.RootElement.GetProperty("type").GetString();

        switch (type)
        {
            case "heartbeat":
                connection.LastHeartbeat = DateTime.UtcNow;
                await SendJsonAsync(connection, new { type = "heartbeat_ack" });
                break;

            case "refresh_feed":
                await SendInitialFeedAsync(connection);
                break;
        }
    }

    public async Task NotifyNewPostAsync(long userId, TimelineEntry entry)
    {
        if (_connections.TryGetValue(userId, out var connection))
        {
            await SendJsonAsync(connection, new
            {
                type = "new_post",
                post_id = entry.PostId,
                author_id = entry.AuthorId,
                published_at = entry.PublishedAt
            });
        }
    }

    private async Task SendJsonAsync(
        WebSocketConnection connection, object payload)
    {
        if (connection.Socket.State != WebSocketState.Open) return;

        var json = JsonSerializer.Serialize(payload);
        var bytes = Encoding.UTF8.GetBytes(json);
        await connection.Socket.SendAsync(
            new ArraySegment<byte>(bytes),
            WebSocketMessageType.Text,
            true,
            CancellationToken.None);
    }

    private async Task SendInitialFeedAsync(WebSocketConnection connection)
    {
        var entries = await _timelineCache
            .GetTimelineAsync(connection.UserId, 0, 20);

        await SendJsonAsync(connection, new
        {
            type = "initial_feed",
            posts = entries
        });
    }
}

public class WebSocketConnection
{
    public long UserId { get; set; }
    public WebSocket Socket { get; set; }
    public DateTime ConnectedAt { get; set; }
    public DateTime LastHeartbeat { get; set; }
}

Connection Scaling Architecture

WebSocket connections are stateful and long-lived, which presents unique scaling challenges. Each server can maintain approximately 50,000-100,000 concurrent WebSocket connections, depending on the message frequency and available memory. For 300 million daily active users, you need a connection tier of approximately 3,000-6,000 servers. Client connections are distributed across these servers using a consistent hashing scheme that maps each user to a specific connection server. When a post event needs to be delivered, the event publisher looks up the user's connection server from a routing table and publishes the event to that specific server.

Heartbeat Management: Without heartbeats, stale connections accumulate and waste server resources. Implement a 30-second heartbeat interval where the client sends a ping and the server responds with an ack. If the server does not receive a heartbeat within 90 seconds (3 missed heartbeats), it closes the connection and removes the user from the routing table. This ensures that the routing table accurately reflects which servers hold active connections for each user.

Graceful Degradation

When the WebSocket connection drops — due to network changes, server restarts, or load balancer failovers — the client must gracefully degrade. The recommended approach is a multi-layer fallback: first, attempt to reconnect via WebSocket with exponential backoff (1s, 2s, 4s, 8s, max 30s). If WebSocket reconnection fails after 3 attempts, fall back to SSE with a long-polling endpoint. If SSE is also unavailable, fall back to periodic HTTP polling every 30 seconds. This layered approach ensures that users receive updates within seconds under normal conditions and within 30 seconds under degraded conditions.

10. Timeline Caching Strategy

Timeline caching is the backbone of feed read performance. Without effective caching, every feed request would require querying the social graph and merging hundreds of post result sets — an operation that would take seconds rather than milliseconds. The caching strategy must handle three key challenges: maintaining cache freshness (new posts must appear quickly), managing cache memory (timelines for millions of users cannot fit in a single cache tier), and handling cache invalidation gracefully (when posts are deleted or hidden, the cache must be updated without serving stale data).

Multi-Tier Cache Architecture

graph TB subgraph "Cache Tiers" L1["L1: In-Process Cache - Caffeine/Guava - ~10K users, ~1ms"] L2["L2: Redis Hot Tier - ~1M users, ~5ms"] L3["L3: Redis Warm Tier - ~50M users, ~10ms"] DB["L4: PostgreSQL Cold Fallback - ~all users, ~50ms"] end Feed[Feed Service] --> L1 L1 -->|Miss| L2 L2 -->|Miss| L3 L3 -->|Miss| DB DB -->|Populate| L3 L3 -->|Populate| L2 L2 -->|Populate| L1
C#
public class MultiTierTimelineCache : ITimelineCache
{
    private readonly IMemoryCache _l1Cache;
    private readonly IDatabase _redisHot;
    private readonly IDatabase _redisWarm;
    private readonly IPostgresTimelineStore _postgresStore;

    private static readonly TimeSpan L1Ttl = TimeSpan.FromMinutes(5);
    private static readonly TimeSpan L2Ttl = TimeSpan.FromHours(1);
    private static readonly TimeSpan L3Ttl = TimeSpan.FromHours(24);

    public async Task<List<TimelineEntry>> GetTimelineAsync(
        long userId, int offset, int limit)
    {
        var cacheKey = $"timeline:{userId}:{offset}:{limit}";

        var l1Result = _l1Cache.Get<List<TimelineEntry>>(cacheKey);
        if (l1Result != null) return l1Result;

        var l2Result = await GetFromRedisAsync(_redisHot, cacheKey);
        if (l2Result != null)
        {
            _l1Cache.Set(cacheKey, l2Result, L1Ttl);
            return l2Result;
        }

        var l3Result = await GetFromRedisAsync(_redisWarm, cacheKey);
        if (l3Result != null)
        {
            await SetRedisAsync(_redisHot, cacheKey, l3Result, L2Ttl);
            _l1Cache.Set(cacheKey, l3Result, L1Ttl);
            return l3Result;
        }

        var dbResult = await _postgresStore
            .GetTimelineAsync(userId, offset, limit);

        await SetRedisAsync(_redisWarm, cacheKey, dbResult, L3Ttl);
        await SetRedisAsync(_redisHot, cacheKey, dbResult, L2Ttl);
        _l1Cache.Set(cacheKey, dbResult, L1Ttl);

        return dbResult;
    }

    public async Task AppendToTimelineAsync(
        long userId, TimelineEntry entry)
    {
        var sortedSetKey = $"timeline:{userId}";

        await _redisHot.SortedSetAddAsync(
            sortedSetKey,
            JsonSerializer.Serialize(entry),
            entry.PublishedAt.Ticks);

        var count = await _redisHot.SortedSetLengthAsync(sortedSetKey);
        if (count > 1000)
        {
            await _redisHot.SortedSetRemoveRangeByRankAsync(
                sortedSetKey, 0, (int)(count - 1000));
        }

        InvalidateLocalCache(userId);
    }

    public async Task InvalidateTimelineAsync(long userId)
    {
        await _redisHot.KeyDeleteAsync($"timeline:{userId}");
        await _redisWarm.KeyDeleteAsync($"timeline:{userId}");
        InvalidateLocalCache(userId);
    }

    private void InvalidateLocalCache(long userId)
    {
        var keysToRemove = _l1Cache.Keys
            .Where(k => k.ToString().StartsWith($"timeline:{userId}"))
            .ToList();

        foreach (var key in keysToRemove)
        {
            _l1Cache.Remove(key);
        }
    }

    private async Task<List<TimelineEntry>?> GetFromRedisAsync(
        IDatabase redis, string key)
    {
        var value = await redis.StringGetAsync(key);
        if (value.IsNullOrEmpty) return null;
        return JsonSerializer.Deserialize<List<TimelineEntry>>(value!);
    }

    private async Task SetRedisAsync(
        IDatabase redis, string key,
        List<TimelineEntry> entries, TimeSpan ttl)
    {
        var json = JsonSerializer.Serialize(entries);
        await redis.StringSetAsync(key, json, ttl);
    }
}

Cache Invalidation Strategies

Cache invalidation is famously one of the two hard problems in computer science, and timeline caches are no exception. When a post is deleted, it must be removed from every follower's timeline cache. When a post is hidden by moderation, the same removal must occur. When a user blocks another user, all posts from the blocked user must disappear from the blocking user's timeline. Each of these operations requires identifying which caches to invalidate and doing so quickly enough that users do not see the stale content.

The most practical approach for social media feeds is lazy invalidation combined with periodic reconciliation. When a post is deleted, publish a PostDeleted event to Kafka. Consumer workers process the event and attempt to remove the post from the author's follower timelines. However, this fan-out deletion is best-effort — it may take several seconds to complete. During this window, the post may still appear in some users' feeds. To handle this, the feed service applies a soft filter at read time: even if a post ID is in the timeline cache, the service checks that the post still exists and is visible before including it in the response. A background reconciliation job runs every hour to catch any stale entries that the event-driven deletion missed.

Production Tip: Use Redis Sorted Sets for timeline storage. The score is the post timestamp (ticks), and the value is the serialized timeline entry. This allows efficient range queries for pagination (ZRANGEBYSCORE), automatic time-based eviction (ZREMRANGEBYRANK), and O(log N) insertion and deletion. Avoid using Redis Lists for timelines, as they do not support efficient deletion of arbitrary elements.

11. Media Upload & Processing

Media content — images, videos, and GIFs — is what makes social media feeds visually engaging. However, handling media at scale introduces significant complexity: uploads must be reliable even on poor mobile networks, images must be resized into multiple resolutions for different devices, videos must be transcoded into multiple formats and bitrates, and all content must be delivered through a CDN with minimal latency. The media pipeline must handle petabytes of data per month while maintaining upload success rates above 99.9%.

Upload Architecture

graph LR A[Client] -->|1. Request Upload URL| B[Media Service] B -->|2. Generate Pre-signed URL| S3[(Object Storage)] B -->|3. Return URL| A A -->|4. Upload Directly| S3 S3 -->|5. S3 Event| Q[SQS/SNS] Q -->|6. Process| W[Media Worker] W -->|7a. Resize Images| IF[Image Processing] W -->|7b. Transcode Video| VF[Video Processing] W -->|7c. Generate Thumbnails| TF[Thumbnail Generator] IF -->|8. Store Variants| S3 VF -->|8. Store Variants| S3 TF -->|8. Store Variants| S3 W -->|9. Update Metadata| DB[(Database)]
C#
public class MediaUploadService
{
    private readonly IStorageProvider _storage;
    private readonly IEventPublisher _eventPublisher;
    private readonly IMediaMetadataStore _metadataStore;

    private static readonly HashSet<string> AllowedImageTypes = new()
    {
        "image/jpeg", "image/png", "image/gif", "image/webp"
    };

    private static readonly HashSet<string> AllowedVideoTypes = new()
    {
        "video/mp4", "video/quicktime", "video/webm"
    };

    private const long MaxImageSize = 10 * 1024 * 1024;
    private const long MaxVideoSize = 512 * 1024 * 1024;

    public async Task<UploadSession> InitiateUploadAsync(
        string filename, string contentType, long fileSize, long userId)
    {
        ValidateFileType(contentType);
        ValidateFileSize(contentType, fileSize);

        var mediaId = IdGenerator.Generate();
        var extension = Path.GetExtension(filename);
        var storageKey = $"media/{userId}/{mediaId}/{Guid.NewGuid()}{extension}";

        var uploadUrl = await _storage.GeneratePresignedUploadUrlAsync(
            storageKey, contentType, TimeSpan.FromMinutes(30));

        var session = new UploadSession
        {
            MediaId = mediaId,
            UploadUrl = uploadUrl,
            StorageKey = storageKey,
            ExpiresAt = DateTime.UtcNow.AddMinutes(30),
            MaxFileSize = GetMaxFileSize(contentType)
        };

        await _metadataStore.CreatePendingUploadAsync(new MediaMetadata
        {
            MediaId = mediaId,
            UserId = userId,
            Filename = filename,
            ContentType = contentType,
            FileSize = fileSize,
            StorageKey = storageKey,
            Status = MediaStatus.Uploading,
            CreatedAt = DateTime.UtcNow
        });

        return session;
    }

    public async Task<MediaAttachment> CompleteUploadAsync(
        long mediaId, long userId)
    {
        var metadata = await _metadataStore.GetAsync(mediaId);

        if (metadata == null || metadata.UserId != userId)
            throw new NotFoundException("Upload session not found");

        var fileInfo = await _storage.GetFileInfoAsync(metadata.StorageKey);

        if (fileInfo == null)
            throw new InvalidOperationException("File not found in storage");

        metadata.Status = MediaStatus.Processing;
        await _metadataStore.UpdateAsync(metadata);

        await _eventPublisher.PublishAsync(new MediaUploadedEvent
        {
            MediaId = mediaId,
            StorageKey = metadata.StorageKey,
            ContentType = metadata.ContentType,
            FileSize = metadata.FileSize
        });

        return await BuildMediaAttachmentAsync(metadata);
    }

    private async Task<MediaAttachment> BuildMediaAttachmentAsync(
        MediaMetadata metadata)
    {
        var variantBase = Path.ChangeExtension(metadata.StorageKey, null);

        return new MediaAttachment
        {
            MediaId = metadata.MediaId,
            Type = GetMediaType(metadata.ContentType),
            OriginalUrl = await _storage.GetPublicUrlAsync(metadata.StorageKey),
            ThumbnailUrl = await _storage.GetPublicUrlAsync(
                $"{variantBase}_thumb.webp"),
            MediumUrl = await _storage.GetPublicUrlAsync(
                $"{variantBase}_medium.webp"),
            Width = metadata.ProcessedWidth ?? 0,
            Height = metadata.ProcessedHeight ?? 0,
            DurationMs = metadata.DurationMs ?? 0
        };
    }

    private void ValidateFileType(string contentType)
    {
        if (!AllowedImageTypes.Contains(contentType) &&
            !AllowedVideoTypes.Contains(contentType))
        {
            throw new ContentTypeNotSupportedException(
                $"Content type {contentType} is not supported");
        }
    }

    private void ValidateFileSize(string contentType, long fileSize)
    {
        var maxSize = GetMaxFileSize(contentType);
        if (fileSize > maxSize)
        {
            throw new FileSizeExceededException(
                $"File size {fileSize} exceeds maximum {maxSize}");
        }
    }

    private long GetMaxFileSize(string contentType) =>
        AllowedVideoTypes.Contains(contentType)
            ? MaxVideoSize
            : MaxImageSize;
}

Image Processing Pipeline

When an image is uploaded, the processing pipeline generates multiple variants to serve different use cases. The original image is stored at full resolution for the "open image" view. A medium variant (max 1200px wide) is generated for feed display on desktop browsers. A small variant (max 600px wide) is generated for feed display on mobile devices. A thumbnail variant (max 200px square, center-cropped) is generated for search results and notification previews. Each variant is converted to WebP format for optimal compression, with a fallback to JPEG for older browsers. The processing is done asynchronously using a distributed worker pool, with images typically processed within 5-10 seconds of upload.

Video Processing Pipeline

Video processing is significantly more complex and expensive than image processing. A 60-second video at 1080p resolution can take 5-10 minutes to transcode on a single CPU core. The pipeline must generate multiple resolutions (360p, 480p, 720p, 1080p) and bitrates to support adaptive bitrate streaming. The transcoded segments are packaged into HLS or DASH format for progressive delivery. A thumbnail is extracted from the video at the midpoint, and a short preview clip (first 3 seconds) is generated for autoplay in the feed. Video processing typically uses GPU-accelerated instances (NVENC) or specialized media processing services like AWS Elemental MediaConvert to reduce transcoding time to under 1 minute.

Cost Warning: Video processing is the most expensive operation in the media pipeline. Transcoding a single 1-minute video at 1080p costs approximately $0.03-0.10 on cloud compute. With millions of videos uploaded daily, this can amount to hundreds of thousands of dollars per month. Implement aggressive quality-of-service controls: reject videos over a certain length, limit uploads per user per day, and prioritize processing for recently uploaded content while deprioritizing older uploads.

12. Follow/Unfollow Social Graph

The social graph — the network of who follows whom — is the most fundamental data structure in a social media feed system. Every feed request, every notification, and every content recommendation ultimately depends on the social graph. The graph must support several critical query patterns with low latency: "who does this user follow" (for feed assembly), "who follows this user" (for fan-out), "do these two users follow each other" (for relationship display), and "who should this user follow" (for recommendations). The graph also must handle the most write-intensive operation in the system: the follow/unfollow action, which modifies two relationships simultaneously (the follower's following list and the followee's follower list).

Graph Storage Options

Storage Strengths Weaknesses Used By
Adjacency List (RDBMS) Strong consistency, SQL queries, mature tooling Slow for multi-hop queries, limited graph traversals Twitter (early), Pinterest
Graph Database (Neo4j) Native graph operations, efficient traversals, Cypher queries Operational complexity, limited horizontal scaling Facebook (partial)
Adjacency List (Redis) Sub-millisecond reads, efficient set operations (SINTER, SUNION) Memory constrained, no complex queries Twitter, Instagram
Custom Graph Store Optimized for specific access patterns, horizontal scaling Development cost, operational burden Facebook (TAO), Twitter (Manhattan)
C#
public class SocialGraphService
{
    private readonly IDatabase _redis;
    private readonly IFollowRepository _postgresRepo;
    private readonly IEventPublisher _eventPublisher;
    private readonly ITimelineCache _timelineCache;

    private static string FollowingKey(long userId) => $"following:{userId}";
    private static string FollowersKey(long userId) => $"followers:{userId}";

    public async Task<FollowResult> FollowAsync(
        long followerId, long followeeId)
    {
        if (followerId == followeeId)
            throw new InvalidOperationException("Cannot follow yourself");

        if (await IsFollowingAsync(followerId, followeeId))
            return new FollowResult { Status = FollowStatus.AlreadyFollowing };

        var existing = await _postgresRepo.GetAsync(followerId, followeeId);

        if (existing != null && existing.Status == FollowStatus.Active)
            return new FollowResult { Status = FollowStatus.AlreadyFollowing };

        var relationship = new FollowRelationship
        {
            FollowerId = followerId,
            FolloweeId = followeeId,
            FollowedAt = DateTime.UtcNow,
            Status = FollowStatus.Active
        };

        await _postgresRepo.UpsertAsync(relationship);

        await _redis.SetAddAsync(FollowingKey(followerId), followeeId);
        await _redis.SetAddAsync(FollowersKey(followeeId), followerId);

        await _redis.SortedSetAddAsync(
            "follow_counts:following", followerId,
            await GetFollowingCountAsync(followerId));
        await _redis.SortedSetAddAsync(
            "follow_counts:followers", followeeId,
            await GetFollowerCountAsync(followeeId));

        await _eventPublisher.PublishAsync(new FollowEvent
        {
            FollowerId = followerId,
            FolloweeId = followeeId,
            Action = FollowAction.Followed,
            Timestamp = DateTime.UtcNow
        });

        return new FollowResult { Status = FollowStatus.Success };
    }

    public async Task<UnfollowResult> UnfollowAsync(
        long followerId, long followeeId)
    {
        await _postgresRepo.DeleteAsync(followerId, followeeId);

        await _redis.SetRemoveAsync(FollowingKey(followerId), followeeId);
        await _redis.SetRemoveAsync(FollowersKey(followeeId), followerId);

        await _timelineCache.RemoveAuthorPostsAsync(followeeId, followerId);

        await _eventPublisher.PublishAsync(new FollowEvent
        {
            FollowerId = followerId,
            FolloweeId = followeeId,
            Action = FollowAction.Unfollowed,
            Timestamp = DateTime.UtcNow
        });

        return new UnfollowResult { Status = UnfollowStatus.Success };
    }

    public async Task<bool> IsFollowingAsync(long followerId, long followeeId)
    {
        return await _redis.SetContainsAsync(FollowingKey(followerId), followeeId);
    }

    public async Task<List<long>> GetFollowerIdsAsync(long userId)
    {
        var ids = await _redis.SetMembersAsync(FollowersKey(userId));
        return ids.Select(id => (long)id).ToList();
    }

    public async Task<List<long>> GetFollowingIdsAsync(long userId)
    {
        var ids = await _redis.SetMembersAsync(FollowingKey(userId));
        return ids.Select(id => (long)id).ToList();
    }

    public async Task<List<long>> GetMutualFollowsAsync(
        long userId, long otherUserId)
    {
        var myFollowing = await _redis.SetMembersAsync(FollowingKey(userId));
        var theirFollowing = await _redis.SetMembersAsync(FollowingKey(otherUserId));

        return myFollowing.Intersect(theirFollowing)
            .Select(id => (long)id)
            .ToList();
    }

    public async Task<int> GetFollowerCountAsync(long userId)
    {
        var count = await _redis.SetLengthAsync(FollowersKey(userId));
        return (int)count;
    }

    public async Task<int> GetFollowingCountAsync(long userId)
    {
        var count = await _redis.SetLengthAsync(FollowingKey(userId));
        return (int)count;
    }
}

Graph Consistency

Follow/unfollow operations must maintain consistency between the relational store and the Redis cache. The pattern used is write-through: the relational store is updated first (as the source of truth), and then the Redis cache is updated. If the Redis update fails, the system is still consistent because the relational store is authoritative. A background reconciliation job compares the Redis sets against the relational store and fixes any discrepancies, which handles edge cases like failed cache updates during network partitions.

Scaling Consideration: At Twitter scale, the follow graph has over 600 billion edges. Storing this in Redis would require approximately 15 TB of memory at $10/GB, totaling $150,000/month just for the graph. Most companies store the full graph in a partitioned database (e.g., a custom key-value store like Manhattan or a sharded MySQL cluster) and use Redis only for the hot subset of recently active users. The warm/cold tiering strategy can reduce Redis memory requirements by 90%.

13. Content Ranking & Chronological Feed

Content ranking is what separates a good social media feed from a great one. A chronological feed is simple to implement — just sort posts by timestamp — but it fails to surface the most relevant and engaging content for each individual user. Modern social media feeds use machine learning ranking models that predict the probability of each user engaging with each candidate post, then sort the candidates by predicted engagement. This ranking step typically increases engagement metrics by 20-40% compared to chronological feeds, which directly translates to increased time-on-app and advertising revenue.

Ranking Model Architecture

graph TB subgraph "Feature Engineering" UF["User Features - Engagement history, demographics, interests"] PF["Post Features - Content type, engagement velocity, author signals"] CF["Context Features - Time of day, device, network quality"] SF["Social Features - Relationship strength, mutual follows, interaction frequency"] end subgraph "ML Pipeline" FE["Feature Store - Real-time + Batch Features"] IM["Inference Model - LightGBM / Deep Learning"] RS["Ranking Score - P of engagement given user and post"] end UF --> FE PF --> FE CF --> FE SF --> FE FE --> IM IM --> RS
C#
public class ContentRankingService : IMLRankingService
{
    private readonly IFeatureStore _featureStore;
    private readonly IRankingModel _model;

    public async Task<List<RankedPost>> RankPostsAsync(
        long userId, List<Post> candidates, int limit)
    {
        if (!candidates.Any())
            return new List<RankedPost>();

        var userFeatures = await _featureStore.GetUserFeaturesAsync(userId);

        var rankingTasks = candidates.Select(async post =>
        {
            var postFeatures = await _featureStore
                .GetPostFeaturesAsync(post.PostId);
            var socialFeatures = await _featureStore
                .GetSocialFeaturesAsync(userId, post.AuthorId);
            var contextFeatures = BuildContextFeatures();

            var features = MergeFeatures(
                userFeatures, postFeatures, socialFeatures, contextFeatures);

            var score = await _model.PredictAsync(features);

            return new RankedPost
            {
                Post = post,
                RankingScore = score,
                ScoreBreakdown = new ScoreBreakdown
                {
                    EngagementScore = features.EngagementScore,
                    RecencyScore = features.RecencyScore,
                    RelationshipScore = features.RelationshipScore,
                    ContentQualityScore = features.ContentQualityScore
                }
            };
        });

        var ranked = await Task.WhenAll(rankingTasks);

        var diversified = ApplyDiversityRules(ranked.ToList());

        return diversified
            .OrderByDescending(r => r.RankingScore)
            .Take(limit)
            .ToList();
    }

    private List<RankedPost> ApplyDiversityRules(List<RankedPost> posts)
    {
        var result = new List<RankedPost>();
        var authorCounts = new Dictionary<long, int>();
        var typeCounts = new Dictionary<PostType, int>();

        foreach (var post in posts.OrderByDescending(p => p.RankingScore))
        {
            var authorCount = authorCounts.GetValueOrDefault(
                post.Post.AuthorId, 0);
            var typeCount = typeCounts.GetValueOrDefault(post.Post.Type, 0);

            if (authorCount >= 3 || typeCount >= 5)
                continue;

            result.Add(post);
            authorCounts[post.Post.AuthorId] = authorCount + 1;
            typeCounts[post.Post.Type] = typeCount + 1;

            if (result.Count >= 200) break;
        }

        return result;
    }

    private ContextFeatures BuildContextFeatures()
    {
        var now = DateTime.UtcNow;
        return new ContextFeatures
        {
            HourOfDay = now.Hour,
            DayOfWeek = now.DayOfWeek,
            IsWeekend = now.DayOfWeek == DayOfWeek.Saturday ||
                       now.DayOfWeek == DayOfWeek.Sunday
        };
    }

    private RankingFeatures MergeFeatures(
        UserFeatures user,
        PostFeatures post,
        SocialFeatures social,
        ContextFeatures context)
    {
        return new RankingFeatures
        {
            EngagementScore = ComputeEngagementScore(user, post, social),
            RecencyScore = ComputeRecencyScore(post.PublishedAt),
            RelationshipScore = social.InteractionFrequency,
            ContentQualityScore = post.QualityScore
        };
    }

    private double ComputeEngagementScore(
        UserFeatures user, PostFeatures post, SocialFeatures social)
    {
        var baseScore = post.LikeRate * 0.3
            + post.RetweetRate * 0.5
            + post.ReplyRate * 0.2;

        var relationshipBoost = social.IsMutualFollow ? 1.5 : 1.0;
        var affinityBoost = social.InteractionFrequency * 0.3;

        return baseScore * relationshipBoost + affinityBoost;
    }

    private double ComputeRecencyScore(DateTime publishedAt)
    {
        var age = DateTime.UtcNow - publishedAt;
        return Math.Exp(-age.TotalHours / 6.0);
    }
}

Chronological Feed Fallback

While ranked feeds maximize engagement, some users prefer a chronological feed. The system should support both modes, controlled by a user preference setting. The chronological feed bypasses the ML ranking model entirely and simply sorts the precomputed timeline entries by timestamp. This is significantly cheaper to serve (no ML inference required) and can be served from cache with lower latency. However, it still applies basic filters: deleted posts, blocked users, and muted content are removed before the chronological sort.

A/B Testing Ranking Models

Ranking models are continuously evolving. New models must be A/B tested against the current production model before being promoted. The testing framework assigns users to treatment groups randomly, serves them the treatment ranking model, and measures engagement metrics (clicks, likes, time spent, session length) against the control group. Models must pass a statistical significance threshold (typically p < 0.05) and show at least a 1% improvement in the primary metric before being promoted to production.

Ethical Consideration: Ranking models that maximize engagement can inadvertently amplify divisive, sensational, or misleading content. Build guardrails into the ranking pipeline: demote content flagged by fact-checkers, reduce the ranking score of content from accounts with high abuse reports, and ensure a minimum diversity of topics and viewpoints. These guardrails may reduce short-term engagement metrics but protect long-term platform health.

15. Notification System

The notification system is the primary mechanism for re-engaging users who are not currently active in the app. When someone likes your post, follows you, or mentions you in a reply, you expect to receive a notification within seconds. The notification system must handle millions of events per minute, support multiple delivery channels (push notifications, in-app notifications, email digests), respect user notification preferences, and implement rate limiting to prevent notification spam.

Notification Architecture

graph TB subgraph "Event Sources" PE["Post Events - Likes, Replies, Reposts"] FE["Follow Events - New Followers"] ME["Mention Events - at mentions in posts"] TE["Trending Events - Trending from followed"] end subgraph "Notification Pipeline" KA[Kafka Consumer] PR["Preference Filter - User notification settings"] RL["Rate Limiter - Prevent notification spam"] BZ["Batcher - Aggregate notifications"] DL[Delivery Layer] end PE --> KA FE --> KA ME --> KA TE --> KA KA --> PR PR --> RL RL --> BZ BZ --> DL subgraph "Delivery Channels" APNs["Apple Push Notification Service"] FCM["Firebase Cloud Messaging"] WS["WebSocket In-App"] EM["Email Digest"] end DL --> APNs DL --> FCM DL --> WS DL --> EM
C#
public class NotificationService
{
    private readonly INotificationPreferences _preferences;
    private readonly INotificationRateLimiter _rateLimiter;
    private readonly INotificationStore _store;
    private readonly IPushNotificationService _pushService;
    private readonly IWebSocketHub _webSocketHub;

    public async Task<NotificationResult> SendNotificationAsync(
        NotificationEvent evt)
    {
        var recipientPreferences = await _preferences
            .GetPreferencesAsync(evt.RecipientId);

        if (!IsNotificationTypeEnabled(recipientPreferences, evt.Type))
            return NotificationResult.Skipped("Type disabled");

        if (await _rateLimiter.IsRateLimitedAsync(evt.RecipientId, evt.Type))
            return NotificationResult.Skipped("Rate limited");

        var notification = new Notification
        {
            NotificationId = IdGenerator.Generate(),
            RecipientId = evt.RecipientId,
            ActorId = evt.ActorId,
            Type = evt.Type,
            EntityType = evt.EntityType,
            EntityId = evt.EntityId,
            Message = await BuildNotificationMessageAsync(evt),
            CreatedAt = DateTime.UtcNow,
            Read = false
        };

        await _store.SaveAsync(notification);

        if (recipientPreferences.PushEnabled &&
            ShouldPushNow(recipientPreferences, notification))
        {
            await _pushService.SendAsync(evt.RecipientId, notification);
        }

        if (recipientPreferences.InAppRealtimeEnabled)
        {
            await _webSocketHub.SendToUserAsync(evt.RecipientId, notification);
        }

        return NotificationResult.Sent(notification.NotificationId);
    }

    public async Task<BatchNotificationResult> SendBatchNotificationsAsync(
        List<NotificationEvent> events)
    {
        var results = new List<NotificationResult>();
        var groupedByRecipient = events.GroupBy(e => e.RecipientId);

        foreach (var group in groupedByRecipient)
        {
            var collapsed = CollapseNotifications(group.ToList());

            foreach (var evt in collapsed)
            {
                var result = await SendNotificationAsync(evt);
                results.Add(result);
            }
        }

        return new BatchNotificationResult
        {
            Sent = results.Count(r => r.Status == NotificationStatus.Sent),
            Skipped = results.Count(r => r.Status == NotificationStatus.Skipped),
            Failed = results.Count(r => r.Status == NotificationStatus.Failed)
        };
    }

    private List<NotificationEvent> CollapseNotifications(
        List<NotificationEvent> events)
    {
        var byType = events.GroupBy(e => new { e.Type, e.EntityType, e.EntityId });

        return byType.Select(group =>
        {
            var eventsOfType = group.ToList();
            if (eventsOfType.Count == 1)
                return eventsOfType.First();

            return new NotificationEvent
            {
                RecipientId = eventsOfType[0].RecipientId,
                Type = eventsOfType[0].Type,
                EntityType = eventsOfType[0].EntityType,
                EntityId = eventsOfType[0].EntityId,
                ActorIds = eventsOfType.Select(e => e.ActorId).Distinct().ToList(),
                IsCollapsed = true,
                ActorCount = eventsOfType.Count
            };
        }).ToList();
    }

    private bool ShouldPushNow(
        UserNotificationPreferences prefs, Notification notification)
    {
        var now = DateTime.UtcNow;
        var localHour = (now.Hour + prefs.TimezoneOffsetHours) % 24;

        if (localHour >= prefs.QuietHoursStart &&
            localHour < prefs.QuietHoursEnd)
            return false;

        return true;
    }
}

Notification Collapsing

When a post goes viral, it may receive thousands of likes and retweets within minutes. Sending an individual notification for each like would overwhelm the user with hundreds of push notifications. Instead, the notification system collapses multiple similar notifications into a single notification: "Alice, Bob, and 47 others liked your post" instead of 49 separate "Alice liked your post" notifications. The collapsing strategy groups notifications by type and entity, then uses template-based messages that replace actor names with a count when the number exceeds a threshold (typically 3 actors before collapsing).

Best Practice: Implement notification delivery budgets per user. Each user gets a daily budget of push notifications (e.g., 5 per day), and the system prioritizes which notifications to send as push based on the strength of the social relationship (close friends and family first), the importance of the notification type (mentions and replies rank higher than likes), and the user's historical open rate for push notifications. Users who rarely open push notifications get fewer of them, preserving their attention budget for the notifications most likely to drive re-engagement.

16. Content Moderation

Content moderation is the system that ensures the platform remains safe, legal, and aligned with community standards. At scale, this is one of the most challenging problems in social media engineering — not just technically, but also ethically and operationally. The moderation system must handle millions of posts per day across dozens of languages, make fast decisions that affect free expression, and balance automated detection with human review. False positives silence legitimate speech; false negatives allow harmful content to spread. Both failure modes have real consequences for users and the platform.

Moderation Pipeline

graph LR A[Post Created] --> B{"Automated Pre-screening"} B -->|Clean| C[Publish Immediately] B -->|Suspicious| D["ML Classifier Queue"] B -->|Known Violation| E["Auto-remove + Appeal"] D -->|High Confidence| F["Auto-action Flag/Remove"] D -->|Medium Confidence| G["Human Review Queue"] D -->|Low Confidence| C G -->|Approved| C G -->|Rejected| H["Remove + Notify"] H --> I[User Appeal] I --> J[Appeals Review]
C#
public class ContentModerationService
{
    private readonly IMLModerationClassifier _classifier;
    private readonly IHashBlocklist _blocklist;
    private readonly IHumanReviewQueue _reviewQueue;
    private readonly IPostRepository _postRepo;
    private readonly IEventPublisher _eventPublisher;

    public async Task<ModerationResult> ModeratePostAsync(Post post)
    {
        var hashResult = await _blocklist.CheckAsync(post.Content, post.Media);

        if (hashResult.IsExactMatch)
        {
            await HandleViolationAsync(post, hashResult.Category,
                ModerationAction.AutoRemove, "Exact hash match");
            return ModerationResult.Removed(hashResult.Category);
        }

        var mlResult = await _classifier.ClassifyAsync(post.Content, post.Media);

        if (mlResult.Confidence > 0.95 && mlResult.IsViolation)
        {
            await HandleViolationAsync(post, mlResult.Category,
                ModerationAction.AutoRemove,
                $"ML confidence: {mlResult.Confidence:F2}");
            return ModerationResult.Removed(mlResult.Category);
        }

        if (mlResult.Confidence > 0.7 && mlResult.IsViolation)
        {
            await _reviewQueue.EnqueueAsync(new ReviewItem
            {
                PostId = post.PostId,
                AuthorId = post.AuthorId,
                Content = post.Content,
                MLCategory = mlResult.Category,
                MLConfidence = mlResult.Confidence,
                Priority = CalculateReviewPriority(mlResult),
                CreatedAt = DateTime.UtcNow
            });
            return ModerationResult.UnderReview();
        }

        if (mlResult.Confidence > 0.4)
        {
            await _reviewQueue.EnqueueAsync(new ReviewItem
            {
                PostId = post.PostId,
                AuthorId = post.AuthorId,
                Content = post.Content,
                MLCategory = mlResult.Category,
                MLConfidence = mlResult.Confidence,
                Priority = ReviewPriority.Low,
                CreatedAt = DateTime.UtcNow
            });
            return ModerationResult.LowConfidenceUnderReview();
        }

        return ModerationResult.Approved();
    }

    public async Task<ModerationResult> ModeratePostMediaAsync(
        Post post, MediaAttachment media)
    {
        if (media.Type == MediaType.Image || media.Type == MediaType.Gif)
        {
            var imageResult = await _classifier
                .ClassifyImageAsync(media.OriginalUrl);

            if (imageResult.NSFWScore > 0.9)
            {
                await HidePostAsync(post, "NSFW image detected");
                return ModerationResult.Hidden("NSFW");
            }
        }

        if (media.Type == MediaType.Video)
        {
            var videoResult = await _classifier
                .ClassifyVideoAsync(media.OriginalUrl);

            if (videoResult.ContainsViolation)
            {
                await HandleViolationAsync(post, videoResult.Category,
                    ModerationAction.AutoRemove,
                    "Video content violation");
                return ModerationResult.Removed(videoResult.Category);
            }
        }

        return ModerationResult.Approved();
    }

    private async Task HandleViolationAsync(
        Post post, ModerationCategory category,
        ModerationAction action, string reason)
    {
        if (action == ModerationAction.AutoRemove)
        {
            await _postRepo.UpdateVisibilityAsync(
                post.PostId, ContentVisibility.Private);

            await _eventPublisher.PublishAsync(new PostModeratedEvent
            {
                PostId = post.PostId,
                AuthorId = post.AuthorId,
                Action = action,
                Category = category,
                Reason = reason,
                ModeratedAt = DateTime.UtcNow
            });
        }
    }

    private int CalculateReviewPriority(MLClassificationResult result)
    {
        if (result.Category == ModerationCategory.Threats ||
            result.Category == ModerationCategory.ChildSafety)
            return 1;

        if (result.Category == ModerationCategory.Hate ||
            result.Category == ModerationCategory.Harassment)
            return 2;

        return 3;
    }
}

Scaling Human Review

Machine learning classifiers handle the majority of moderation decisions, but edge cases and appeals require human judgment. At scale, a platform processing 500 million posts per day with a 0.1% human review rate needs 500,000 review decisions per day. This requires a global team of tens of thousands of content moderators, organized by language and expertise. The review interface must provide moderators with the full context of the content, the author's history, the community standards guidelines, and the ability to escalate decisions that involve ambiguous or unprecedented situations.

Moderator Well-being: Content moderation exposes human reviewers to disturbing content, leading to high rates of PTSD and burnout. Implement mandatory content exposure limits (e.g., 4 hours of review per day maximum), provide on-site counseling, rotate moderators across content categories, and invest in AI tools that reduce the amount of explicit content humans must view (e.g., showing only the flagged portion of a video rather than the full video).

17. Spam & Abuse Prevention

Spam and abuse prevention is an adversarial problem: as soon as you block one class of spam, attackers evolve to circumvent your defenses. The spam prevention system must be multi-layered, combining pre-publication checks, post-publication detection, behavioral analysis, and network-level signals. The goal is not to catch every piece of spam (which is impossible) but to keep the spam rate below a threshold that degrades the user experience, typically less than 0.01% of posts.

Multi-Layer Defense

C#
public class SpamDetectionService
{
    private readonly IReputationStore _reputation;
    private readonly IRateLimiter _rateLimiter;
    private readonly IContentAnalyzer _contentAnalyzer;
    private readonly ILinkChecker _linkChecker;
    private readonly IBotDetectionService _botDetector;

    public async Task<SpamVerdict> EvaluatePostAsync(
        Post post, long authorId)
    {
        var reputationScore = await _reputation
            .GetReputationScoreAsync(authorId);

        var accountAge = await _reputation
            .GetAccountAgeAsync(authorId);

        if (accountAge < TimeSpan.FromHours(24) &&
            reputationScore < 0.3)
        {
            return SpamVerdict.Suspicious("New account with low reputation");
        }

        var rateLimitResult = await _rateLimiter
            .CheckPostRateAsync(authorId);

        if (rateLimitResult.IsExceeded)
        {
            return SpamVerdict.Spam(
                $"Rate limit exceeded: {rateLimitResult.PostCount} posts " +
                $"in {rateLimitResult.WindowMinutes} minutes");
        }

        var contentResult = await _contentAnalyzer.AnalyzeAsync(
            post.Content, authorId);

        if (contentResult.IsDuplicateOfRecentPost)
        {
            return SpamVerdict.Spam("Duplicate content from same author");
        }

        if (contentResult.HasExcessiveHashtags > 10)
        {
            return SpamVerdict.Suspicious(
                $"Excessive hashtags: {contentResult.HashtagCount}");
        }

        if (contentResult.HasExcessiveMentions > 10)
        {
            return SpamVerdict.Suspicious(
                $"Excessive mentions: {contentResult.MentionCount}");
        }

        var botScore = await _botDetector
            .GetBotProbabilityAsync(authorId);

        if (botScore > 0.8)
        {
            return SpamVerdict.Suspicious(
                $"Likely bot account: score {botScore:F2}");
        }

        var engagementPatterns = await _reputation
            .GetEngagementPatternsAsync(authorId);

        if (engagementPatterns.FollowUnfollowRate > 50)
        {
            return SpamVerdict.Suspicious(
                "High follow/unfollow rate detected");
        }

        return SpamVerdict.Clean();
    }

    public async Task<double> UpdateReputationAsync(
        long userId, ReputationEvent evt)
    {
        var currentScore = await _reputation
            .GetReputationScoreAsync(userId);

        var adjustment = evt.Type switch
        {
            ReputationEventType.PostLiked => +0.001,
            ReputationEventType.PostReported => -0.05,
            ReputationEventType.AccountSuspended => -0.5,
            ReputationEventType.SpamDetected => -0.1,
            ReputationEventType.PostGoingViral => +0.01,
            _ => 0
        };

        var newScore = Math.Clamp(currentScore + adjustment, 0, 1);

        await _reputation.SetReputationScoreAsync(userId, newScore);

        return newScore;
    }
}

public class SpamVerdict
{
    public SpamStatus Status { get; set; }
    public string Reason { get; set; }
    public double Confidence { get; set; }

    public static SpamVerdict Clean() =>
        new() { Status = SpamStatus.Clean, Confidence = 1.0 };

    public static SpamVerdict Spam(string reason) =>
        new() { Status = SpamStatus.Spam, Reason = reason, Confidence = 0.9 };

    public static SpamVerdict Suspicious(string reason) =>
        new() { Status = SpamStatus.Suspicious, Reason = reason, Confidence = 0.7 };
}

Spam Patterns to Detect

Spam Type Signals Detection Method
Phishing Links Shortened URLs, known phishing domains, deceptive anchor text URL reputation database plus ML classifier on page content
Follow-Unfollow Mass follow/unfollow cycles, follow-for-follow hashtags Behavioral pattern analysis over 24-hour window
Retweet Bots Accounts that only retweet, high retweet velocity, no original content Content originality score plus account behavior features
Reply Spam Generic replies, irrelevant replies, promotional content in replies Reply-to-post similarity analysis plus link detection
Hashtag Hijacking Unrelated content using trending hashtags for visibility Semantic similarity between post content and hashtag topic
Coordinated Inauthentic Networks of accounts with synchronized behavior, shared infrastructure Graph analysis plus IP fingerprinting and behavioral clustering
Reputation System: Implement a user reputation score that ranges from 0.0 (known spammer) to 1.0 (trusted user). The score influences how aggressively the spam filter treats the user's content. New accounts start at 0.5. Positive signals (posts getting organic engagement, account age, profile completeness) increase the score. Negative signals (posts reported as spam, content removed by moderation, high bounce rates on shared links) decrease the score. Users with high reputation scores bypass certain spam checks, reducing false positives for trusted users.

18. Reliability & Failure Modes

Reliability in a social media feed system means that users can always view their feed, create posts, and interact with content, even when individual components fail. The system must be designed for graceful degradation: partial failures should degrade the user experience rather than causing total outages. This section covers the most common failure modes in a feed system, the circuit breaker patterns used to contain them, and the redundancy strategies that ensure no single point of failure.

Failure Mode Analysis

Failure Impact Detection Mitigation
Redis Timeline Cache Down Feed reads fall back to database, latency increases 10x Health check plus error rate spike Circuit breaker opens after 5 failures per 10s, falls back to PostgreSQL timeline query
Fan-out Worker Crash New posts not propagated to timelines, stale feeds Kafka consumer lag monitoring Auto-restart plus Kafka offset rewind on worker restart
Kafka Partition Unavailable Events delayed, fan-out and notifications stall Consumer group rebalance and lag alerts Multi-AZ replication plus consumer retry with backoff
PostgreSQL Primary Failover Post creation fails for 30-60 seconds during failover Replication lag monitoring and connection pool health Read replicas absorb read traffic, write queue buffers post creation attempts
ML Ranking Service Timeout Feed falls back to chronological ranking, lower engagement Latency P99 alert (over 200ms) Circuit breaker plus fallback to chronological sort
CDN Outage Media not loading, degraded feed appearance CDN health checks and error rate Multi-CDN strategy with automatic failover
C#
public class ResilientFeedService : IFeedService
{
    private readonly ITimelineCache _timelineCache;
    private readonly IPostgresTimelineStore _fallbackStore;
    private readonly CircuitBreaker _cacheCircuitBreaker;
    private readonly CircuitBreaker _rankingCircuitBreaker;
    private readonly ILogger<ResilientFeedService> _logger;

    public ResilientFeedService(
        ITimelineCache timelineCache,
        IPostgresTimelineStore fallbackStore)
    {
        _timelineCache = timelineCache;
        _fallbackStore = fallbackStore;

        _cacheCircuitBreaker = new CircuitBreaker(
            failureThreshold: 5,
            recoveryTimeout: TimeSpan.FromSeconds(30),
            halfOpenMaxAttempts: 3);

        _rankingCircuitBreaker = new CircuitBreaker(
            failureThreshold: 3,
            recoveryTimeout: TimeSpan.FromSeconds(60),
            halfOpenMaxAttempts: 2);
    }

    public async Task<FeedPage> GetFeedAsync(
        long userId, int limit, string? cursor)
    {
        var candidateIds = await GetCandidateIdsResilientAsync(userId);

        var posts = await BatchFetchPostsAsync(candidateIds);

        posts = ApplyFilters(userId, posts);

        var rankedPosts = await RankResilientAsync(userId, posts, limit);

        return new FeedPage
        {
            Posts = rankedPosts,
            NextCursor = rankedPosts.LastOrDefault()?.PostId.ToString(),
            HasMore = candidateIds.Count > limit
        };
    }

    private async Task<List<long>> GetCandidateIdsResilientAsync(
        long userId)
    {
        try
        {
            return await _cacheCircuitBreaker.ExecuteAsync(async () =>
            {
                var entries = await _timelineCache
                    .GetTimelineAsync(userId, 0, 800);
                return entries.Select(e => e.PostId).ToList();
            });
        }
        catch (CircuitBreakerOpenException)
        {
            _logger.LogWarning(
                "Cache circuit breaker open, falling back to DB for user {UserId}",
                userId);
            return await _fallbackStore
                .GetTimelineAsync(userId, 0, 100);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex,
                "Cache failure for user {UserId}, falling back to DB",
                userId);
            return await _fallbackStore
                .GetTimelineAsync(userId, 0, 100);
        }
    }

    private async Task<List<Post>> RankResilientAsync(
        long userId, List<Post> candidates, int limit)
    {
        try
        {
            return await _rankingCircuitBreaker.ExecuteAsync(async () =>
            {
                var rankingService = ResolveRankingService();
                return await rankingService
                    .RankPostsAsync(userId, candidates, limit);
            });
        }
        catch (CircuitBreakerOpenException)
        {
            _logger.LogWarning(
                "Ranking circuit breaker open, using chronological fallback");
            return candidates
                .OrderByDescending(p => p.CreatedAt)
                .Take(limit)
                .ToList();
        }
    }
}

public class CircuitBreaker
{
    private int _failureCount;
    private DateTime _lastFailureTime;
    private CircuitBreakerState _state = CircuitBreakerState.Closed;
    private readonly int _failureThreshold;
    private readonly TimeSpan _recoveryTimeout;
    private readonly int _halfOpenMaxAttempts;
    private int _halfOpenAttempts;

    public CircuitBreaker(
        int failureThreshold,
        TimeSpan recoveryTimeout,
        int halfOpenMaxAttempts)
    {
        _failureThreshold = failureThreshold;
        _recoveryTimeout = recoveryTimeout;
        _halfOpenMaxAttempts = halfOpenMaxAttempts;
    }

    public async Task<T> ExecuteAsync<T>(Func<Task<T>> action)
    {
        if (_state == CircuitBreakerState.Open)
        {
            if (DateTime.UtcNow - _lastFailureTime > _recoveryTimeout)
            {
                _state = CircuitBreakerState.HalfOpen;
                _halfOpenAttempts = 0;
            }
            else
            {
                throw new CircuitBreakerOpenException();
            }
        }

        try
        {
            var result = await action();
            OnSuccess();
            return result;
        }
        catch (Exception)
        {
            OnFailure();
            throw;
        }
    }

    private void OnSuccess()
    {
        if (_state == CircuitBreakerState.HalfOpen)
        {
            _halfOpenAttempts++;
            if (_halfOpenAttempts >= _halfOpenMaxAttempts)
            {
                _state = CircuitBreakerState.Closed;
                _failureCount = 0;
            }
        }
        else
        {
            _failureCount = 0;
        }
    }

    private void OnFailure()
    {
        _failureCount++;
        _lastFailureTime = DateTime.UtcNow;

        if (_failureCount >= _failureThreshold)
        {
            _state = CircuitBreakerState.Open;
        }
    }
}

public enum CircuitBreakerState
{
    Closed,
    Open,
    HalfOpen
}

Bulkhead Pattern

The bulkhead pattern isolates components to prevent cascading failures. In a feed system, different types of requests should be isolated from each other: celebrity feed reads should not starve normal feed reads, fan-out writes should not block feed reads, and post creation should not be affected by feed read load spikes. Each request type gets its own thread pool, connection pool, and rate limit. If one bulkhead fills up (e.g., fan-out writes are backed up), only that request type is affected while other request types continue to be served normally.

Critical Lesson: The 2018 Facebook outage was caused by a configuration change that created a DNS resolution loop, which cascaded into a complete platform failure lasting over 5 hours. The lesson: implement comprehensive health checks that verify not just that a service is running, but that it can actually reach all of its dependencies. Run chaos engineering experiments regularly (Netflix's Chaos Monkey approach) to discover failure modes before they discover you.

19. Cost Estimation

Understanding infrastructure costs is essential for making informed architectural decisions. A design that is technically elegant but financially unsustainable will not survive contact with reality. This section provides a detailed cost breakdown for operating a social media feed system at three scales: startup (1M DAU), growth (50M DAU), and enterprise (500M DAU). The costs are estimated using AWS pricing as a reference, though production deployments may use multi-cloud or on-premise infrastructure with different cost profiles.

Cost Breakdown by Category

Component 1M DAU 50M DAU 500M DAU
API Servers (Compute) $3,000/mo $80,000/mo $600,000/mo
PostgreSQL (Storage + Compute) $2,000/mo $60,000/mo $400,000/mo
Redis Cluster $1,500/mo $40,000/mo $300,000/mo
Kafka Cluster $1,000/mo $25,000/mo $200,000/mo
ML Inference (Ranking) $500/mo $30,000/mo $250,000/mo
Object Storage (Media) $500/mo $15,000/mo $150,000/mo
CDN (Media Delivery) $1,000/mo $40,000/mo $350,000/mo
Elasticsearch $1,000/mo $20,000/mo $150,000/mo
Push Notifications $200/mo $5,000/mo $40,000/mo
Monitoring and Observability $500/mo $10,000/mo $80,000/mo
Total Monthly $11,200/mo $325,000/mo $2,520,000/mo
Total Annual $134,400/yr $3,900,000/yr $30,240,000/yr

Cost per User Metrics

Metric 1M DAU 50M DAU 500M DAU
Cost per User per Month $0.011 $0.0065 $0.0050
Cost per Feed Read $0.000012 $0.000007 $0.000005
Cost per Post Created $0.000022 $0.000013 $0.000010
Infrastructure to Revenue Ratio 15-20% 10-15% 8-12%
Economies of Scale: Notice that cost per user decreases as the platform scales. This is because many infrastructure components (load balancers, monitoring, Kafka clusters) have fixed costs that are amortized over more users. The biggest cost savings at scale come from: reserved instances (30-50% discount), spot instances for batch processing (60-80% discount), and negotiated CDN contracts (volume discounts above 1 PB/month).

Cost Optimization Strategies

The largest cost drivers in a social media feed system are compute (API servers and ML inference), storage (PostgreSQL and Redis), and CDN (media delivery). Compute costs can be reduced by 30-40% through right-sizing instances and using auto-scaling groups that scale down during off-peak hours. Storage costs can be reduced by implementing tiered storage: move posts older than 90 days from SSD-backed PostgreSQL to cheaper HDD-backed or S3-backed storage. Redis costs can be reduced by compressing timeline entries (typically 5-10x compression ratio) and evicting cold users' timelines from cache. CDN costs can be reduced by generating smaller image variants for mobile users and using WebP format (30-50% smaller than JPEG).

20. Interview Q&A Cheat Sheet

This section consolidates the most frequently asked questions about social media feed systems in system design interviews, along with concise answers that demonstrate senior-level understanding. Each answer highlights the key trade-off or insight that interviewers look for.

Q1: How would you design the news feed for Twitter?

Start with the hybrid fan-out model: fan-out-on-write for normal users (under 10,000 followers) and fan-out-on-read for celebrity users. Use Redis sorted sets for timeline storage, PostgreSQL for post and user storage, and Kafka for event-driven fan-out processing. The feed read path reads from the precomputed timeline, fetches full post objects in batch, ranks them using an ML model, and returns the top N posts. The key insight to communicate is that you understand the tension between write amplification (fan-out-on-write) and read amplification (fan-out-on-read), and the hybrid approach resolves this tension for most practical scenarios.

Q2: How do you handle the celebrity user problem?

The celebrity user problem occurs when a single user has millions of followers, making fan-out-on-write prohibitively expensive. The solution is a threshold-based hybrid: users above the threshold (typically 10K-100K followers depending on the platform) use fan-out-on-read, where their posts are fetched at query time and merged with the precomputed timeline. The threshold is tunable and should be monitored continuously — if feed read latency for users following many celebrities exceeds the target, lower the threshold. Additionally, pre-cache celebrity posts in a separate Redis key that is refreshed every 5 minutes, reducing the on-read query latency for the most active celebrity accounts.

Q3: How do you ensure feed freshness while maintaining low latency?

Freshness and latency are fundamentally in tension because fresh data requires querying newer, less-cached data. The solution is a two-layer approach: the precomputed timeline provides the baseline (low latency, slightly stale), and a real-time layer adds recent posts that are not yet in the precomputed timeline. The real-time layer can be implemented as a small Redis sorted set per user that holds posts from the last 5 minutes, populated by the fan-out workers. At read time, merge the precomputed timeline with the real-time layer. This achieves sub-100ms latency with freshness under 5 seconds for 95% of feed requests.

Q4: What happens when the fan-out service is overloaded?

If the fan-out service cannot keep up with the write load, timelines will become stale. The mitigation is a multi-tiered approach: first, implement backpressure by slowing down the fan-out rate and accepting slightly higher staleness. Second, use Kafka consumer lag monitoring to detect when fan-out falls behind, and dynamically increase the number of consumer instances. Third, for extreme cases, temporarily switch all users to fan-out-on-read until the fan-out service recovers. Fourth, implement a priority queue within the fan-out topic: posts from users with high engagement rates get fan-out priority over posts from low-engagement users.

Q5: How do you design the ranking system?

The ranking system is a machine learning pipeline that predicts the probability of each user engaging with each candidate post. The model takes user features (engagement history, demographics), post features (content type, author, engagement velocity), social features (relationship strength, interaction frequency), and context features (time of day, device type) as input. A gradient boosted tree model (LightGBM) or deep learning model (two-tower neural network) produces a relevance score. The scoring must complete within 50ms per request, which requires efficient feature lookup from a feature store and batch inference. The ranking model is A/B tested continuously and promoted to production only when it shows statistically significant improvement over the current model.

Q6: How do you handle cache invalidation when a post is deleted?

Post deletion requires removing the post from every follower's timeline cache. This is done asynchronously: publish a PostDeleted event to Kafka, and consumer workers fan out the deletion to all affected timelines. However, the deletion is best-effort and may take several seconds to complete. During this window, the post may still appear in some users' feeds. To handle this, the feed service applies a soft filter at read time: even if a post ID is in the timeline, the service checks that the post still exists and is visible. A background reconciliation job runs hourly to clean up any stale entries. This lazy deletion approach is standard in distributed systems — it trades strict consistency for availability and performance.

Q7: How do you scale WebSocket connections for real-time feed updates?

WebSocket connections are stateful and long-lived, with each server supporting approximately 50,000-100,000 concurrent connections. For 300 million DAU, you need 3,000-6,000 WebSocket servers. Connections are distributed across servers using consistent hashing based on user ID, with a routing table (stored in Redis) that maps each user to their connection server. When a new post needs to be delivered, the system looks up the user's connection server from the routing table and sends the event to that specific server. Implement 30-second heartbeat intervals to detect stale connections, and support SSE fallback for clients that cannot maintain WebSocket connections.

Q8: How do you estimate the storage requirements?

For 500 million posts per day at 800 bytes per post, that is 400 GB per day or 146 TB per year. The follow graph with 120 billion edges at 32 bytes per edge is approximately 3.8 TB. Timeline caches for 300 million active users at 50 post IDs per user is approximately 480 GB. With 3x replication, total storage is approximately 500 TB per year. Adding search indexes, media metadata, and user data pushes the total to 600-800 TB per year. At $0.023/GB/month for cloud storage, that is approximately $14,000-$18,000/month for storage alone. These numbers should be validated against your actual access patterns — cold data can be moved to cheaper storage tiers.

Q9: How do you prevent spam and abuse in the feed system?

Spam prevention uses a multi-layered defense: pre-publication rate limiting (max 30 posts per hour per user), content analysis (duplicate detection, excessive hashtag/mention detection), behavioral analysis (follow-unfollow pattern detection, bot detection), and post-publication monitoring (engagement anomaly detection, user reports). The system assigns each user a reputation score that influences how aggressively spam checks are applied. High-reputation users bypass certain checks, reducing false positives. The key insight is that spam prevention is an adversarial system — you must continuously evolve your detection methods as attackers adapt. Regular A/B testing of spam classifiers and reviewing false positive/negative rates is essential.

Q10: What are the key metrics to monitor?

The most critical metrics for a feed system are: feed read latency (P50, P95, P99), fan-out lag (Kafka consumer lag), cache hit rate (L1, L2, L3), feed freshness (time since last post in feed), post creation latency, ranking model accuracy (AUC, NDCG), notification delivery rate, CDN error rate, and database replication lag. Set up alerting thresholds for each metric: feed read P99 exceeding 300ms, fan-out lag exceeding 10,000 messages, cache hit rate dropping below 95%, and database replication lag exceeding 5 seconds. Use a combination of Prometheus metrics, Grafana dashboards, and PagerDuty alerting to ensure rapid response to any degradation.

Interview Tip: The best system design answers demonstrate not just technical knowledge, but also pragmatic judgment. When you describe a design choice, always articulate the trade-off you are making and why it is the right choice for the given scale constraints. Mention what you would do differently at 10x scale. This shows interviewers that you can think beyond the immediate problem and anticipate future evolution of the system.