Design Twitter (X): The Complete System Design Guide
Building a real-time microblogging platform that handles 500M tweets/day, 330M users, and celebrity fanout at massive scale
1. Introduction — Why Twitter is a Classic System Design Problem
Twitter, now rebranded as X, is one of the most popular microblogging platforms in the world. With over 330 million monthly active users, 500 million tweets posted every day, and peak traffic spikes that can reach 100 times the normal baseline during breaking news events, Twitter represents one of the most challenging distributed systems problems in production today. The platform must serve a real-time timeline to millions of users simultaneously, handle the fanout problem where a single tweet from a celebrity like Elon Musk with over 100 million followers must appear in millions of timelines within seconds, and provide full-text search across hundreds of billions of tweets.
The core challenge in designing Twitter is the intersection of a social graph with a real-time publishing system. Unlike a simple content delivery network where static content is cached and served, Twitter requires dynamic content that changes with every new tweet, every new follow, and every engagement event. The fanout problem is the defining architectural decision: when a user with millions of followers posts a tweet, should the system pre-compute the timeline for every follower (fanout-on-write), or should it merge tweets at query time when the user opens the app (fanout-on-read)? Twitter's production system uses a hybrid approach, and understanding why is the key insight of this system design exercise.
This guide walks through every major component of Twitter's architecture, from the tweet posting pipeline to the timeline generation service, from the search index backed by Elasticsearch to the notification system that pushes alerts to mobile devices. We will cover C# code implementations, Mermaid architecture diagrams, database schemas, capacity estimates, and production-grade patterns for handling the hardest scalability challenges. By the end of this guide, you will have a complete mental model of how to design a Twitter-like system from scratch.
2. Functional and Non-Functional Requirements
Functional Requirements
| # | Requirement | Priority | Details |
|---|---|---|---|
| F1 | Post a tweet | Must | User can post a tweet with up to 280 characters, images, videos, and polls |
| F2 | View home timeline | Must | User sees a ranked feed of tweets from accounts they follow |
| F3 | View user timeline | Must | User can view all tweets from a specific account in reverse chronological order |
| F4 | Follow / Unfollow users | Must | User can follow or unfollow other accounts |
| F5 | Like and retweet | Must | User can like, retweet, and reply to tweets |
| F6 | Search tweets | Must | Full-text search across tweets by keyword, hashtag, and mention |
| F7 | Notifications | Should | Push and in-app notifications for likes, retweets, follows, and mentions |
| F8 | Trending topics | Should | Real-time trending hashtags and topics based on volume and velocity |
| F9 | Direct messages | Nice | Private one-to-one and group messaging |
| F10 | Media upload | Must | Upload and serve images, GIFs, and videos with CDN delivery |
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Availability | 99.99% (21.9 min downtime/year) | Social media is a always-on platform; downtime during major events is unacceptable |
| Latency (timeline) | p99 < 200ms | Timeline must load instantly to maintain user engagement |
| Latency (tweet post) | p99 < 500ms | Tweet posting should feel immediate with optimistic UI |
| Consistency | Eventual consistency | Timeline can be slightly stale; search index lags by 1-3 seconds |
| Throughput (reads) | 600K QPS | 400M DAU x 15 reads/day average across timeline, profiles, search |
| Throughput (writes) | 12K QPS average, 30K peak | 500M tweets/day = ~5,800 tweets/sec average with 5x peak |
| Durability | 99.999999% | Tweets are permanent records; zero data loss is required |
| Storage | 182 TB/year for tweets | 500M tweets x 1KB average = 500GB/day raw data |
3. Capacity Estimation and Back-of-Envelope Calculations
Write Path
With 500 million tweets per day, the average write throughput is 500,000,000 / 86,400 = approximately 5,787 tweets per second. During peak events such as the Super Bowl, World Cup, or breaking political news, this can spike to 15,000-30,000 tweets per second. Each tweet requires one write to the tweets database and one publish to the Kafka fanout topic. The fanout service then performs additional writes to timeline caches. For a user with 1,000 followers, each tweet generates 1,000 additional writes to Redis sorted sets. The total write amplification factor depends on the average follower count and the fanout threshold.
Read Path
With 400 million daily active users loading their timeline 5-10 times per day, we get 2 billion to 4 billion timeline reads per day. This translates to 23,000-46,000 reads per second on average, with peaks of 100,000-200,000 reads per second. Search queries add another 2 billion per day or approximately 23,000 QPS. Profile page views contribute another 10,000 QPS. The total read load is dominated by timeline reads, which is why caching and the fanout strategy are the most critical architectural decisions.
Storage Estimates
| Entity | Size per Record | Daily Volume | Daily Storage | Annual Storage |
|---|---|---|---|---|
| Tweets | ~1 KB (text metadata) | 500 million | 500 GB | 182 TB |
| Media (images) | ~300 KB average | 200 million | 60 TB | 22 PB |
| Media (videos) | ~5 MB average | 20 million | 100 TB | 36.5 PB |
| Follow relationships | ~50 bytes | 10 million changes | 500 MB | 182 GB |
| Timeline caches | ~8 bytes per tweet ID | 2 billion entries | 16 GB | 5.8 TB |
| Search index | ~2x tweet size | 500 million | 1 TB | 365 TB |
Bandwidth Estimates
Outbound bandwidth for serving timelines: 600K QPS x 20 tweets per page x 1 KB per tweet = approximately 12 GB/s outbound. With CDN for media, the CDN handles 160 TB/day of media delivery which translates to approximately 1.85 GB/s average. Inbound bandwidth for tweet ingestion: 5,787 tweets/sec x 1 KB = approximately 5.7 MB/s for tweet text, plus media uploads at approximately 100 GB/s during peaks. The system is read-heavy with a read-to-write ratio of approximately 100:1.
Key Numbers Summary
4. High-Level Architecture and Core Services
The Twitter system architecture consists of several core services that work together to handle the complete lifecycle of a tweet: from the moment a user types and posts a tweet, through the fanout pipeline that distributes it to followers' timelines, to the ranking engine that orders the timeline, and finally to the client that renders the feed. The architecture follows a service-oriented design with clear boundaries between the write path (tweet posting) and the read path (timeline generation, search, profile views).
Core Services Breakdown
| Service | Responsibility | Technology | Scale |
|---|---|---|---|
| Tweet Service | Accept new tweets, validate content, write to DB, publish to Kafka | C# / .NET, gRPC | 6K-30K QPS writes |
| Timeline Service | Assemble home feed by merging pre-computed and on-demand timelines | C# / .NET, Redis | 600K QPS reads |
| Fanout Service | Distribute tweet IDs to followers' timeline caches | C# Workers, Kafka consumers | 1M+ fanout operations/sec |
| Search Service | Index tweets and serve search queries | Elasticsearch cluster | 23K QPS search |
| User Service | User profiles, follow/unfollow, social graph queries | C# / .NET, FlockDB | 50K QPS reads |
| Notification Service | Push notifications, in-app alerts, email digests | C# / .NET, FCM, APNs | 100K notifications/sec |
| Media Service | Image/video upload, transcoding, CDN delivery | C# / .NET, FFmpeg, S3 | 100 GB/s peak |
5. Data Model and Storage Schema
The data model for Twitter must support efficient lookups for several access patterns: fetching a user's tweets, fetching a user's timeline, checking follow relationships, and searching tweet content. The primary entities are User, Tweet, Follow, and Timeline. Each entity has different storage requirements and access patterns, which is why Twitter uses a polyglot persistence approach with different databases for different workloads.
// Twitter Data Model - PostgreSQL / Manhattan Schema
CREATE TABLE users (
user_id BIGSERIAL PRIMARY KEY,
username VARCHAR(15) UNIQUE NOT NULL,
display_name VARCHAR(50),
bio TEXT,
profile_image_url VARCHAR(500),
banner_image_url VARCHAR(500),
follower_count INT DEFAULT 0,
following_count INT DEFAULT 0,
tweet_count INT DEFAULT 0,
is_verified BOOLEAN DEFAULT FALSE,
is_celebrity BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE tweets (
tweet_id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES users(user_id),
content VARCHAR(280),
media_urls TEXT[],
hashtags TEXT[],
mentions BIGINT[],
reply_to_tweet_id BIGINT,
retweet_of_tweet_id BIGINT,
like_count INT DEFAULT 0,
retweet_count INT DEFAULT 0,
reply_count INT DEFAULT 0,
quote_count INT DEFAULT 0,
view_count BIGINT DEFAULT 0,
language VARCHAR(10),
is_sensitive BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE follows (
follower_id BIGINT REFERENCES users(user_id),
followee_id BIGINT REFERENCES users(user_id),
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (follower_id, followee_id)
);
CREATE TABLE likes (
user_id BIGINT REFERENCES users(user_id),
tweet_id BIGINT REFERENCES tweets(tweet_id),
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (user_id, tweet_id)
);
CREATE TABLE retweets (
user_id BIGINT REFERENCES users(user_id),
tweet_id BIGINT REFERENCES tweets(tweet_id),
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (user_id, tweet_id)
);
CREATE INDEX idx_tweets_user_created ON tweets(user_id, created_at DESC);
CREATE INDEX idx_tweets_created ON tweets(created_at DESC);
CREATE INDEX idx_follows_follower ON follows(follower_id);
CREATE INDEX idx_follows_followee ON follows(followee_id);
Storage Technology Choices
| Data | Primary Store | Cache Layer | Access Pattern |
|---|---|---|---|
| Tweets | Manhattan (distributed KV) | Redis (hot tweets) | Read-heavy, append-only writes |
| User profiles | Manhattan | Redis | Read-heavy, infrequent updates |
| Follow graph | FlockDB (graph) | Redis sets | Read-heavy, adjacency list queries |
| Timeline caches | Redis sorted sets | N/A (is the cache) | Write-heavy on fanout, read-heavy on timeline |
| Search index | Elasticsearch (Earlybird) | N/A | Write-once, read-heavy search |
| Likes / Retweets | Manhattan | Redis | Write-once, read for counts and checks |
Why Manhattan Over Traditional RDBMS?
Manhattan is Twitter's custom distributed key-value store that provides multi-dimensional indexing, automatic sharding across data centers, and tunable consistency. Traditional RDBMS systems like PostgreSQL struggle at Twitter's scale because a single table with hundreds of billions of rows cannot be efficiently sharded with range-based partitioning. Manhattan stores data across thousands of machines with consistent hashing on the tweet_id, ensuring均匀 distribution. It also provides point-in-time recovery and replication across three data centers for durability.
6. Tweet Posting Pipeline and Write Path
When a user taps the tweet button, the client sends a POST request to the Tweet Service. The write path involves several steps: content validation, media processing, database write, Kafka publish, and fanout triggering. The critical design goal is to return success to the user as fast as possible (under 500ms) while ensuring the tweet eventually appears in all relevant timelines. This is achieved through optimistic writes and asynchronous fanout processing.
// Tweet Posting Service - C# Implementation
public class TweetService
{
private readonly ITweetRepository _tweetRepo;
private readonly IKafkaProducer _kafkaProducer;
private readonly IContentValidator _contentValidator;
private readonly IMediaService _mediaService;
public async Task<TweetResponse> PostTweetAsync(PostTweetRequest request)
{
// Step 1: Validate content
var validation = _contentValidator.Validate(request.Content);
if (!validation.IsValid)
throw new ValidationException(validation.Errors);
// Step 2: Process media attachments
var mediaUrls = new List<string>();
if (request.MediaIds?.Any() == true)
{
mediaUrls = await _mediaService.ProcessMediaAsync(request.MediaIds);
}
// Step 3: Extract hashtags and mentions
var hashtags = ExtractHashtags(request.Content);
var mentions = ExtractMentions(request.Content);
// Step 4: Create tweet entity
var tweet = new Tweet
{
TweetId = SnowflakeIdGenerator.NextId(),
UserId = request.UserId,
Content = SanitizeContent(request.Content),
MediaUrls = mediaUrls.ToArray(),
Hashtags = hashtags.ToArray(),
Mentions = mentions.ToArray(),
ReplyToTweetId = request.ReplyToTweetId,
RetweetOfTweetId = request.RetweetOfTweetId,
Language = DetectLanguage(request.Content),
CreatedAt = DateTime.UtcNow
};
// Step 5: Write to database (durable storage)
await _tweetRepo.InsertAsync(tweet);
// Step 6: Publish to Kafka for async fanout
await _kafkaProducer.ProduceAsync("tweet-events", new TweetEvent
{
TweetId = tweet.TweetId,
UserId = tweet.UserId,
EventType = tweet.RetweetOfTweetId.HasValue
? TweetEventType.Retweet
: TweetEventType.NewTweet,
Timestamp = tweet.CreatedAt,
FollowerCount = await GetFollowerCountAsync(tweet.UserId)
});
// Step 7: Index in search (async via Kafka consumer)
// Step 8: Update user tweet count (async)
return new TweetResponse
{
TweetId = tweet.TweetId,
CreatedAt = tweet.CreatedAt,
Status = "posted"
};
}
private List<string> ExtractHashtags(string content)
{
var matches = Regex.Matches(content, @"#(\w+)");
return matches.Select(m => m.Groups[1].Value.ToLower()).ToList();
}
private List<long> ExtractMentions(string content)
{
var matches = Regex.Matches(content, @"@(\w+)");
return matches.Select(m => ResolveUsername(m.Groups[1].Value))
.Where(id => id.HasValue)
.Select(id => id.Value).ToList();
}
}
Kafka Topic Design for Tweet Events
// Kafka topic configuration for tweet events
// Topic: tweet-events
// Partitions: 128 (one per million follower range)
// Replication factor: 3
// Retention: 7 days
public class TweetEvent
{
public long TweetId { get; set; }
public long UserId { get; set; }
public TweetEventType EventType { get; set; }
public DateTime Timestamp { get; set; }
public int FollowerCount { get; set; }
}
public enum TweetEventType
{
NewTweet = 1,
Reply = 2,
Retweet = 3,
Quote = 4,
Delete = 5
}
// Kafka partitioning strategy: partition by user_id % 128
// This ensures all events from the same user go to the same partition
// maintaining ordering per user
7. Fanout-on-Write vs Fanout-on-Read
The fanout strategy is the single most important architectural decision in Twitter's design. Fanout is the process of distributing a tweet from the author to all relevant followers' timelines. There are two fundamental approaches, and Twitter uses a hybrid of both.
Fanout-on-Write (Push Model)
When a user posts a tweet, the fanout service immediately inserts the tweet ID into the timeline cache (a Redis sorted set) of every follower. When a follower opens the app, their timeline is already pre-computed and ready to serve. The advantage is extremely fast timeline reads (O(1) lookup in Redis). The disadvantage is write amplification: a user with 100,000 followers generates 100,000 write operations per tweet. For users with millions of followers, this becomes prohibitively expensive.
Fanout-on-Read (Pull Model)
Tweets are stored in the author's profile, and when a user opens their timeline, the system fetches recent tweets from all followed accounts and merges them. The advantage is zero write amplification: posting a tweet is always a single write. The disadvantage is high read latency: for a user following 500 accounts, the timeline service must fetch from 500 different sources and merge. This becomes even worse if some of those accounts are celebrities with thousands of recent tweets.
Twitter's Hybrid Approach
Twitter uses fanout-on-write for regular users with fewer than 5,000 followers, and fanout-on-read for celebrity accounts with more than 5,000 followers. The threshold of 5,000 is chosen based on the analysis that the top 1% of accounts (the celebrities) account for the majority of timeline traffic but are expensive to push to all followers. By using pull for celebrities, the system avoids millions of unnecessary writes per celebrity tweet while keeping the timeline fast for the 99% of users who follow mostly regular accounts.
// Hybrid Fanout Service - C# Implementation
public class FanoutService
{
private readonly IRedisCluster _redis;
private readonly IFollowerStore _followerStore;
private readonly ITweetCache _tweetCache;
private const int CelebrityThreshold = 5000;
public async Task FanoutTweetAsync(TweetEvent tweetEvent)
{
var followerCount = tweetEvent.FollowerCount;
if (followerCount <= CelebrityThreshold)
{
// Fanout-on-Write: Push to all followers' timelines
await PushToFollowersAsync(tweetEvent);
}
else
{
// Fanout-on-Read: Store in celebrity tweet cache
await StoreInCelebrityCacheAsync(tweetEvent);
}
// Always index in search regardless of fanout strategy
await IndexTweetForSearchAsync(tweetEvent);
}
private async Task PushToFollowersAsync(TweetEvent tweetEvent)
{
var followerIds = await _followerStore.GetFollowerIdsAsync(
tweetEvent.UserId, limit: 10000);
// Batch the writes using Redis pipeline
var batchSize = 100;
for (int i = 0; i < followerIds.Count; i += batchSize)
{
var batch = followerIds.Skip(i).Take(batchSize).ToList();
var pipeline = _redis.CreatePipeline();
foreach (var followerId in batch)
{
// Add tweet to follower's timeline sorted set
pipeline.ZAdd(
$"timeline:{followerId}",
tweetEvent.Timestamp.Ticks,
tweetEvent.TweetId.ToString());
// Trim timeline to most recent 800 tweets
pipeline.ZRemRangeByRank(
$"timeline:{followerId}", 0, -801);
// Increment unread count
pipeline.HIncrBy(
$"user:{followerId}:unread", "timeline", 1);
}
await pipeline.ExecuteAsync();
}
}
private async Task StoreInCelebrityCacheAsync(TweetEvent tweetEvent)
{
// Store in a separate Redis list for celebrity tweets
var key = $"celebrity_tweets:{tweetEvent.UserId}";
var tweetData = JsonSerializer.Serialize(new
{
TweetId = tweetEvent.TweetId,
Timestamp = tweetEvent.Timestamp
});
await _redis.LPushAsync(key, tweetData);
await _redis.LTrimAsync(key, 0, 199); // Keep last 200 tweets
await _redis.ExpireAsync(key, TimeSpan.FromHours(24));
// Mark this user as a celebrity for timeline assembly
var celebKey = $"celebrity_follows:{tweetEvent.UserId}";
// Followers will check this set during timeline generation
}
}
Fanout Strategy Comparison
| Aspect | Fanout-on-Write | Fanout-on-Read | Hybrid (Twitter) |
|---|---|---|---|
| Write cost per tweet | O(followers) - high | O(1) - constant | O(followers) if small, O(1) if celebrity |
| Read cost per timeline | O(1) - pre-computed | O(followed accounts) - high | O(1) + O(celebrity followed) |
| Timeline freshness | Immediate | Immediate | Immediate for small, 1-2s delay for celebrity |
| Storage cost | High (N timeline copies) | Low (only source tweets) | Medium (push for regular, pull for celebrity) |
| Celebrity tweet cost | Prohibitive (millions of writes) | Cheap | Cheap (uses pull path) |
| Implementation | Simple | Complex merge logic | Medium complexity |
8. Timeline Generation and Home Feed
Timeline generation is the process of assembling a user's home feed when they open the Twitter app. The timeline service must merge tweets from two sources: the pre-computed timeline (from fanout-on-write) for regular followed accounts, and the on-demand celebrity tweets (from fanout-on-read) for celebrity accounts. The merged timeline is then ranked, hydrated with full tweet content, and paginated for delivery to the client.
// Timeline Generation Service - C# Implementation
public class TimelineService
{
private readonly IRedisCluster _redis;
private readonly ITweetCache _tweetCache;
private readonly ICelebrityService _celebrityService;
private readonly IRankingService _rankingService;
public async Task<TimelineResponse> GetHomeTimelineAsync(
long userId, long? cursor, int limit = 20)
{
var startTime = DateTime.UtcNow;
// Step 1: Fetch pre-computed timeline from Redis
// (tweets pushed by fanout-on-write from regular followed accounts)
var maxScore = cursor.HasValue
? cursor.Value.ToString()
: "+inf";
var tweetIds = await _redis.ZRevRangeByScoreAsync(
$"timeline:{userId}",
maxScore,
"-inf",
skip: 0,
take: 200); // Fetch 200 to have enough after ranking
// Step 2: Hydrate tweet objects from cache
var tweets = new List<Tweet>();
foreach (var tweetId in tweetIds)
{
var tweet = await _tweetCache.GetTweetAsync(long.Parse(tweetId));
if (tweet != null)
tweets.Add(tweet);
}
// Step 3: Fetch celebrity tweets (fanout-on-read path)
var celebrityIds = await _celebrityService
.GetCelebrityFolloweesAsync(userId);
foreach (var celebId in celebrityIds)
{
var celebTweetIds = await _redis.LRangeAsync(
$"celebrity_tweets:{celebId}", 0, 9);
foreach (var celebTweetId in celebTweetIds)
{
var celebTweet = await _tweetCache.GetTweetAsync(
long.Parse(celebTweetId));
if (celebTweet != null)
tweets.Add(celebTweet);
}
}
// Step 4: Remove duplicates (user might see tweet from both paths)
tweets = tweets.DistinctBy(t => t.TweetId).ToList();
// Step 5: Remove tweets the user has already seen
var lastSeenId = await _redis.GetAsync(
$"user:{userId}:last_seen_timeline_id");
if (lastSeenId != null)
{
tweets = tweets.Where(t =>
t.TweetId > long.Parse(lastSeenId)).ToList();
}
// Step 6: Rank the tweets
var viewerContext = await BuildViewerContextAsync(userId);
var rankedTweets = tweets
.Select(t => new { Tweet = t, Score = _rankingService.RankTweet(t, viewerContext) })
.OrderByDescending(x => x.Score)
.Take(limit)
.Select(x => x.Tweet)
.ToList();
// Step 7: Build cursor for next page
var nextCursor = rankedTweets.Any()
? rankedTweets.Last().TweetId
: (long?)null;
// Step 8: Update last seen timestamp
await _redis.SetAsync(
$"user:{userId}:last_seen_timeline_id",
rankedTweets.FirstOrDefault()?.TweetId.ToString(),
TimeSpan.FromDays(7));
return new TimelineResponse
{
Tweets = rankedTweets,
NextCursor = nextCursor,
HasMore = tweetIds.Count == 200,
LatencyMs = (DateTime.UtcNow - startTime).TotalMilliseconds
};
}
}
User Timeline vs Home Timeline
| Aspect | Home Timeline | User Timeline |
|---|---|---|
| Data source | Merged from all followed accounts + celebrity cache | Direct query on user's own tweets |
| Ranking | ML-ranked by engagement, recency, affinity | Reverse chronological (no ranking) |
| Cache layer | Redis sorted set + celebrity Redis list | Redis sorted set per user |
| Read throughput | 200K QPS peak | 50K QPS peak |
| Fanout required | Yes (for non-celebrity follows) | No (direct query) |
9. Timeline Ranking and Relevance Algorithm
The naive reverse-chronological timeline shows tweets in the order they were posted. While simple, this approach misses important tweets that the user would find engaging. Twitter's ranking algorithm (internally known as Earlybird for search and Home Mixer for timeline) uses a multi-signal scoring model to reorder tweets based on predicted engagement. The ranking model considers recency, engagement velocity, author affinity, content type, and language relevance.
The ranking pipeline works in two phases. First, a lightweight pre-ranking model scores the top 200 candidate tweets from the cache. This pre-ranking uses a simple linear model that can be computed in under 10ms. Second, a heavier ML model re-ranks the top 50 candidates using more expensive features like user engagement history, tweet virality prediction, and social graph distance. The final ranked list is what the user sees on their home timeline.
// Timeline Ranking Service - C# Implementation
public class RankingService
{
private readonly IEngagementStore _engagementStore;
private readonly IAffinityService _affinityService;
public double RankTweet(Tweet tweet, ViewerContext context)
{
// Signal 1: Recency (exponential decay over 24 hours)
var hoursSincePosted = (DateTime.UtcNow - tweet.CreatedAt).TotalHours;
var recencyScore = Math.Exp(-0.1 * hoursSincePosted);
// Signal 2: Engagement velocity (engagements per hour)
var ageHours = Math.Max(1, hoursSincePosted);
var totalEngagements = tweet.LikeCount
+ tweet.RetweetCount * 2
+ tweet.ReplyCount * 3
+ tweet.QuoteCount * 2;
var velocityScore = Math.Log10(totalEngagements + 1) / Math.Log10(ageHours + 1);
// Signal 3: Author affinity (how much user interacts with this author)
var affinityScore = _affinityService.GetAffinityScore(
context.UserId, tweet.UserId);
// Signal 4: Content type bonus (media-rich tweets get boost)
var mediaBoost = tweet.MediaUrls?.Length > 0 ? 1.5 : 0;
// Signal 5: Language relevance
var languageMatch = tweet.Language == context.PreferredLanguage ? 1.0 : 0.3;
// Signal 6: Is this a reply? Replies get lower weight
var replyPenalty = tweet.ReplyToTweetId.HasValue ? 0.4 : 1.0;
// Signal 7: Author verification bonus
var verificationBonus = tweet.IsAuthorVerified ? 1.2 : 1.0;
// Weighted combination
var finalScore =
recencyScore * 0.30 +
velocityScore * 0.25 +
affinityScore * 0.20 +
mediaBoost * 0.08 +
languageMatch * 0.07 +
replyPenalty * 0.05 +
verificationBonus * 0.05;
return finalScore;
}
public List<Tweet> RerankWithMLModel(
List<Tweet> candidates, ViewerContext context)
{
// Phase 2: Heavy ML re-ranking on top 50 candidates
// This calls a trained model (e.g., XGBoost or neural network)
// that considers deeper features:
// - User's historical engagement with similar content
// - Tweet virality prediction (will this tweet get 10x more engagement?)
// - Social proof (have the user's friends engaged with this tweet?)
// - Temporal patterns (does user usually engage at this time of day?)
var features = candidates.Select(tweet =>
ExtractFeatures(tweet, context)).ToArray();
var predictions = _mlModel.PredictBatch(features);
return candidates
.Zip(predictions, (tweet, score) => new { Tweet = tweet, Score = score })
.OrderByDescending(x => x.Score)
.Select(x => x.Tweet)
.Take(20)
.ToList();
}
private float[] ExtractFeatures(Tweet tweet, ViewerContext context)
{
return new float[]
{
(float)(DateTime.UtcNow - tweet.CreatedAt).TotalHours,
tweet.LikeCount,
tweet.RetweetCount,
tweet.ReplyCount,
tweet.MediaUrls?.Length ?? 0,
tweet.Content.Length,
_affinityService.GetAffinityScore(context.UserId, tweet.UserId),
context.FollowerCount,
tweet.IsAuthorVerified ? 1f : 0f,
tweet.Language == context.PreferredLanguage ? 1f : 0f
};
}
}
Ranking Signal Weights
| Signal | Weight | Why It Matters |
|---|---|---|
| Recency | 0.30 | Users expect fresh content; tweets older than 24 hours get exponentially penalized |
| Engagement velocity | 0.25 | Tweets gaining rapid engagement indicate viral or high-quality content |
| Author affinity | 0.20 | Users care more about tweets from accounts they frequently interact with |
| Media content | 0.08 | Tweets with images and videos get 2-3x more engagement on average |
| Language match | 0.07 | Users prefer content in their preferred language |
| Reply penalty | 0.05 | Replies to other tweets are less interesting as standalone timeline content |
| Verification bonus | 0.05 | Verified accounts tend to produce higher quality content |
10. Real-Time Search with Elasticsearch
Twitter search handles over 2 billion queries per day, making it one of the largest Elasticsearch deployments in the world. The search system, internally known as Earlybird, provides full-text search across hundreds of billions of tweets with near-real-time indexing (1-3 seconds from tweet post to search availability). The system must handle a wide variety of query types: keyword search, hashtag lookup, mention filtering, user-specific search, and advanced query operators like exclusion and phrase matching.
The search architecture uses a custom Elasticsearch cluster with several optimizations. The index is sharded across hundreds of nodes with a custom routing strategy that ensures tweets from the same time period are co-located on the same shards. This allows the search system to efficiently handle time-range queries, which are the most common search pattern. The index uses a two-tier structure: a small in-memory segment for the most recent tweets (last 24 hours) and a larger on-disk segment for older tweets.
// Elasticsearch Index Mapping for Tweets
PUT /tweets_v2
{
"settings": {
"number_of_shards": 48,
"number_of_replicas": 2,
"refresh_interval": "1s",
"index.routing.partition_size": 6
},
"mappings": {
"properties": {
"tweet_id": { "type": "long" },
"user_id": { "type": "long" },
"content": { "type": "text", "analyzer": "english",
"fields": {
"keyword": { "type": "keyword" }
}
},
"hashtags": { "type": "keyword" },
"mentions": { "type": "long" },
"language": { "type": "keyword" },
"like_count": { "type": "integer" },
"retweet_count": { "type": "integer" },
"reply_count": { "type": "integer" },
"view_count": { "type": "long" },
"has_media": { "type": "boolean" },
"is_reply": { "type": "boolean" },
"is_retweet": { "type": "boolean" },
"created_at": { "type": "date" },
"author_verified":{ "type": "boolean" }
}
}
}
// Search Service - C# Implementation
public class SearchService
{
private readonly IElasticClient _elastic;
public async Task<SearchResponse> SearchTweetsAsync(SearchRequest request)
{
var searchDescriptor = new SearchDescriptor<TweetDocument>()
.Index("tweets_v2")
.Size(request.Limit ?? 20)
.From(request.Offset ?? 0)
.Query(q =>
{
var queries = new List<IQueryContainer>();
// Parse query type
if (request.Query.StartsWith("#"))
{
// Hashtag search
queries.Add(q.Term(t =>
t.Field(f => f.Hashtags)
.Value(request.Query.Substring(1).ToLower())));
}
else if (request.Query.StartsWith("@"))
{
// Mention search
var userId = await ResolveUsername(request.Query.Substring(1));
queries.Add(q.Term(t =>
t.Field(f => f.UserId).Value(userId)));
}
else
{
// Full-text search with BM25
queries.Add(q.MultiMatch(mm =>
mm.Fields(f =>
f.Field(ff => ff.Content, 2.0)
.Field(ff => ff.Hashtags, 1.5))
.Query(request.Query)
.Type(TextQueryType.BestFields)
.Fuzziness(Fuzziness.Auto)));
}
// Time range filter
if (request.Since.HasValue)
{
queries.Add(q.DateRange(dr =>
dr.Field(f => f.CreatedAt)
.GreaterThanOrEquals(request.Since.Value)));
}
// Language filter
if (!string.IsNullOrEmpty(request.Language))
{
queries.Add(q.Term(t =>
t.Field(f => f.Language)
.Value(request.Language)));
}
// Exclude retweets if requested
if (request.ExcludeRetweets)
{
queries.Add(q.Term(t =>
t.Field(f => f.IsRetweet).Value(false)));
}
return q.Bool(b => b.Must(queries.ToArray()));
})
.Sort(s =>
{
// Sort by relevance score (BM25) + engagement boost
if (request.SortBy == "engagement")
return s.Descending(f =>
Field("like_count") + Field("retweet_count") * 2);
return s.Descending(SortSpecialField.Score);
})
.Highlight(h =>
h.PreTags("<mark>")
.PostTags("</mark>")
.Fields(
f => f.Field(ff => ff.Content)
.FragmentSize(150)
.NumberOfFragments(1)));
var response = await _elastic.SearchAsync<TweetDocument>(searchDescriptor);
return new SearchResponse
{
Tweets = response.Documents.ToList(),
TotalHits = response.Total,
took = response.TookMilliseconds,
Highlights = response.Hits.Select(h => h.Highlight).ToList()
};
}
}
Search Architecture Components
| Component | Technology | Role |
|---|---|---|
| Real-time index | In-memory segment (custom) | Indexes tweets within 1-3 seconds for latest 24 hours |
| Search index | Elasticsearch cluster (48 shards) | Full-text search across all historical tweets |
| Autocomplete | Trie-based service | Suggests hashtags, usernames, and trending queries |
| Spelling correction | Edit-distance model | Corrects typos in search queries |
| Anti-spam filter | ML classifier | Removes spam and low-quality tweets from search results |
11. Social Graph Storage and Follow Relationships
The social graph is the foundation of Twitter's architecture. Every feature — from the home timeline to notifications to search — depends on knowing who follows whom. At Twitter's scale, the social graph contains over 100 billion follow relationships stored across thousands of machines. The primary access patterns are: given a user, return their followers (fanout-on-write needs this), given a user, return who they follow (timeline generation needs this), and given two users, check if one follows the other (authorization checks).
Twitter uses FlockDB, a custom graph database designed for shallow, wide graph traversals. Unlike Neo4j or other graph databases that excel at deep traversals (friend-of-friend recommendations), FlockDB is optimized for the specific pattern of "get all edges incident to this node." The data model is simple: a edges table with columns (source_id, destination_id, status, created_at), sharded by source_id. This ensures that all follow relationships for a single user are co-located on the same shard, enabling efficient range scans.
// Social Graph Service - C# Implementation
public class SocialGraphService
{
private readonly IFlockDBClient _flockDb;
private readonly IRedisCluster _redis;
public async Task FollowUserAsync(long followerId, long followeeId)
{
// Validate both users exist
var users = await _userService.GetUsersAsync(
new[] { followerId, followeeId });
if (users.Count != 2)
throw new NotFoundException("User not found");
// Write to FlockDB (primary store)
await _flockDb.InsertEdgeAsync(new GraphEdge
{
SourceId = followerId,
DestinationId = followeeId,
Status = EdgeStatus.Active,
CreatedAt = DateTime.UtcNow
});
// Update follower/following counts (async)
await _eventBus.PublishAsync(new FollowEvent
{
FollowerId = followerId,
FolloweeId = followeeId,
Action = FollowAction.Follow
});
// Invalidate caches
await _redis.SetRemoveAsync(
$"followers:{followeeId}:set", followerId.ToString());
await _redis.SetAddAsync(
$"following:{followerId}:set", followeeId.ToString());
// Update fanout strategy if needed
var followerCount = await GetFollowerCountAsync(followeeId);
if (followerCount >= FanoutService.CelebrityThreshold)
{
await _redis.SetAddAsync("celebrity_users", followeeId.ToString());
}
}
public async Task<List<long>> GetFollowersAsync(
long userId, int offset = 0, int limit = 100)
{
// Try cache first
var cached = await _redis.ZRangeAsync(
$"followers:{userId}", offset, offset + limit - 1);
if (cached.Length == limit)
return cached.Select(long.Parse).ToList();
// Fall back to FlockDB
var followers = await _flockDb.GetEdgesAsync(
sourceId: null,
destinationId: userId,
status: EdgeStatus.Active,
offset: offset,
limit: limit);
return followers.Select(e => e.SourceId).ToList();
}
public async Task<bool> IsFollowingAsync(long followerId, long followeeId)
{
// Check Redis set for fast lookup
return await _redis.SetContainsAsync(
$"following:{followerId}:set", followeeId.ToString());
}
public async Task<int> GetFollowerCountAsync(long userId)
{
var count = await _redis.GetLongAsync($"user:{userId}:follower_count");
if (count.HasValue) return (int)count.Value;
// Recompute from FlockDB if cache miss
var countValue = await _flockDb.CountEdgesAsync(
sourceId: null,
destinationId: userId,
status: EdgeStatus.Active);
await _redis.SetLongAsync(
$"user:{userId}:follower_count", countValue, TimeSpan.FromMinutes(5));
return (int)countValue;
}
}
FlockDB Sharding Strategy
FlockDB shards edges by source_id (the follower), meaning all of a user's follow relationships are stored together. This is optimal for the "get who I follow" query pattern needed by timeline generation. For the reverse query "get my followers" (needed by fanout), FlockDB maintains a secondary index on destination_id. The MySQL backing store provides durability while Redis provides the hot cache layer for frequently accessed adjacency lists.
12. Handling Viral Events and Thundering Herds
Viral events are the ultimate stress test for Twitter's architecture. When a major event occurs — a celebrity death, a World Cup final, an election result — millions of users flood the platform simultaneously. Traffic can spike 100x within minutes. The system must handle this gracefully without cascading failures. Twitter has experienced several high-profile outages during viral events, which drove the development of several resilience patterns.
The thundering herd problem occurs when a popular tweet triggers millions of timeline reads simultaneously, overwhelming the cache and database layers. The first defense is request coalescing: when 10,000 concurrent requests arrive for the same celebrity's timeline, only one backend fetch is made, and the result is shared across all waiting requests. The second defense is stale-while-revalidate: if the cache is being rebuilt, serve the stale version with a header indicating it may be slightly outdated. The third defense is progressive degradation: during extreme load, the system can reduce the number of tweets per timeline page, disable ranking, or serve a simplified chronological feed.
// Request Coalescing and Circuit Breaker - C# Implementation
public class CoalescedTimelineFetcher
{
private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();
private readonly ConcurrentDictionary<string, Task<TimelineResult>> _inflight = new();
private readonly ICircuitBreaker _circuitBreaker;
public async Task<TimelineResult> FetchWithCoalescingAsync(
long userId, long? celebId)
{
var key = $"timeline:{userId}:{celebId}";
// Check if there is already an in-flight request for this key
if (_inflight.TryGetValue(key, out var existingTask))
{
// Coalesce: wait for the existing request to complete
return await existingTask;
}
// Create a new request with coalescing
var tcs = new TaskCompletionSource<TimelineResult>();
if (_inflight.TryAdd(key, tcs.Task))
{
try
{
// Use circuit breaker to prevent cascading failures
var result = await _circuitBreaker.ExecuteAsync(async () =>
{
return await FetchTimelineFromBackend(userId, celebId);
});
tcs.SetResult(result);
return result;
}
catch (Exception ex)
{
tcs.SetException(ex);
// Serve stale data if available
var staleResult = await GetStaleTimelineAsync(userId, celebId);
if (staleResult != null)
{
staleResult.IsStale = true;
return staleResult;
}
throw;
}
finally
{
_inflight.TryRemove(key, out _);
}
}
return await _inflight[key];
}
private async Task<TimelineResult?> GetStaleTimelineAsync(
long userId, long? celebId)
{
// Try to serve stale data from a secondary cache
var staleKey = $"timeline_stale:{userId}:{celebId}";
var staleData = await _redis.GetAsync(staleKey);
if (staleData != null)
{
return JsonSerializer.Deserialize<TimelineResult>(staleData);
}
return null;
}
}
// Circuit Breaker configuration
public class TwitterCircuitBreaker : ICircuitBreaker
{
private int _failureCount = 0;
private DateTime _lastFailure = DateTime.MinValue;
private const int FailureThreshold = 5;
private const int RecoverySeconds = 30;
public async Task<T> ExecuteAsync<T>(Func<Task<T>> action)
{
if (_failureCount >= FailureThreshold)
{
if ((DateTime.UtcNow - _lastFailure).TotalSeconds < RecoverySeconds)
throw new CircuitBreakerOpenException(
"Circuit breaker is open. Serving stale data.");
_failureCount = 0; // Try half-open
}
try
{
var result = await action();
_failureCount = 0;
return result;
}
catch (Exception)
{
_failureCount++;
_lastFailure = DateTime.UtcNow;
throw;
}
}
}
Resilience Patterns Summary
| Pattern | Purpose | Implementation |
|---|---|---|
| Request coalescing | Prevent duplicate backend calls for same data | ConcurrentDictionary of in-flight tasks |
| Circuit breaker | Stop calling failing backends, serve stale data | Failure count threshold + recovery timer |
| Stale-while-revalidate | Serve slightly outdated data during cache rebuild | Secondary Redis key with TTL |
| Rate limiting | Protect backends from excessive requests | Token bucket per user per endpoint |
| Load shedding | Drop non-critical requests during extreme load | Priority queue with low-priority drop |
| Graceful degradation | Reduce feature richness to maintain availability | Simplified timeline without ranking |
| Retry with backoff | Handle transient failures without thundering retry | Exponential backoff with jitter |
13. Notification System
Twitter's notification system must deliver millions of notifications per minute across push notifications (iOS/Android), in-app notification center, email digests, and web browser push. The system must handle notification storms during viral events (a celebrity tweet can trigger millions of like notifications within minutes) while respecting user preferences for notification frequency and channels. The core challenge is volume: with 330 million users generating hundreds of millions of engagement events daily, the notification system processes over 100 billion notifications per day.
The notification architecture uses a fan-in, fan-out pattern. Engagement events (likes, retweets, follows, mentions) are published to a Kafka topic. The Notification Service consumes these events, determines the target user, checks notification preferences, deduplicates, batches, and routes to the appropriate delivery channel. For high-volume events (like a viral tweet getting millions of likes), the system aggregates notifications: instead of sending "User A liked your tweet" and "User B liked your tweet" separately, it sends "User A and 45,000 others liked your tweet."
// Notification Service - C# Implementation
public class NotificationService
{
private readonly IKafkaConsumer _consumer;
private readonly IPushService _pushService;
private readonly IEmailService _emailService;
private readonly INotificationStore _notificationStore;
private readonly INotificationPreferences _preferences;
public async Task ProcessEngagementEventAsync(EngagementEvent evt)
{
// Step 1: Determine target user (tweet author)
var targetUserId = await GetTweetAuthorAsync(evt.TweetId);
if (targetUserId == evt.ActorUserId) return; // No self-notifications
// Step 2: Check notification preferences
var prefs = await _preferences.GetAsync(targetUserId);
if (!IsNotificationEnabled(evt.Type, prefs)) return;
// Step 3: Deduplicate and aggregate
var dedupeKey = $"notif:{targetUserId}:{evt.Type}:{evt.TweetId}";
var existingCount = await _redis.IncrAsync(dedupeKey);
await _redis.ExpireAsync(dedupeKey, TimeSpan.FromMinutes(10));
if (existingCount == 1)
{
// First notification of this type for this tweet
await SendNotificationAsync(targetUserId, evt);
}
else if (existingCount == 10 || existingCount == 100
|| existingCount == 1000 || existingCount == 10000)
{
// Aggregated milestone notification
await SendAggregatedNotificationAsync(targetUserId, evt.Type,
evt.TweetId, existingCount);
}
}
private async Task SendNotificationAsync(long userId, EngagementEvent evt)
{
// Store in notification center
await _notificationStore.InsertAsync(new Notification
{
NotificationId = SnowflakeIdGenerator.NextId(),
UserId = userId,
Type = evt.Type,
ActorUserId = evt.ActorUserId,
TweetId = evt.TweetId,
CreatedAt = DateTime.UtcNow,
IsRead = false
});
// Send push notification
var deviceTokens = await _pushService.GetDeviceTokensAsync(userId);
foreach (var token in deviceTokens)
{
await _pushService.SendPushAsync(new PushMessage
{
DeviceToken = token,
Title = GetNotificationTitle(evt),
Body = GetNotificationBody(evt),
Data = new Dictionary<string, string>
{
["type"] = evt.Type.ToString(),
["tweetId"] = evt.TweetId.ToString(),
["actorId"] = evt.ActorUserId.ToString()
}
});
}
}
private async Task SendAggregatedNotificationAsync(
long userId, NotificationType type, long tweetId, int count)
{
var message = type switch
{
NotificationType.Like =>
$"@{await GetUsernameAsync(userId)} and {count:N0} others liked your tweet",
NotificationType.Retweet =>
$"Your tweet was retweeted {count:N0} times",
NotificationType.Follow =>
$"You have {count:N0} new followers",
_ => $"Your tweet has {count:N0} new engagements"
};
await _pushService.SendBulkPushAsync(userId, new PushMessage
{
Title = "Twitter",
Body = message,
Priority = PushPriority.Low
});
}
}
Notification Delivery Channels
| Channel | Latency Target | Throughput | Technology |
|---|---|---|---|
| Push (iOS) | < 2 seconds | 50M pushes/day | Apple APNs (HTTP/2 multiplexed) |
| Push (Android) | < 2 seconds | 80M pushes/day | Firebase Cloud Messaging |
| In-app center | < 1 second | 100K QPS reads | Redis sorted set + Cassandra |
| Email digest | Batched hourly/daily | 10M emails/day | SES with Kafka batching |
| Web push | < 5 seconds | 20M pushes/day | Web Push API + VAPID |
14. Media Storage and Delivery
Media content — images, GIFs, and videos — constitutes the majority of Twitter's storage and bandwidth. Users upload over 200 million images and 20 million videos daily. Each image must be stored in its original format and also processed into multiple thumbnail sizes for different client devices. Videos must be transcoded into multiple resolutions and bitrates for adaptive streaming. All media is served through a CDN to minimize latency and reduce origin server load.
The media upload pipeline works as follows: the client uploads the media file to the Media Service, which stores it in an object store (S3), generates thumbnails and transcodes videos asynchronously, and returns a media ID that the client includes in the tweet. The Media Service generates unique URLs for each media asset and configures CDN caching with appropriate TTLs. Images are served with WebP format for modern browsers and JPEG for older ones, reducing bandwidth by 30-50%.
// Media Upload Service - C# Implementation
public class MediaService
{
private readonly IObjectStore _objectStore;
private readonly IImageProcessor _imageProcessor;
private readonly IVideoTranscoder _videoTranscoder;
private readonly ICDNService _cdn;
private static readonly string[] ThumbnailSizes = { "small", "medium", "large" };
public async Task<MediaResult> UploadMediaAsync(
Stream fileStream, string contentType, long userId)
{
var mediaId = SnowflakeIdGenerator.NextId();
var ext = GetExtension(contentType);
var basePath = $"media/{userId}/{mediaId}";
// Step 1: Store original file
var originalKey = $"{basePath}/original{ext}";
await _objectStore.PutObjectAsync(originalKey, fileStream, contentType);
// Step 2: Generate thumbnails for images
if (IsImage(contentType))
{
foreach (var size in ThumbnailSizes)
{
var thumbnailStream = await _imageProcessor.ResizeAsync(
fileStream, GetDimensions(size));
var thumbKey = $"{basePath}/{size}{ext}";
await _objectStore.PutObjectAsync(thumbKey, thumbnailStream, contentType);
}
// Generate WebP version for modern browsers
var webpStream = await _imageProcessor.ConvertToWebPAsync(fileStream);
await _objectStore.PutObjectAsync($"{basePath}/original.webp", webpStream, "image/webp");
}
// Step 3: Transcode video asynchronously
if (IsVideo(contentType))
{
await _videoTranscoder.EnqueueAsync(new TranscodeJob
{
MediaId = mediaId,
SourceKey = originalKey,
OutputPath = basePath,
Resolutions = new[] { "360p", "480p", "720p", "1080p" }
});
}
// Step 4: Generate CDN URLs
var cdnUrl = _cdn.GetPublicUrl(originalKey);
return new MediaResult
{
MediaId = mediaId,
Url = cdnUrl,
ThumbnailUrls = ThumbnailSizes.ToDictionary(
s => s,
s => _cdn.GetPublicUrl($"{basePath}/{s}{ext}")),
ContentType = contentType,
ProcessingStatus = IsVideo(contentType) ? "processing" : "ready"
};
}
}
Media Processing Pipeline
15. Caching Strategy — Multi-Layer Approach
Twitter's caching strategy is a multi-layer system designed to minimize database reads while maintaining acceptable staleness. The cache hierarchy consists of the client-side cache (local storage and HTTP cache headers), the CDN cache (for media and public profiles), the application cache (Redis clusters for timelines, tweets, and user data), and the database buffer pool. Each layer has different invalidation strategies and TTL values depending on the data's volatility and access patterns.
The most critical cache is the timeline cache in Redis. Twitter maintains per-user Redis sorted sets where the score is the tweet timestamp. The sorted set allows efficient range queries for pagination (ZREVRANGEBYSCORE) and automatic eviction of old tweets (ZREMRANGEBYRANK). Each user's timeline cache holds approximately 800 tweets (the most recent ones), which provides enough content for several pages of scrolling. The cache hit rate target is 95%+ for timeline reads, meaning only 5% of timeline requests fall through to the database.
// Multi-Layer Cache Service - C# Implementation
public class MultiLayerCacheService
{
private readonly IMemoryCache _l1Cache; // In-process, ~100MB
private readonly IRedisCluster _l2Cache; // Distributed, ~50TB
private readonly ITweetRepository _database; // Persistent storage
private static readonly TimeSpan L1TTL = TimeSpan.FromSeconds(30);
private static readonly TimeSpan L2TTL_Tweet = TimeSpan.FromHours(24);
private static readonly TimeSpan L2TTL_User = TimeSpan.FromHours(1);
private static readonly TimeSpan L2TTL_Timeline = TimeSpan.FromMinutes(5);
public async Task<Tweet?> GetTweetAsync(long tweetId)
{
// L1: In-process memory cache (per server instance)
var l1Key = $"tweet:{tweetId}";
if (_l1Cache.TryGetValue<Tweet>(l1Key, out var l1Tweet))
return l1Tweet;
// L2: Redis distributed cache
var l2Data = await _l2Cache.GetAsync(l1Key);
if (l2Data != null)
{
var tweet = JsonSerializer.Deserialize<Tweet>(l2Data);
_l1Cache.Set(l1Key, tweet, L1TTL);
return tweet;
}
// L3: Database (cache miss)
var dbTweet = await _database.GetTweetByIdAsync(tweetId);
if (dbTweet != null)
{
await _l2Cache.SetAsync(l1Key,
JsonSerializer.Serialize(dbTweet), L2TTL_Tweet);
_l1Cache.Set(l1Key, dbTweet, L1TTL);
}
return dbTweet;
}
public async Task InvalidateTweetCacheAsync(long tweetId)
{
// Invalidate across all layers
_l1Cache.Remove($"tweet:{tweetId}");
await _l2Cache.DeleteAsync($"tweet:{tweetId}");
// Also invalidate any timeline caches that might contain this tweet
// (This is handled asynchronously via Kafka for performance)
}
}
// Cache warming service - pre-populates cache during off-peak hours
public class CacheWarmingService
{
public async Task WarmPopularTweetCacheAsync()
{
// Get top 100K most-viewed tweets from the last 24 hours
var popularTweets = await _analyticsStore.GetTopTweetsAsync(
period: TimeSpan.FromHours(24), limit: 100_000);
// Pre-load into Redis
var pipeline = _redis.CreatePipeline();
foreach (var tweet in popularTweets)
{
pipeline.Set(
$"tweet:{tweet.TweetId}",
JsonSerializer.Serialize(tweet),
TimeSpan.FromHours(24));
}
await pipeline.ExecuteAsync();
}
}
Cache Layer Summary
| Layer | Technology | Size | TTL | Hit Rate Target |
|---|---|---|---|---|
| L1 (In-process) | IMemoryCache (C#) | 100 MB per instance | 30 seconds | 40% |
| L2 (Distributed) | Redis cluster | 50 TB across 1000 nodes | 5 min - 24 hours | 55% |
| L3 (CDN) | CloudFront | Unlimited (media) | 24 hours | 90% for media |
| L4 (Database) | Manhattan / MySQL | Petabytes | N/A | Final fallback |
16. Database Sharding and Partitioning
At Twitter's scale, a single database instance cannot store all tweets, users, or follow relationships. The data must be sharded (horizontally partitioned) across thousands of machines. Twitter uses different sharding strategies for different data types, each optimized for the primary access pattern. The key challenge in sharding is choosing the right shard key: it must distribute data evenly across shards while co-locating data that is frequently queried together.
For the tweets table, Twitter shards by tweet_id using consistent hashing. Since tweet IDs are Snowflake-generated (roughly time-ordered), this ensures that recent tweets are distributed across all shards rather than concentrated on a single shard. For the follows table, Twitter shards by follower_id, co-locating all follow relationships for a given user on the same shard. This optimizes the "get who I follow" query needed for timeline generation. The users table is sharded by user_id with range-based partitioning for efficient range scans.
// Database Sharding Service - C# Implementation
public class ShardedTweetRepository : ITweetRepository
{
private readonly List<IDatabaseConnection> _shardConnections;
private const int ShardCount = 256;
public ShardedTweetRepository(List<string> shardConnectionStrings)
{
_shardConnections = shardConnectionStrings
.Select(connStr => new DatabaseConnection(connStr))
.ToList();
}
private IDatabaseConnection GetShard(long tweetId)
{
// Consistent hashing on tweet_id
var shardIndex = (int)(tweetId % ShardCount);
return _shardConnections[shardIndex % _shardConnections.Count];
}
public async Task<Tweet?> GetTweetByIdAsync(long tweetId)
{
var shard = GetShard(tweetId);
var command = new MySqlCommand(
"SELECT * FROM tweets WHERE tweet_id = @id", shard.Connection);
command.Parameters.AddWithValue("@id", tweetId);
using var reader = await command.ExecuteReaderAsync();
if (await reader.ReadAsync())
{
return MapToTweet(reader);
}
return null;
}
public async Task InsertAsync(Tweet tweet)
{
var shard = GetShard(tweet.TweetId);
var command = new MySqlCommand(@"
INSERT INTO tweets
(tweet_id, user_id, content, media_urls, hashtags,
mentions, reply_to_tweet_id, retweet_of_tweet_id,
language, created_at)
VALUES
(@tweetId, @userId, @content, @mediaUrls, @hashtags,
@mentions, @replyTo, @retweetOf,
@language, @createdAt)", shard.Connection);
command.Parameters.AddWithValue("@tweetId", tweet.TweetId);
command.Parameters.AddWithValue("@userId", tweet.UserId);
command.Parameters.AddWithValue("@content", tweet.Content);
// ... additional parameters
await command.ExecuteNonQueryAsync();
}
// Cross-shard query: Get all tweets by a user (for user timeline)
public async Task<List<Tweet>> GetUserTweetsAsync(
long userId, int limit = 20)
{
// Must query all shards in parallel (scatter-gather)
var tasks = _shardConnections.Select(async shard =>
{
var command = new MySqlCommand(@"
SELECT * FROM tweets
WHERE user_id = @userId
ORDER BY created_at DESC
LIMIT @limit", shard.Connection);
command.Parameters.AddWithValue("@userId", userId);
command.Parameters.AddWithValue("@limit", limit);
using var reader = await command.ExecuteReaderAsync();
var tweets = new List<Tweet>();
while (await reader.ReadAsync())
{
tweets.Add(MapToTweet(reader));
}
return tweets;
});
var results = await Task.WhenAll(tasks);
return results
.SelectMany(t => t)
.OrderByDescending(t => t.CreatedAt)
.Take(limit)
.ToList();
}
}
Sharding Strategy Comparison
| Table | Shard Key | Strategy | Reason |
|---|---|---|---|
| tweets | tweet_id | Consistent hashing | Even distribution; time-ordered IDs prevent hotspots |
| follows | follower_id | Hash-based | Co-locate all follow relationships for a user |
| users | user_id | Range-based | Efficient range scans for user ID lookups |
| likes | user_id | Hash-based | Check "did I like this tweet?" is user-centric |
| timeline_cache | user_id | Redis hash slots | Each user's timeline is independent |
17. Monitoring, Observability, and Reliability
Operating a system at Twitter's scale requires comprehensive monitoring and observability to detect issues before they impact users. Twitter uses a multi-layered observability stack: metrics (time-series data for dashboards and alerts), logs (structured logs for debugging and auditing), and distributed traces (end-to-end request tracing for latency analysis). The monitoring system must handle millions of data points per second and provide sub-second alerting for critical metrics like timeline latency, tweet posting success rate, and fanout lag.
The key metrics tracked across the Twitter infrastructure include: timeline read latency (p50, p95, p99), tweet posting latency, fanout lag (how far behind the fanout service is from real-time), cache hit rates across all layers, Kafka consumer lag for tweet events, search index freshness (time from tweet post to search availability), error rates per service, and database replication lag. Anomaly detection algorithms automatically alert on-call engineers when metrics deviate from baseline patterns.
// Monitoring and Observability Setup - C# Implementation
public class TwitterMetrics
{
private readonly IMetricsCollector _metrics;
public TwitterMetrics(IMetricsCollector metrics)
{
_metrics = metrics;
}
// Timeline read metrics
public void RecordTimelineRead(long userId, double latencyMs, int tweetCount, bool fromCache)
{
_metrics.Histogram("timeline.read.latency_ms", latencyMs);
_metrics.Histogram("timeline.read.tweet_count", tweetCount);
_metrics.Counter("timeline.read.total", 1,
("cache_hit", fromCache.ToString()),
("user_type", GetUserType(userId)));
}
// Tweet posting metrics
public void RecordTweetPost(double latencyMs, bool success, string errorType = null)
{
_metrics.Histogram("tweet.post.latency_ms", latencyMs);
_metrics.Counter("tweet.post.total", 1,
("success", success.ToString()),
("error_type", errorType ?? "none"));
}
// Fanout metrics
public void RecordFanoutOperation(int followerCount, double latencyMs, bool isCelebrity)
{
_metrics.Histogram("fanout.followers_affected", followerCount);
_metrics.Histogram("fanout.latency_ms", latencyMs);
_metrics.Counter("fanout.operations_total", 1,
("is_celebrity", isCelebrity.ToString()));
}
// Kafka consumer lag
public void RecordConsumerLag(string topic, string consumerGroup, long lagMessages)
{
_metrics.Gauge("kafka.consumer_lag", lagMessages,
("topic", topic), ("consumer_group", consumerGroup));
}
// Cache metrics
public void RecordCacheOperation(string layer, string operation, bool hit, double latencyMs)
{
_metrics.Counter("cache.operations_total", 1,
("layer", layer),
("operation", operation),
("hit", hit.ToString()));
_metrics.Histogram("cache.latency_ms", latencyMs,
("layer", layer));
}
}
// Alerting rules
public class AlertingRules
{
public static readonly List<AlertRule> Rules = new()
{
new AlertRule
{
Name = "Timeline Latency High",
Metric = "timeline.read.latency_ms",
Condition = Condition.P99_GreaterThan(200),
Severity = Severity.Critical,
Notification = NotificationType.PagerDuty
},
new AlertRule
{
Name = "Fanout Lag Growing",
Metric = "kafka.consumer_lag",
Condition = Condition.GreaterThan(10000),
Severity = Severity.Warning,
Notification = NotificationType.Slack
},
new AlertRule
{
Name = "Cache Hit Rate Low",
Metric = "cache.operations_total{hit=true}",
Condition = Condition.Rate_LessThan(0.90),
Severity = Severity.Warning,
Notification = NotificationType.Slack
},
new AlertRule
{
Name = "Tweet Post Error Rate",
Metric = "tweet.post.total{success=false}",
Condition = Condition.Rate_GreaterThan(0.01),
Severity = Severity.Critical,
Notification = NotificationType.PagerDuty
}
};
}
SLO Targets
| Service | SLO | Target | Error Budget |
|---|---|---|---|
| Timeline Service | Availability | 99.99% | 4.38 min/month |
| Timeline Service | Latency p99 | < 200ms | 1% of requests above |
| Tweet Service | Availability | 99.99% | 4.38 min/month |
| Tweet Service | Durability | 99.999999% | Zero data loss |
| Search Service | Index freshness | < 3 seconds | 5% of queries stale |
| Notification Service | Delivery | 99.9% | 43.8 min/month |
18. Cost Estimation
Running a Twitter-like system at scale involves significant infrastructure costs. The major cost drivers are: compute (application servers and ML inference), storage (tweets, media, search indexes), caching (Redis clusters), database (sharded MySQL/Manhattan), message queues (Kafka), CDN (media delivery), and network bandwidth. A rough monthly cost estimate for a Twitter-scale system is $30-50 million per month, which aligns with Twitter's reported infrastructure spending of approximately $400 million per year before the 2022 acquisition.
| Component | Units | Unit Cost | Monthly Cost |
|---|---|---|---|
| Application servers | 5,000 instances (c5.4xlarge) | $0.68/hr | $2.45M |
| Redis cache cluster | 2,000 nodes (r5.4xlarge) | $0.504/hr | $7.26M |
| Manhattan/MySQL databases | 3,000 nodes (r5.8xlarge) | $1.008/hr | $21.8M |
| Elasticsearch cluster | 500 nodes (r5.2xlarge) | $0.252/hr | $0.91M |
| Kafka cluster | 200 brokers (m5.2xlarge) | $0.384/hr | $0.55M |
| Object storage (S3) | 15 PB | $0.023/GB/month | $345K |
| CDN (CloudFront) | 160 TB/day transfer | $0.085/GB | $4.08M |
| Network bandwidth | 100 Gbps dedicated | $0.02/GB | $5.26M |
| ML inference (ranking) | 100 GPU instances (p3.2xlarge) | $3.06/hr | $2.2M |
| Monitoring & logging | Custom stack | - | $500K |
| Total Estimated Monthly | ~$45M | ||
19. Interview Q&A
Q1: Walk me through what happens when a user posts a tweet.
When a user posts a tweet, the client sends a POST request to the Tweet Service. The service validates the content (character limit, prohibited content), generates a Snowflake ID for the tweet, writes it to the Manhattan database, and publishes a TweetEvent to the Kafka tweet-events topic. The Fanout Service consumes the event and, based on the author's follower count, either pushes the tweet ID to all followers' Redis timeline caches (fanout-on-write for users with fewer than 5,000 followers) or stores it in the celebrity tweet cache (fanout-on-read for accounts with more than 5,000 followers). The Search Service also consumes the event and indexes the tweet in the Elasticsearch cluster. The entire write path returns success to the client in under 500ms because all downstream processing is asynchronous.
Q2: How would you design the fanout system to handle 100M+ follower accounts?
The key insight is to never use fanout-on-write for celebrity accounts. When an account like @elaborate Musk (100M+ followers) tweets, pushing to 100 million Redis sorted sets is prohibitively expensive and would take over 16 minutes even with aggressive parallelism. Instead, we use fanout-on-read: the tweet is stored once in the celebrity's Redis list. When a follower loads their timeline, the system fetches the top 10 tweets from each celebrity they follow and merges them with the pre-computed timeline. To optimize this, we maintain a set of celebrity users and track which users follow celebrities, so the merge only happens when needed. Request coalescing ensures that concurrent requests for the same celebrity timeline only trigger one backend fetch.
Q3: How do you ensure the timeline is fresh but the system remains available?
We use eventual consistency with a staleness budget. The fanout service targets a lag of less than 2 seconds between a tweet being posted and it appearing in followers' timelines. The Redis timeline caches are the source of truth for the home feed, and they are updated asynchronously via the fanout pipeline. If the fanout service falls behind (Kafka consumer lag grows), we serve stale timelines with a "recent" indicator. The system never blocks timeline reads to wait for fanout completion. We use circuit breakers to prevent cascading failures: if Redis is slow, we serve from L1 (in-memory) cache or return a simplified chronological feed without ranking.
Q4: How does Twitter search achieve sub-second indexing and retrieval?
Twitter search uses a two-tier index architecture. The top tier is an in-memory segment that indexes tweets within 1-3 seconds of posting. This segment holds the most recent 24 hours of tweets and handles time-range queries efficiently. The bottom tier is a persistent Elasticsearch cluster that holds the complete historical index. When a search query arrives, the system queries both tiers in parallel and merges the results. The index uses 48 shards distributed across hundreds of nodes, with custom routing that co-locates tweets from the same time period. The search ranking combines BM25 text relevance with engagement signals (likes, retweets, recency) to produce the final ranked results.
Q5: How would you handle a sudden traffic spike during a breaking news event?
Several mechanisms work together to handle viral traffic spikes. First, request coalescing prevents duplicate backend calls: if 10,000 requests for the same trending topic arrive simultaneously, only one Elasticsearch query is executed and the result is shared. Second, aggressive caching at the CDN and Redis layers means most requests are served from cache. Third, load shedding drops non-critical requests (like analytics and recommendation prefetches) to protect the core timeline and tweet posting paths. Fourth, auto-scaling provisions additional application server capacity within minutes. Fifth, the architecture is designed to degrade gracefully: during extreme load, we can disable timeline ranking (serve chronological) and reduce tweets per page from 20 to 10, cutting the read amplification in half.
Q6: Explain the difference between Manhattan and FlockDB in Twitter's stack.
Manhattan is Twitter's distributed key-value store used for general-purpose storage of tweets, user profiles, and engagement data. It provides multi-dimensional indexing, automatic sharding, and tunable consistency across data centers. Think of it as a highly scalable NoSQL database optimized for point lookups and range scans. FlockDB is a graph database specifically designed for storing follow relationships (social graph edges). It is optimized for shallow, wide traversals — "give me all followers of user X" or "give me all accounts that user Y follows." FlockDB stores edges in MySQL with a custom indexing layer that supports efficient adjacency list queries. The two systems serve different access patterns: Manhattan for general data storage, FlockDB for graph queries.
Q7: How does Twitter handle tweet deletion and its propagation through the system?
When a user deletes a tweet, the deletion must propagate through multiple systems: the Manhattan database (mark tweet as deleted), the timeline caches (remove from all followers' Redis sorted sets), the search index (remove from Elasticsearch), and the media storage (soft-delete associated images/videos). The deletion is published as a DeleteEvent to Kafka, and each downstream consumer handles its part of the cleanup. Timeline cache cleanup uses lazy deletion: the tweet is marked as deleted in the database, and the timeline service skips deleted tweets during rendering. Active timeline caches are cleaned up asynchronously via a background worker. Search index deletion uses Elasticsearch's delete-by-query API. The system ensures that deleted tweets do not appear in new timeline renders within 5 seconds, though they may remain in cached responses for up to 30 seconds.
Q8: How would you design the trending topics feature?
Trending topics are computed by analyzing the volume and velocity of hashtag usage in real-time. The system uses a sliding window algorithm: it counts the number of tweets containing each hashtag over the last 5 minutes and compares it to the baseline count from the previous day (same time window). Hashtags with a significant velocity increase (e.g., 10x above baseline) are flagged as trending. The implementation uses a two-pass approach: first, a Kafka Streams job computes per-hashtag counts using a 5-minute tumbling window. Second, a ranking service compares current counts to historical baselines and applies geographic and personalization filters. Trending topics are pre-computed and cached in Redis every minute, and the client fetches them from a dedicated API endpoint. For local trends, the system maintains per-country and per-city trend lists.
Q9: What are the key trade-offs in Twitter's architecture?
The primary trade-offs are: (1) Consistency vs. Availability — Twitter prioritizes availability; timelines can be 1-2 seconds stale but must always load. (2) Write amplification vs. read latency — fanout-on-write trades higher write costs (N writes per tweet) for O(1) timeline reads, while fanout-on-read trades expensive merge queries for constant write cost. The hybrid approach balances both. (3) Freshness vs. performance — the search index trades 1-3 seconds of indexing delay for batch-optimized write performance. (4) Storage cost vs. latency — keeping 800 tweets per user in Redis cache costs more memory but provides sub-millisecond timeline reads. (5) Ranking quality vs. latency — the ML ranking model improves relevance but adds 10-20ms of latency compared to simple chronological sorting.
Q10: How would you scale the system to support direct messages (DMs)?
Direct messages require a different architecture than the public tweet system because DMs are private, ordered, and require end-to-end encryption. The DM system uses a conversation-based data model: each DM belongs to a conversation (1-to-1 or group), and messages within a conversation are stored in chronological order. The storage layer uses Cassandra with a partition key of conversation_id, ensuring all messages in a conversation are co-located. For real-time delivery, DMs use WebSocket connections instead of polling. When a DM is sent, the server pushes it to all connected recipients via their WebSocket connections. If a recipient is offline, the DM is stored in Cassandra and delivered via push notification. The DM system must handle group conversations with up to 500 participants, which requires careful fanout to avoid amplification issues.
Q11: How does Twitter handle spam and abuse at scale?
Twitter uses a multi-layered approach to spam and abuse detection. At the content level, an ML classifier analyzes tweet text for spam patterns, offensive content, and misinformation. At the behavioral level, a velocity-based system detects accounts that tweet too fast, follow/unfollow rapidly, or send excessive DMs. At the graph level, the system identifies coordinated inauthentic behavior (bot networks) by analyzing follow patterns and engagement anomalies. At the infrastructure level, rate limiting at the API gateway prevents abuse of the tweet posting and search APIs. The spam classifier runs in real-time and can quarantine suspicious tweets within seconds. False positives are managed through an appeals process and periodic model retraining on labeled data.
Q12: Design the analytics pipeline for tweet engagement metrics.
The analytics pipeline processes billions of engagement events (views, likes, retweets, clicks) per day. Each engagement event is published to a Kafka analytics topic. A Spark Streaming job consumes the events and computes real-time aggregates: tweet view count, like count, retweet count, and engagement rate. These aggregates are stored in a time-series database (like InfluxDB or Druid) for real-time dashboards and in HDFS/S3 for historical analysis. The pipeline uses exactly-once semantics to prevent double-counting. For the tweet author, engagement metrics are updated in near-real-time (within 5 seconds) and displayed on the tweet. For analytics dashboards, metrics are aggregated at 1-minute, 5-minute, and 1-hour intervals. The pipeline also feeds the ML ranking model by providing training data on which tweets received high engagement.
Q13: How would you implement the "For You" algorithmic feed?
The "For You" feed uses a candidate generation and ranking architecture. Candidate generation pulls tweets from multiple sources: (1) tweets from followed accounts (pre-computed timeline), (2) tweets from accounts similar to those the user follows (collaborative filtering), (3) trending tweets in the user's geographic region, and (4) tweets that the user's friends engaged with (social proof). The candidate set (approximately 1,000 tweets) is then ranked by a deep learning model that considers user behavior history, tweet features, author features, and social graph signals. The model is trained on implicit feedback: tweets the user liked, retweeted, or spent time reading. The top 200 ranked tweets are cached, and the timeline service selects the final 20 based on a diversity function that ensures the feed is not dominated by a single topic or author.
Q14: Explain how Twitter handles multi-device synchronization.
Multi-device sync requires tracking which tweets each user has seen across all their devices. Twitter uses a "last seen ID" approach: each device reports its position in the timeline (the ID of the last tweet it displayed) to the server. The server stores the most recent last-seen ID across all devices. When the user opens the app on any device, the timeline shows unread tweets since the globally most recent last-seen ID. This means opening the app on a desktop marks tweets as "seen" on mobile too. The implementation uses a Redis hash per user storing last-seen IDs per device type. A merge function computes the global position as the maximum across all devices. For the "new tweets" indicator, the system counts tweets between the device's last-seen ID and the global last-seen ID.
Q15: How would you reduce the cost of running the system by 50%?
Cost reduction strategies include: (1) Compress timeline caches using delta encoding — store only the delta between consecutive tweet IDs instead of full IDs, reducing Redis memory by 40%. (2) Use tiered storage — move tweets older than 90 days from SSD-backed Manhattan to HDD-backed archival storage, reducing storage cost by 60%. (3) Optimize fanout threshold — increasing the celebrity threshold from 5,000 to 10,000 reduces fanout writes by 30% at the cost of slightly more on-demand reads. (4) Adopt spot instances for batch workloads — fanout processing, search indexing, and ML training can use spot instances at 70% discount. (5) Reduce CDN costs by serving WebP/AVIF images and compressing video at lower bitrates without perceptible quality loss. (6) Consolidate Redis instances by increasing cache density and using Redis Cluster mode to reduce overhead.
20. Conclusion
Designing a Twitter-like system requires solving several interconnected distributed systems challenges: the fanout problem for distributing tweets to millions of followers, the timeline generation problem for assembling a personalized feed, the search problem for full-text indexing across hundreds of billions of tweets, and the reliability problem for handling viral traffic spikes without cascading failures. The key architectural decisions — hybrid fanout, multi-layer caching, Kafka-based async processing, and polyglot persistence — are all driven by the fundamental tension between read performance and write amplification at massive scale.
The hybrid fanout strategy is the most important insight: by using fanout-on-write for regular accounts and fanout-on-read for celebrity accounts, the system achieves O(1) timeline reads for 99% of users while avoiding the prohibitive write costs of pushing tweets to millions of celebrity followers. The multi-layer caching strategy (L1 in-process, L2 Redis, L3 CDN, L4 database) ensures that 95%+ of timeline reads are served from cache with sub-millisecond latency. The Kafka-based async pipeline decouples the write path from the read path, allowing the system to absorb write spikes without impacting read performance.
For system design interviews, the Twitter question tests your ability to reason about trade-offs at every layer: consistency vs. availability, write amplification vs. read latency, ranking quality vs. response time, and storage cost vs. performance. A strong answer demonstrates not just the "what" but the "why" — explaining the reasoning behind each architectural decision and acknowledging the trade-offs. The C# code examples in this guide show production-grade implementations of the core services, from the tweet posting pipeline to the ranking algorithm to the multi-layer cache service.
- Always clarify the scale: 500M tweets/day and 330M users drive different decisions than 1M tweets/day.
- Explain the fanout trade-off clearly: push for regular users, pull for celebrities, hybrid is the answer.
- Discuss cache hierarchy and invalidation — this is where most candidates fall short.
- Show awareness of failure modes: thundering herds, cascading failures, and the need for circuit breakers.
- Mention monitoring and SLOs — this distinguishes senior+ candidates from mid-level engineers.
Twitter's architecture has evolved significantly since its inception in 2006, moving from a monolithic Ruby on Rails application to a sophisticated microservices architecture powered by custom-built infrastructure (Manhattan, FlockDB, Earlybird). The lessons learned from operating this system at scale — particularly around fanout optimization, cache efficiency, and viral event handling — are directly applicable to any large-scale social platform, news feed, or real-time publishing system. Understanding these patterns will serve you well not only in system design interviews but also in building and operating production systems at scale.
Originally published on Ayodhyya. Last updated July 1, 2026.