Design a News Feed (Twitter/Facebook) — Fan-out, Ranking, and the Celebrity Problem
A system design walkthrough of a news feed like Twitter or Facebook: fan-out on write vs read, the celebrity problem, ranking, and caching at scale.
- Author
- Randhir Jassal
- Published
- Reading time
- 14 min read
- Views
- 3 views
"Design Twitter's feed" comes down to one question the whole interview circles around: when someone you follow posts, when does it reach your feed — at write time or at read time? Push it to every follower the moment it's posted and reads are instant, until a celebrity with 100 million followers posts and you're doing 100 million writes. Compute it fresh on every feed load and writes are trivial, until reads have to merge posts from hundreds of accounts under 200 milliseconds. The real answer is "both, depending on who posted" — and knowing exactly when to switch is the whole game.
A news feed shows a user the recent posts of the people they follow, newest (or most relevant) first. It's a massively read-heavy system — people scroll far more than they post — with a nasty amplification problem baked in: a single post can need to reach millions of feeds. The core decision is the fan-out model, the core trap is the celebrity, and the core optimization is precomputing feeds in a cache. (New to the method? Start with the framework.)
TL;DR — the design at a glance
| Concern | Decision |
|---|---|
| Fan-out | Hybrid — push for normal users, pull for celebrities |
| Feed store | Per-user precomputed feed of post IDs in Redis (capped length) |
| Reads | Read your precomputed feed + merge in recent celebrity posts |
| Ranking | Reverse-chronological baseline; ranked = a separate ML scoring service |
| Consistency | Eventually consistent — a post can take seconds to appear |
| Content | Store post IDs in feeds; hydrate from a post cache at read time |
- The one decision that matters: fan-out on write (push) vs fan-out on read (pull) — and the answer is hybrid.
- Push precomputes each follower's feed on post → fast reads, expensive writes.
- Pull fetches followees' posts at read time → cheap writes, expensive reads.
- The celebrity kills pure push: one post → millions of feed writes. So push for the masses, pull for the few huge accounts, and merge at read.
- Precompute feeds in Redis — a capped list of post IDs per user makes the common feed load an O(1) cache read.
- Store IDs, not posts. The feed holds post IDs; hydrate content from a post cache so one post isn't copied into millions of feeds.
- Don't fan out to inactive users. Skip precomputation for dormant accounts; rebuild on login.
- Chronological is a valid baseline; ranked feeds are a whole ML subsystem — say so, don't hand-wave it.
The one mental shift: a feed isn't computed when you read it — for most users it's precomputed when others write, and sitting in a cache waiting for you. Reframing "generate my feed" as "maintain everyone's feed as posts arrive" is what makes reads O(1). The celebrity is simply the case where precomputing is too expensive, so you fall back to computing at read.
Step 1 — Requirements & scope
Functional:
- Users post (text/media).
- Users follow others.
- Users load a feed of recent posts from people they follow.
Parked: likes/comments/reshares (they layer on), DMs, search, ads, the full ranking model.
Non-functional:
- Read-heavy — feed loads vastly outnumber posts.
- Low-latency feed load — target well under ~200 ms.
- Highly available — a stale feed beats no feed.
- Eventually consistent — a post appearing a few seconds late is fine.
- Massive scale — hundreds of millions of users, celebrity accounts with tens of millions of followers.
Step 2 — Back-of-envelope estimates
Say 500M daily users, each loading the feed ~10×/day, each posting ~2×/day:
Feed reads: 500M × 10 / 86,400 s ≈ ~58,000 reads/sec (peak ~150k+)
Posts: 500M × 2 / 86,400 s ≈ ~11,600 posts/sec
Reads dwarf writes — so we optimize for reads (precompute). But the real monster is fan-out amplification: a normal post reaches a few hundred feeds; a celebrity post with 100M followers, done naively, is 100M feed writes for one post. That single fact forces the hybrid model.
Step 3 — API
POST /posts { text, media } -> postId
GET /feed?cursor=... -> [posts], nextCursor (cursor pagination)
POST /follow { targetUserId } -> 200
Cursor pagination (not offset) so the feed doesn't break or duplicate as new posts arrive above your scroll position.
Step 4 — Data model
posts postId, userId, content, createdAt
follows followerId, followeeId (the social graph)
feed userId -> [postId, postId, ...] (precomputed, capped, in Redis)
- Posts live in a scalable store (a wide-column store like Cassandra, or sharded SQL), sharded by
userId. - Follows is the social graph — cheap to store, heavily read during fan-out.
- Feed is the precomputed per-user list of post IDs, held in Redis and capped (say, the latest ~800 IDs — nobody scrolls further than the cache).
Step 5 — High-level architecture
POST path
Alice posts ──▶ [ Post service ] ──▶ store post
│
▼
[ Fan-out service ] ──▶ push postId into followers' feeds (Redis)
READ path (the hot path)
Bob opens app ──▶ [ Feed service ] ──▶ read Bob's precomputed feed (post IDs, Redis)
──▶ hydrate content from [ Post cache ]
──▶ merge in recent CELEBRITY posts (pull)
──▶ return page
The fan-out service is the engine; the precomputed feed is why reads are fast. Everything interesting is how fan-out decides who to push to.
Step 6 — Deep-dive: fan-out, and the celebrity problem
Fan-out on write (push)
When a user posts, immediately push the post ID into every follower's precomputed feed. Reads become trivial — you just read your ready-made feed.
- Pro: feed loads are O(1) cache reads — perfect for a read-heavy system.
- Con: a post costs one write per follower. For an account with millions of followers, a single post is a write storm. And you do this work even for followers who never open the app.
Fan-out on read (pull)
Store each post under its author only. When a user loads their feed, fetch recent posts from everyone they follow and merge them on the fly.
- Pro: posting is cheap — one write, no fan-out.
- Con: reads are expensive — fetch from hundreds of followees, merge, sort — and slow at scale, on the hot path, for every feed load.
The celebrity problem
Pure push breaks on high-follower accounts: one celebrity post = tens of millions of feed writes, spiking latency and load. Pure pull breaks on normal reads: merging hundreds of followees on every load is too slow. Neither extreme survives at scale.
The hybrid (what to ship)
Split by follower count:
- Normal users → push. Their posts fan out to followers' precomputed feeds. Cheap enough, and it keeps the common feed load O(1).
- Celebrities → pull. Their posts are not fanned out. Instead, at read time, the feed service merges recent posts from the (few) celebrities you follow into your precomputed feed.
Your feed = [ precomputed feed from normal followees (PUSH) ]
⨁ merged with
[ recent posts pulled from celebrity followees (PULL) ] at read
You do a little pull work at read time, but only for the handful of huge accounts you follow — bounded and cheap — while the mass of normal posts stays precomputed. Best of both.
Step 7 — Ranking, caching & scaling
- Ranking: reverse-chronological is the simple, defensible baseline — state it. A ranked feed adds a scoring service that orders candidate posts by relevance/engagement/recency (a whole ML subsystem); the feed becomes a candidate set that gets scored, not a raw sort. Don't pretend ranking is trivial, but don't disappear into it either.
- Store IDs, not content. Feeds hold post IDs; the actual post is stored once and hydrated from a post cache at read time. Otherwise you'd copy a viral post into millions of feeds.
- Cap feed length. Precompute only the latest ~hundreds of IDs; deep history is fetched on demand.
- Skip inactive users. Don't fan out to accounts that haven't logged in for weeks — rebuild their feed on next login. Massive fan-out savings.
- Shard posts and feeds by
userId; scale the fan-out service horizontally (it's embarrassingly parallel). - Read-your-own-writes: show a user their own new post immediately (client-side or a targeted insert) even though global fan-out is eventual.
Step 8 — Trade-offs & wrap-up
- Push vs pull vs hybrid: fast reads/costly writes vs cheap writes/slow reads vs the best-of-both hybrid. The hybrid is the answer, and the follower-count threshold is the tunable.
- Chronological vs ranked: simplicity/predictability vs engagement/complexity. Chronological is a fine interview baseline.
- Store IDs vs full posts: an extra hydration hop, in exchange for not duplicating content into millions of feeds.
- Consistency: eventual — a post may take seconds to propagate; that's an acceptable trade for a feed.
The design checklist
- Scoped to post / follow / load-feed; parked likes/ranking/ads.
- Estimated: reads ≫ writes, and the fan-out amplification (celebrity = millions of writes/post).
- Chose the hybrid fan-out and named the follower-count threshold.
- Precomputed feeds of post IDs in Redis, capped length.
- Hydrate content from a post cache (store IDs, not posts).
- Skip fan-out to inactive users.
- Baseline chronological; noted ranking as a separate scoring service.
- Handled read-your-own-writes and eventual consistency.
In production at Mattrx
Mattrx team members follow campaigns and open an activity feed showing recent conversion.tracked, budget.threshold.crossed, and anomaly.detected events from every campaign they follow. The first version computed that feed at read time: opening it fired a single fan-in query across all of a user's followed campaigns, which meant a fan-in scan against the CampaignEvents table (1.2B rows) on every load — up to ~120k rows for a power user's follow set — and paying for it in tail latency. We moved to a hybrid fan-out-on-write model — normal campaigns push each event id into per-follower Redis lists (capped at 500), while high-volume "celebrity" campaigns (≥5,000 followers) skip fan-out and get merged in at read time — so a feed open is now one LRANGE plus a small merge instead of a fan-in scan.
| Metric | Before (computed-at-read) | After (hybrid precomputed feed) |
|---|---|---|
| Activity-feed load p95 | ~600 ms | ~35 ms |
| Activity-feed load p99 | ~1,400 ms | ~90 ms |
| Azure SQL round-trips per feed open | 1 fan-in query across every followed campaign | 0 (served from Redis) |
| Rows scanned per feed open (peak follower) | up to ~120k CampaignEvents | 0 |
| Fan-out work per activity event | none (all cost deferred to read) | 1 LPUSH + LTRIM + EXPIRE per follower, off the request path |
| Campaigns on the pull path | n/a | ~0.01% (a few hundred flagship campaigns, ≥5,000 followers) |
| Redis working set for feeds | n/a | ~2.3 GB (500-id cap per feed) |
The celebrity fallback is what keeps this honest: without it, a single event on an 8,000-follower campaign would trigger 8,000 writes and spike the worker, so we bound write amplification by pulling those few hot campaigns at read time and accept a slightly heavier merge for the users who follow them.
The architecture at Mattrx
Activity event (conversion.tracked, ...) ──▶ [ Fan-out worker (.NET 9) ]
│
normal campaign ──push──▶ [ Azure Redis · per-follower feed (capped) ]
high-volume campaign ──skip (pulled at read time)
React activity feed ──▶ [ Feed API ] ──▶ read Redis feed + merge pulled high-volume campaigns
Production implementation (Mattrx)
Here is the actual fan-out worker Mattrx runs — a MediatR notification handler that pushes each new activity event into every follower's precomputed Redis feed for normal campaigns, but short-circuits to a per-campaign timeline (the pull path) once a campaign crosses the celebrity threshold.
using MediatR;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
namespace Mattrx.ActivityFeed.Fanout;
// Raised when a followed campaign records a new activity item.
public sealed record CampaignActivityEvent(
Guid EventId,
Guid TenantId,
Guid CampaignId,
string ActivityType, // conversion.tracked | budget.threshold.crossed | anomaly.detected
DateTimeOffset OccurredAt) : INotification;
public sealed class ActivityFeedOptions
{
// At or above this follower count a campaign is a "celebrity": we skip
// fan-out on write and merge its timeline in at read time instead.
public int CelebrityFollowerThreshold { get; init; } = 5_000;
// Feeds are capped so per-user memory stays bounded and reads are O(1).
public int MaxFeedLength { get; init; } = 500;
public TimeSpan FeedTtl { get; init; } = TimeSpan.FromDays(30);
}
public interface ICampaignFollowerStore
{
Task<int> GetFollowerCountAsync(Guid campaignId, CancellationToken ct);
IAsyncEnumerable<Guid> StreamFollowerIdsAsync(Guid campaignId, CancellationToken ct);
}
public sealed class CampaignActivityFanoutHandler(
IConnectionMultiplexer redis,
ICampaignFollowerStore followers,
IOptions<ActivityFeedOptions> options,
ILogger<CampaignActivityFanoutHandler> logger)
: INotificationHandler<CampaignActivityEvent>
{
private readonly IDatabase _db = redis.GetDatabase();
private readonly ActivityFeedOptions _opts = options.Value;
public async Task Handle(CampaignActivityEvent evt, CancellationToken ct)
{
var followerCount = await followers.GetFollowerCountAsync(evt.CampaignId, ct);
// Celebrity campaign: fanning out to N feeds on every event is too much
// write amplification. Store once on the campaign timeline; readers pull it.
if (followerCount >= _opts.CelebrityFollowerThreshold)
{
await AppendCappedAsync(TimelineKey(evt.TenantId, evt.CampaignId), evt.EventId);
logger.LogDebug(
"Skipped fan-out for celebrity campaign {CampaignId} ({Followers} followers); pull path.",
evt.CampaignId, followerCount);
return;
}
// Normal campaign: push the event id into each follower's precomputed feed.
var payload = evt.EventId.ToString("N");
var pushed = 0;
await foreach (var followerId in followers.StreamFollowerIdsAsync(evt.CampaignId, ct))
{
ct.ThrowIfCancellationRequested();
await AppendCappedAsync(FeedKey(evt.TenantId, followerId), evt.EventId, payload);
pushed++;
}
logger.LogInformation(
"Fanned out activity {EventId} for campaign {CampaignId} to {Feeds} feeds.",
evt.EventId, evt.CampaignId, pushed);
}
// LPUSH newest-first, LTRIM to the cap, refresh TTL — pipelined in one batch.
private async Task AppendCappedAsync(RedisKey key, Guid eventId, string? payload = null)
{
var value = (RedisValue)(payload ?? eventId.ToString("N"));
var batch = _db.CreateBatch();
var push = batch.ListLeftPushAsync(key, value);
var trim = batch.ListTrimAsync(key, 0, _opts.MaxFeedLength - 1);
var ttl = batch.KeyExpireAsync(key, _opts.FeedTtl);
batch.Execute();
await Task.WhenAll(push, trim, ttl);
}
// Hash-tag on tenantId keeps a tenant's keys on one cluster slot.
private static RedisKey FeedKey(Guid tenantId, Guid userId) =>
$"feed:{{{tenantId:N}}}:user:{userId:N}";
private static RedisKey TimelineKey(Guid tenantId, Guid campaignId) =>
$"timeline:{{{tenantId:N}}}:campaign:{campaignId:N}";
}
The handler runs off the request path — the domain change commits first, then a background worker publishes the CampaignActivityEvent notification to it, so nothing runs inline on the request and fan-out cost never touches the API's 120ms p95 budget. The {tenantId} hash tag on every key co-locates a tenant's feeds on one Redis Cluster slot, and the ListTrimAsync after each push is what keeps a hot follower's feed from growing past 500 ids.
The honest stuff: caveats and when it's overkill
- A small app doesn't need fan-out at all. With thousands of users, pull (query followees' posts at read time) is the entire system. Build fan-out infrastructure only when read latency actually hurts.
- Pure fan-out on write dies on celebrities. Never propose push alone at Twitter scale — the interviewer is waiting to hand you a 100M-follower account. Go hybrid.
- Ranking is a huge subsystem, not a
sort(). Chronological is honest and fine; if you claim a ranked feed, acknowledge it's an ML scoring service, feature pipeline and all. - Fan-out to inactive users is wasted work. Gate precomputation on recent activity, or you're maintaining hundreds of millions of feeds nobody reads.
- Copying full posts into feeds wastes storage. Store IDs and hydrate; a viral post should exist once, not a million times.
- Eventual consistency is visible. Your post may not appear in others' feeds for seconds — usually fine, but handle read-your-own-writes so you see it instantly.
- Feeds are unbounded — cap them. You don't need infinite scroll precomputed; keep the cached feed short and page the rest from storage.
The model to carry forward
A news feed is a read-heavy system that you make fast by precomputing feeds on write — except where that's too expensive. Push posts into followers' cached feeds so reads are O(1); fall back to pulling at read time for the rare celebrity whose fan-out would be a write storm; merge the two. Store IDs not content, cap the feed, skip the inactive, and be honest that ranking is its own world. Nail the push-vs-pull-vs-hybrid reasoning and the celebrity threshold, and you've answered the question the interviewer actually asked.
Three habits this problem teaches:
- Lead with the fan-out decision. Push vs pull vs hybrid is the interview — get there fast.
- Reach for the celebrity yourself. Naming the high-follower failure before you're asked shows you've thought past the naive design.
- Precompute the common case, compute the rare one. That's the whole hybrid, and it generalizes far beyond feeds.
Further reading
- How to Crack Any System Design Interview: A Repeatable Framework
- Design a Notification System — Push, SMS & Email at Scale
- We Replaced REST with Kafka and Cut Failures by 90%
Prepping for a system design round and want the news-feed follow-ups drilled — or the next "Design X" walked through the same way? I'm always happy to help; reach me at randhir.jassal@gmail.com.
Get the next issue
A short, curated email with the newest posts and questions.