Fan-Out on Write Slow for Users With Many Followers: Hybrid Feeds
Architecture · Advanced · 7 min read · published
This article was written by Claude (Anthropic) and published automatically. Published by Osama Fayyaz.
What this solves: Your push-based feed works until one account with millions of followers posts and the fanout queue backs up for everyone. Here is the hybrid design that fixes it.
The Forces at Play
If fan-out on write is slow for users with many followers, you've hit the problem every push-based feed eventually hits. Pre-computing timelines makes reads cheap, but the write cost now scales with the author's follower count.
For an account with 300 followers, a post costs 300 cheap inserts. For an account with 3 million followers, the same post costs 3 million inserts. That cost lands at the exact moment that account is most active, during a live event.
Several pressures pull against each other:
- Read latency. Timelines are read 10–100x more often than posts are written, so you want reads to be one key lookup.
- Write amplification. Push turns one post into N writes, where N is wildly skewed. Follower counts follow a power law, so the top 0.01% of accounts produce most of the fanout work.
- Freshness. People expect a friend's post to appear within seconds. One celebrity's backlog shouldn't delay it.
- Memory. Materialised timelines for users who never log in are pure waste.
Pure fan-out on read (query posts WHERE author_id IN (...followed) ORDER BY ts) has the opposite problem. Writes are trivial, but every timeline load becomes a scatter-gather across hundreds of authors.
Neither extreme survives a power-law follower graph. The hybrid does.
The Shape
The hybrid splits authors into two tiers by follower count:
- Push tier: ordinary authors. Their posts are written into followers' timelines at post time.
- Pull tier: a small set of huge authors. Their posts are written once, to a per-author list, and merged in at read time.
flowchart LR
subgraph Write path
A[Post Service] -->|insert| P[(Posts DB)]
A --> T{follower_count >= threshold?}
T -->|no: push| Q1[[fanout-small queue]]
T -->|no, large-ish| Q2[[fanout-large queue]]
T -->|yes: pull| R[(author:ID:recent<br/>shared sorted set)]
Q1 --> W[Fanout Workers<br/>1 job = 1k followers]
Q2 --> W
G[(Social Graph<br/>followers + last_active)] --> W
W -->|ZADD + trim| TL[(timeline:USER<br/>per-user sorted set)]
end
subgraph Read path
U[Client] --> S[Timeline Service]
S -->|1. ZREVRANGE| TL
S -->|2. list followed pull-tier authors| G
S -->|3. pipelined multi-get| R
S -->|4. hydrate ids| P
S --> M[Merge + rank + dedupe]
M --> U
end
The threshold decision sits on the write path, but its cost is paid on the read path. Every pull-tier author a user follows adds one more list to merge on each read.
How Data Flows Through It
Take one ordinary post and one celebrity post, then one read.
Alice (800 followers) posts.
- Post Service writes the row to Posts DB.
- It reads Alice's cached
follower_count. 800 is below the threshold, so this is a push. - It enqueues one job on
fanout-small. - A worker pages through Alice's followers, skipping anyone inactive for 30+ days.
- For each remaining follower it runs
ZADD timeline:{follower} ts post_id, then trims the set to the newest 800 entries. - The post is visible to followers within a second or two.
A league account (2.3M followers) posts.
- Post Service writes the row to Posts DB.
- The account is over the threshold, so this is a pull.
- It runs one
ZADD author:{league}:recent, trimmed to about 200 entries. - No fanout happens. The write costs the same as Alice's single row.
Bob opens his feed. He follows Alice and the league.
- Timeline Service reads the top 50 ids from
timeline:bob. Alice's post is there. - It asks the graph which of Bob's follows are pull-tier. The answer is a small, cached set.
- It multi-gets the top 20 ids from each of those
recentlists in one pipelined round trip. - It merges by timestamp (or a ranking score), dedupes, and cuts to 50.
- It hydrates the post ids from a post cache, with Posts DB as the fallback.
The message contract on the fanout queue is deliberately tiny. Workers carry ids and a cursor, never post bodies:
{
"post_id": "01J9Z3K8W7",
"author_id": "u_4821",
"ts": 1735689600123,
"follower_cursor": "u_190000",
"chunk_size": 1000,
"attempt": 1
}
Because each job covers one chunk of followers, a retried job re-applies the same ZADD for the same chunk. ZADD with the same score is idempotent, so retries are safe.
What Each Piece Owns
- Post Service owns the canonical post and the push/pull routing decision.
- It does not own delivery. It never loops over followers itself.
- Social Graph owns follow edges, cached follower counts,
last_active, and the tier flag for each author.- It does not own timelines.
- Tier changes should be sticky, with hysteresis. Otherwise an account hovering at the threshold flips back and forth.
- Fanout Workers own turning one post into bounded chunks of timeline inserts.
- They do not own ranking or post content.
- They don't decide tiers either. They trust the job they were given.
- Per-user timeline sets own a capped, best-effort list of post ids.
- They are a cache, not a source of truth. They must be rebuildable from the graph plus Posts DB, which is how inactive users get served on their next login.
- Per-author recent sets own the hot tail of each pull-tier author's posts, shared by every reader.
- Timeline Service owns merge, dedupe, ranking, and hydration.
- It does not write timelines, except to trigger a lazy rebuild on a cache miss.
Where It Breaks Down
The first bottleneck is head-of-line blocking in the fanout queue.
- Symptom: one big push-tier author posts and everyone's fanout lag climbs.
- Cause: one job per post means a 400k-follower job holds a worker for minutes.
- Fix: chunk jobs into 1,000-follower pieces, and split queues by author size so small authors never wait behind large ones.
Read-side fan-in appears next.
- Symptom: a user who follows 400 pull-tier accounts pays for 400 list reads per page load.
- Fix: pipeline the multi-get so it's one round trip, cap how many posts you take from each list, and cache the merged result for a few seconds.
- Watch out: if you lower the threshold to reduce write cost, you shift that cost onto readers. Track p99 read latency per follow-count bucket whenever you change it.
Threshold transitions are an easy place to lose posts.
- When an account moves from pull to push, posts made before the switch exist only in its
recentlist. Followers' timelines won't contain them. - Fix: keep reading the
recentlist for a grace window after the switch, or backfill the timelines. - Moving from push to pull is safer: old posts are already in timelines, and new ones get merged in at read time.
Deletes and unfollows don't retract themselves.
- Push already put the post id into 2,000 timelines.
- Fix: filter at hydration time. Drop ids whose post is deleted or whose author is no longer followed. Clean up the timelines lazily.
- Don't try to fan out deletions synchronously.
Redis memory becomes the operational burden.
- Timeline count × cap × entry size grows fast.
- Fix: skip inactive followers and keep caps tight (a few hundred ids).
- Watch out: this is the piece that pages you at 3am, not the workers.
Under partial failure, a stalled worker pool degrades freshness, not availability.
- Timelines still load. They're just stale.
- Pull-tier posts stay fresh because they never depend on workers.
- That asymmetry is a feature. Make sure your alerting watches fanout lag, not just error rate.
When This Is Overkill
Most products should start with plain fan-out on read: a posts(author_id, created_at) index and a query that pulls recent posts from followed authors. Postgres handles this comfortably while users follow a few hundred accounts and you have moderate traffic.
The next step is pure fan-out on write with chunked jobs. It's fine as long as your largest account's followers can be fanned out within your freshness target.
Move to the hybrid when you see one of these specific signals:
- Your top accounts' fanout regularly exceeds your freshness budget. For example, a single author's push takes longer than 30 seconds even when chunked.
- Fanout lag spikes line up with specific authors posting, rather than with overall traffic.
If instead your follower distribution is flat (internal tools, B2B activity feeds, small communities), you'll never need the pull tier. Keep the simpler design.
Key takeaway: Push posts into timelines for ordinary accounts, pull at read time for the few huge ones, and treat the follower-count threshold, chunked fanout jobs and inactive-user skipping as the knobs you tune.
Real-world challenge
Your social app uses fan-out on write: every new post enqueues one job, and a worker loops over the author's followers and runs ZADD into each follower's Redis timeline. A sports league account with 2.3M followers posts during a match. For the next 25 minutes, ordinary users report that their friends' posts aren't showing up. Redis CPU is fine, but the fanout queue depth went from about 50 to 40,000 and fanout lag on the dashboard climbed steadily.
Diagnosis
This is head-of-line blocking on the write path, and one job is causing it.
- The league's post produced a single fanout job that has to iterate 2.3M followers.
- That job holds a worker for many minutes.
- Each later post from that account (it posts every few minutes during a match) adds another multi-minute job.
- Ordinary users' posts, each needing only about 200 ZADDs, wait behind these jobs in the same FIFO queue.
Redis is not the bottleneck. Job granularity and queue sharing are.
Fix, in order of impact
- Stop pushing for huge accounts. Mark authors above a threshold as
pulland write their posts only to a per-author recent list. Readers merge that list in at read time. - Chunk the fanout that remains. One job per 1,000 followers, not one job per post. This keeps any single job short and lets workers interleave.
- Split queues by tier. Small authors and large authors go to separate queues, so a 400k-follower account can't starve a 200-follower one.
- Skip inactive followers. Users who haven't opened the app in 30 days get their timeline rebuilt on next login instead.
def enqueue_fanout(post, author):
if author.follower_count >= PULL_THRESHOLD:
redis.zadd(f"author:{author.id}:recent", {post.id: post.ts})
return
queue = "fanout-large" if author.follower_count > 20_000 else "fanout-small"
for cursor in follower_cursors(author.id, chunk=1000, active_days=30):
jobs.enqueue(queue, {"post_id": post.id, "ts": post.ts, "cursor": cursor})
Verify: measure p99 fanout lag per queue separately. Small-author lag should stay flat even while a large account posts in bursts.