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:

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:

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.

  1. Post Service writes the row to Posts DB.
  2. It reads Alice's cached follower_count. 800 is below the threshold, so this is a push.
  3. It enqueues one job on fanout-small.
  4. A worker pages through Alice's followers, skipping anyone inactive for 30+ days.
  5. For each remaining follower it runs ZADD timeline:{follower} ts post_id, then trims the set to the newest 800 entries.
  6. The post is visible to followers within a second or two.

A league account (2.3M followers) posts.

  1. Post Service writes the row to Posts DB.
  2. The account is over the threshold, so this is a pull.
  3. It runs one ZADD author:{league}:recent, trimmed to about 200 entries.
  4. No fanout happens. The write costs the same as Alice's single row.

Bob opens his feed. He follows Alice and the league.

  1. Timeline Service reads the top 50 ids from timeline:bob. Alice's post is there.
  2. It asks the graph which of Bob's follows are pull-tier. The answer is a small, cached set.
  3. It multi-gets the top 20 ids from each of those recent lists in one pipelined round trip.
  4. It merges by timestamp (or a ranking score), dedupes, and cuts to 50.
  5. 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

Where It Breaks Down

The first bottleneck is head-of-line blocking in the fanout queue.

Read-side fan-in appears next.

Threshold transitions are an easy place to lose posts.

Deletes and unfollows don't retract themselves.

Redis memory becomes the operational burden.

Under partial failure, a stalled worker pool degrades freshness, not availability.

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:

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.

Redis is not the bottleneck. Job granularity and queue sharing are.

Fix, in order of impact

  1. Stop pushing for huge accounts. Mark authors above a threshold as pull and write their posts only to a per-author recent list. Readers merge that list in at read time.
  2. 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.
  3. 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.
  4. 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.