Design a Social Media Feed (Twitter/X)
A social media feed must aggregate posts from everyone a user follows and present them in a ranked order — in near real-time. Behind this lies a write-fan-out problem: when a user tweets, their message must appear in the timelines of potentially millions of followers. The core design challenge is choosing between fan-out on write (push) and fan-out on read (pull), and handling the celebrity problem where a single account has tens of millions of followers.
Design it yourself
Don't just read it — drag components onto a canvas and get Aria's interviewer review.
Requirements
Functional
- Users can post tweets (text up to 280 chars, optional media)
- Users can follow/unfollow other users
- A user's home timeline shows tweets from everyone they follow, sorted by recency (and optionally ranked)
- Tweets can include images and videos served via CDN
- Push notifications for mentions and replies (stretch goal)
- Tweet can be liked, retweeted, and replied to
Non-Functional
- 300M DAU, 500M tweets posted per day ≈ 5,800 writes/sec
- Timeline reads: each DAU loads feed ~5 times/day → 1.5B reads/day ≈ 17,400 reads/sec
- 99.99% availability — feed must degrade gracefully, never go fully dark
- Timeline load latency < 100ms at p99
- Eventual consistency is acceptable — a tweet may appear in follower feeds within a few seconds
- Media (images, video) served within 2s globally via CDN
Capacity Estimation
| Tweet writes | 500M / day ≈ 5,800 / sec |
| Timeline reads | 1.5B / day ≈ 17,400 / sec |
| Storage per tweet | ~300 bytes (text + metadata) |
| Tweet storage (5 years) | 500M × 365 × 5 × 300B ≈ 274 TB |
| Avg followers per user | ~200 (median); celebrities > 1M |
| Fan-out events (push) | 5,800 writes/sec × 200 followers = 1.16M fan-out ops/sec |
| Media storage | ~10% of tweets have images; ~1% have video → ~100 PB over 5 years |
High-Level Components
API Gateway / Load Balancer
Terminates TLS, handles authentication (JWT/OAuth), enforces rate limits (300 tweets/3h per user), and routes POST /tweet to the Tweet Service and GET /timeline to the Timeline Service.
Tweet Service
Validates and persists new tweets to the Tweet Store (Cassandra/DynamoDB). After a successful write, publishes a TweetCreated event to Kafka for async fan-out processing.
Fan-out Service (Workers)
Consumes TweetCreated events from Kafka. For each tweet, looks up the author's follower list and pushes the tweet ID into each follower's timeline cache (Redis sorted set, scored by timestamp). Scales horizontally by partitioning Kafka by author ID.
Timeline Service
Serves GET /timeline requests. Reads the pre-built timeline from the user's Redis sorted set. For celebrity accounts (> 1M followers) whose tweets are NOT pre-fanned-out, merges on read. Returns a hydrated list of tweets with author info.
Tweet Store (Cassandra)
Stores tweet content keyed by tweet_id. Partitioned by author_id for efficient author-timeline queries. Cassandra's wide-row model allows efficient range scans for a given author's recent tweets.
Timeline Cache (Redis Sorted Sets)
Each user has a Redis sorted set keyed by user_id, where members are tweet IDs and scores are Unix timestamps. The Fan-out Service pushes IDs here; Timeline Service reads them. Capped at ~800 most recent entries per user to bound memory.
User Graph Service
Maintains follower/following relationships. Backed by a graph-optimised store (e.g. a dedicated MySQL table with (follower_id, followee_id) or a graph DB for richer traversal). Fan-out Service queries this to get follower lists.
Media Service + CDN
Handles chunked upload of images and videos. Transcodes video to multiple resolutions. Stores master copies in object storage (S3). Serves assets via a globally distributed CDN (CloudFront/Fastly) for low-latency delivery.
Kafka (Event Bus)
Decouples tweet creation from fan-out, notification delivery, analytics, and search indexing. Partitioned by author_id to preserve ordering per author. Fan-out consumers are the heaviest consumers.
Architecture Diagram
Deep Dives
Fan-out on Write vs Fan-out on Read
This is the central trade-off in feed system design.
Fan-out on Write (Push model) When a user posts, the Fan-out Service immediately pushes the tweet ID into every follower's timeline cache. Timeline reads become trivial: just fetch the pre-built sorted set from Redis.
- Pros: O(1) read cost, very low read latency. - Cons: Write amplification. A user with 1M followers generates 1M Redis writes per tweet. Follower list changes (follows/unfollows) require cache invalidation.
Fan-out on Read (Pull model) The timeline cache is NOT pre-built. At read time, the Timeline Service fetches tweets from everyone the user follows (from the Tweet Store) and merges them.
- Pros: No write amplification. No fan-out delay. - Cons: Read is expensive — must query N followees' tweet tables and merge/sort N result sets. At 17,400 reads/sec with 200 followees each, this is 3.5M DB reads/sec.
Hybrid approach (Twitter's actual approach) Use fan-out on write for ordinary users (followers < 1M threshold). For celebrities, skip fan-out — their tweets are fetched on read and merged with the pre-built portion of the timeline. This caps write amplification while keeping read latency acceptable.
The threshold is typically configurable (e.g. 1M followers). The Timeline Service must detect celebrity followees and perform a targeted pull merge for just those accounts.
Java — Fan-out worker with celebrity threshold check
// Fan-out worker — pushes tweet ID into follower timelines
public class FanoutWorker {
private static final int CELEBRITY_THRESHOLD = 1_000_000;
private static final int MAX_TIMELINE_SIZE = 800;
private final FollowerRepository followerRepo;
private final RedisTemplate<String, String> redis;
public void fanOut(TweetCreatedEvent event) {
long authorId = event.authorId();
long tweetId = event.tweetId();
long timestamp = event.createdAt().toEpochMilli();
long followerCount = followerRepo.countFollowers(authorId);
if (followerCount >= CELEBRITY_THRESHOLD) {
// Skip fan-out; Timeline Service will pull-merge on read
return;
}
// Paginate follower list to avoid OOM for accounts with 100K followers
int page = 0;
List<Long> followers;
do {
followers = followerRepo.findFollowers(authorId, page++, 5000);
for (long followerId : followers) {
String key = "timeline:" + followerId;
// Add tweet to sorted set; score = timestamp for chronological order
redis.opsForZSet().add(key, String.valueOf(tweetId), timestamp);
// Trim to most recent MAX_TIMELINE_SIZE entries
redis.opsForZSet().removeRange(key, 0, -(MAX_TIMELINE_SIZE + 1));
}
} while (followers.size() == 5000);
}
}Timeline Generation and Redis Sorted Sets
The home timeline for a normal user is stored as a Redis Sorted Set where: - Key: `timeline:{userId}` - Member: tweet ID (as string) - Score: Unix timestamp in milliseconds
This gives O(log N) inserts and O(log N + K) range reads (ZREVRANGEBYSCORE for the K most recent tweets).
Read flow for a normal user: 1. ZREVRANGE `timeline:{userId}` 0 49 → returns 50 most recent tweet IDs 2. Multi-GET tweet content from Tweet Store (or a secondary tweet cache) using the IDs 3. Merge author profile info (from User Service or another cache) 4. Return hydrated tweet list
Read flow for a user following celebrities: 1. ZREVRANGE `timeline:{userId}` 0 49 → pre-built portion (non-celebrity tweets) 2. For each celebrity the user follows: ZREVRANGEBYSCORE `tweets:{celebrityId}` to get recent tweet IDs 3. Merge all sets in memory, sort by timestamp, take top 50 4. Hydrate and return
Timeline cap: Each sorted set is capped at 800 entries. Users scrolling beyond 800 entries trigger a read from the Tweet Store directly, paginated by cursor.
Java — Timeline Service with celebrity pull-merge
// Timeline Service — fetch and hydrate home timeline
public List<TweetDTO> getHomeTimeline(long userId, int limit) {
String timelineKey = "timeline:" + userId;
// 1. Fetch tweet IDs from pre-built sorted set (most recent first)
Set<String> tweetIdStrs = redis.opsForZSet()
.reverseRange(timelineKey, 0, limit - 1);
List<Long> tweetIds = tweetIdStrs.stream()
.map(Long::parseLong)
.collect(Collectors.toList());
// 2. Pull celebrity tweets and merge
List<Long> celebrityFollowees = userGraphService.getCelebrityFollowees(userId);
for (long celeb : celebrityFollowees) {
List<Long> celebTweets = tweetStore.getRecentByAuthor(celeb, limit);
tweetIds.addAll(celebTweets);
}
// 3. Sort merged list by tweet ID (Snowflake IDs are time-ordered)
tweetIds.sort(Comparator.reverseOrder());
List<Long> topTweets = tweetIds.stream().limit(limit).collect(Collectors.toList());
// 4. Batch-fetch tweet content and hydrate
return tweetStore.batchFetch(topTweets).stream()
.map(tweet -> hydrate(tweet))
.collect(Collectors.toList());
}Handling the Celebrity Problem
A celebrity with 10M followers posts a tweet. Fan-out on write would require 10M Redis writes within seconds — this creates: - A write storm on the Fan-out Service (message queue spike) - Redis hotspot as all workers try to write to the same shard - Follower list pagination overhead (10M records must be iterated)
Solution: Hybrid model with threshold - Define a celebrity threshold (e.g. 1M followers) - Fan-out Service skips celebrity authors entirely - Tweet Store maintains a per-author sorted set: `tweets:{authorId}` with tweet IDs scored by timestamp - Timeline Service detects which followees are celebrities and merges their tweets on read
Celebrity detection: The User Graph Service maintains a follower count per user, updated asynchronously via Kafka. A cached celebrity flag is checked at fan-out time.
Follow event handling: When a user follows a celebrity, no back-fill of old tweets into their timeline cache is needed — the pull-merge handles it automatically on the next read.
Unfollow event: When a user unfollows a non-celebrity, their cached timeline may still contain old tweets. Two strategies: 1. Lazy eviction: Keep the sorted set intact; Timeline Service filters out unfollowed authors at read time for a short grace period, then the stale entries naturally fall off as new tweets push them out. 2. Background cleanup job: A Kafka UnfollowEvent triggers an async worker that removes the unfollowed author's tweets from the timeline cache. Preferred for large-scale production.
Java — Celebrity detection with Redis-cached follower count
// Detecting celebrity accounts at timeline read time
public class UserGraphService {
private static final int CELEBRITY_THRESHOLD = 1_000_000;
// Cached in Redis: "celeb:{userId}" → "1" or "0"
// Refreshed by FollowerCountUpdater consuming Kafka FollowEvent stream
public List<Long> getCelebrityFollowees(long userId) {
List<Long> followees = getFollowing(userId); // from graph store
return followees.stream()
.filter(this::isCelebrity)
.collect(Collectors.toList());
}
public boolean isCelebrity(long userId) {
String cached = redis.opsForValue().get("celeb:" + userId);
if (cached != null) return "1".equals(cached);
// Cache miss — query and cache for 10 minutes
long count = followerRepo.countFollowers(userId);
boolean celeb = count >= CELEBRITY_THRESHOLD;
redis.opsForValue().set("celeb:" + userId, celeb ? "1" : "0",
Duration.ofMinutes(10));
return celeb;
}
}Media Delivery via CDN
Media (images and video) represents the bulk of bandwidth. Serving it correctly requires several layers:
Upload pipeline: 1. Client uploads media to the Media Service via a pre-signed S3 URL (bypasses app servers entirely) 2. Media Service publishes a MediaUploaded event to Kafka 3. Transcoding workers consume the event and produce multiple resolutions: - Images: thumbnail (150px), medium (600px), original - Video: 360p, 720p, 1080p HLS segments 4. Transcoded files are written back to S3 5. CDN origin is pointed at the S3 bucket
CDN architecture: - Use a CDN with a large PoP network (CloudFront, Fastly, Akamai) - Cache-Control: public, max-age=31536000 (1 year) — media is immutable once processed - URL includes a content hash so updates create new URLs, not cache invalidations - Video segments are small HLS chunks (2-10s); CDN serves them independently, enabling adaptive bitrate
Adaptive Bitrate (ABR): - The video player (HLS.js / ExoPlayer) requests an M3U8 manifest listing available quality levels - Based on measured bandwidth, the player selects the appropriate segment quality per chunk - Seamlessly switches quality mid-stream without buffering gaps
Cost optimisation: - S3 Intelligent-Tiering moves infrequently accessed media to cheaper storage classes - Older tweets' media (> 30 days) is moved to S3 Glacier; CDN TTL expiry triggers an S3 restore on re-access
Java — Pre-signed S3 upload + Kafka-triggered transcoding
// Media upload — generate pre-signed S3 URL for direct client upload
@RestController
@RequestMapping("/api/media")
public class MediaController {
private final AmazonS3 s3;
private final KafkaTemplate<String, MediaUploadedEvent> kafka;
@PostMapping("/upload-url")
public UploadUrlResponse getUploadUrl(@RequestBody UploadRequest req) {
String key = "uploads/" + UUID.randomUUID() + "/" + req.filename();
Date expiry = Date.from(Instant.now().plus(15, ChronoUnit.MINUTES));
GeneratePresignedUrlRequest urlReq = new GeneratePresignedUrlRequest(
"media-bucket", key)
.withMethod(HttpMethod.PUT)
.withExpiration(expiry)
.withContentType(req.mimeType());
URL presignedUrl = s3.generatePresignedUrl(urlReq);
return new UploadUrlResponse(presignedUrl.toString(), key);
}
// Called by client after S3 upload completes
@PostMapping("/confirm")
public void confirmUpload(@RequestBody ConfirmRequest req) {
kafka.send("media-uploaded", new MediaUploadedEvent(
req.mediaKey(), req.mimeType(), req.tweetId()));
}
}
// Transcoding worker (simplified)
@KafkaListener(topics = "media-uploaded")
public void transcode(MediaUploadedEvent event) {
if (event.mimeType().startsWith("video/")) {
ffmpegService.transcodeHLS(event.mediaKey(),
List.of("360p", "720p", "1080p"));
} else {
imageService.resize(event.mediaKey(),
List.of(150, 600)); // thumbnail, medium
}
// Update tweet record with CDN URLs
tweetStore.attachMedia(event.tweetId(),
cdnBaseUrl + "/" + event.mediaKey());
}Key Trade-offs
Fan-out on write vs fan-out on read
Pure write fan-out collapses under celebrity-scale follower counts. Pure read fan-out makes timelines expensive for users with many followees. The hybrid caps write amplification while keeping read latency low for the common case.
SQL vs Cassandra for tweet storage
Tweets are written once and read by tweet_id or (author_id, timestamp). Cassandra's wide rows handle high-volume append-only writes and efficient range scans by author. SQL would require expensive sharding at 500M tweets/day.
Chronological vs algorithmic ranking
Sorted sets give O(log N) inserts and O(log N + K) range reads without a ranking pass. A lightweight re-ranking model (engagement prediction) can be applied as a post-processing step on the top-N candidates without changing the storage model.
Timeline cache cap (800 entries)
The vast majority of users never scroll past 50-100 tweets. Capping at 800 bounds Redis memory cost. Users who scroll beyond the cap fall back to the Tweet Store with negligible impact on the p99 latency SLO.
Synchronous vs asynchronous fan-out
Posting a tweet must return in milliseconds. Writing to potentially millions of Redis keys synchronously in the request path would time out. Kafka decouples the write path; fan-out may lag by a few seconds, which is acceptable.
Interview Tips
- 1Always start with the fan-out problem — it's the crux. Ask the interviewer whether to optimise for write latency or read latency before choosing your model.
- 2The celebrity problem is a well-known follow-up. Introduce the threshold concept proactively and explain both the fan-out skip and the read-merge.
- 3Redis Sorted Set is the canonical data structure for a timeline. Know: ZADD, ZREVRANGE, ZREMRANGEBYRANK. Explain why the score is a timestamp.
- 4Kafka is mentioned for a reason — explain WHY async fan-out matters: decouples write latency from fan-out depth, enables retry/backpressure, and allows multiple consumers (analytics, notifications, search indexing) from the same event stream.
- 5Media is a common extension question. Know pre-signed S3 URLs, HLS/DASH for adaptive streaming, and CDN cache-control strategy.
- 6If asked about ranking: keep it simple. Fetch the top N by recency, score each with a lightweight model (likes, retweets, relationship strength), re-sort. Avoid over-engineering into a full ML pipeline unless asked.
- 7Mention eventual consistency explicitly — followers see a new tweet within seconds, not milliseconds. This is a deliberate trade-off, not a bug.
Discussion
Discussion
Sign in to join the discussion.