Skip to content
chiltepin

Generated from: “Explain how a new post reaches every follower's feed quickly, and what we do differently for accounts with millions of followers.

Feed fan-out

Written by an agent from the skill, validated by chiltepin check, rendered by the renderer — shown as generated, 13 September 2026.

DOCUMENTDESIGN

Feed fan-out

How a new post reaches every follower, and why accounts with millions of followers take a different path.

A post is written once and read by every follower. We move that work off the read path. A fan-out worker pushes the post id into each follower's feed cache right after the post commits, so a feed read is one cache lookup. Above a follower-count threshold that push is too expensive. Those accounts switch to pull: followers fetch their latest posts at read time and merge them in.

SECTION 01 · Note

Assumptions

Note
The request did not fix the stack or the numbers. This document assumes Postgres for posts and the follow graph, Kafka for the post.created topic, and Redis sorted sets for the feed cache. It assumes a 5 s p95 delivery target for pushed posts and a 10,000-follower threshold between push and pull. Correct any of these and the envelope and spec below change with them.

Who is wired to whom

The Post API never talks to a follower's feed. It commits the post and an outbox row, and everything after that flows through the post.created topic. A fan-out worker outage delays feeds; it cannot lose a post, because the outbox row stays pending until the relay publishes it.

SECTION 02 · Architecture
EVENT
Block diagram: 8 nodes, 8 connectionsFan-out consumer groupPost APIpost.createdPRODUCERPosts DBPostgres + outboxDBpost.createdKafka, key =author_idTOPICFan-out workerCONSUMER×12Follow graphPostgresDBFeed cacheRedis sorted setsDBPull-account postsRedis list per authorDBFeed APISVC1234567
1insert post + outbox row2outbox relay publishes3page follower ids4push post id per follower5append when author is pull6read cached feed7merge at read time
LegendPRODUCERproducerDBdatabaseTOPICtopicCONSUMERconsumerSVCservice×Nreplicascallsasync / optionalentry point

One post, hop by hop

The client waits only for the database commit. Every hop after 201 Created is asynchronous, so a slow fan-out never slows the author. The feed cache write is a ZADD keyed by post id, so a redelivered event writes the same member again and changes nothing.

SECTION 03 · Sequence
SEQUENCE
Sequence diagram: 15 messages between 8 actorsClientPost APIPosts DBOutbox relaypost.createdFan-out workerFollow graphFeed cacheLOOP[per page of 1,000 followers]ALT[feed cache write fails][5 attempts exhausted]1POST /posts2insert post and outbox row in one transaction3committed4201 Created with post_id5poll pending outbox rows6publish post.created (key = author_id)7deliver post.created8followers of author_id, active in last 30 days9follower ids in pages of 1,00010ZADD feed:{follower_id} post_id, score = created_at11OK12timeout13retry with backoff, 5 attempts max14move event to post.created.dlq15commit offset
Legendcallresponseasyncerrorthe answer the caller getsfragment (alt / opt / loop)active

Why push stops at 10,000 followers

Push costs one cache write per follower per post. For a median author that is a few hundred writes and the fleet absorbs it. For one account with five million followers, a single post occupies the whole fleet for minutes and every other author's post waits behind it. The threshold falls out of the delivery budget, not out of taste.

SECTION 04 · Capacity math

Push cost per post

Posts per day
2M
Median followers per author
300
Followers of one large account
5M
Feed cache write throughput, whole fleet
30,000 writes/s
Concurrent posts in fan-out at peak
~15
Delivery target for a pushed post
5 s (p95)
Feed writes per day, push for everyone2M × 300600M/day
Average write rate600M / 86,400 s≈ 7,000/s
Peak write rate7,000 × 3 (peak factor)≈ 21,000/s, inside the 30,000/s fleet
One large-account post, pushed5M / 30,000 per s≈ 170 s with the whole fleet, everyone else waits
Write share per post at peak30,000 / 15 concurrent posts≈ 2,000/s per post
Followers that fit the budget2,000/s × 5 s10,000 followers
Push below 10,000 followers
Pull at or above; 5M followers would take 34× the budget

What the worker does with each event

The worker decides once per event, from the author's follower count at that moment. A pull author's post is one list append; the followers pay a small read-time cost instead. A push author's post is paged through the follow graph in batches so one slow batch retries alone.

SECTION 05 · Flowchart
FLOW
Flowchart: 10 stepspost.createdconsumedCount followers ofauthor10,000 followersor more?Append post id toauthor's pull listLoad next 1,000active followersWrite post id toeach follower's feedMore followers?Commit offsetFewer than 5attempts?post.created.dlq1234567
1yes, pull author2no, push author3yes4no5cache write fails6yes, back off and retry7no
Legendstartstepdecision (diamond)exitnextoptionalerror path

An account that crosses the threshold switches to pull for its next post; its older pushed posts stay in follower caches until they age out. It switches back to push only under 8,000 followers, so an account that hovers at the line does not flip on every post.

The numbers we hold to

SECTION 06 · Spec

Fan-out contract

Delivery target
A pushed post is in every active follower's feed cache within 5 s (p95).
Push or pull
Push below 10,000 followers. Pull at or above. Switch back to push only under 8,000.
Batch size
1,000 follower ids per feed cache write batch.
Delivery guarantee
At-least-once from post.created. The write is ZADD keyed by post_id, so a redelivery changes nothing.
Ordering
Partition key = author_id. One author's posts arrive in order; two authors' posts do not.
Feed cache entry
One Redis sorted set per follower, score = created_at, trimmed to 800 post ids, TTL 30 days.
Inactive followers
No push to a follower with no session in 30 days. The feed rebuilds from the follow graph on the next login.
Read path
Read one page of the feed cacheFetch the 20 latest post ids from each followed pull authorMerge by created_atHydrate post bodies
Retry and dead letter
5 attempts, backoff 200 ms to 3 s, then post.created.dlq. Page on-call at 10 dead letters in 5 min.

The read-time merge is bounded. A reader follows at most a few dozen pull authors, so the Feed API adds a few dozen small list reads to one cache read. That bound is what makes the split safe. If pull authors ever number in the thousands per reader, the threshold or the merge has to change.

View the Markdown
```meta
title: Feed fan-out
subtitle: How a new post reaches every follower, and why accounts with millions of followers take a different path.
tag: DESIGN
```

A post is written once and read by every follower. We move that work off the
read path. A fan-out worker pushes the post id into each follower's feed cache
right after the post commits, so a feed read is one cache lookup. Above a
follower-count threshold that push is too expensive. Those accounts switch to
pull: followers fetch their latest posts at read time and merge them in.

```callout
tone: note
title: Assumptions
body: "The request did not fix the stack or the numbers. This document assumes Postgres for posts and the follow graph, Kafka for the post.created topic, and Redis sorted sets for the feed cache. It assumes a 5 s p95 delivery target for pushed posts and a 10,000-follower threshold between push and pull. Correct any of these and the envelope and spec below change with them."
```

## Who is wired to whom

The Post API never talks to a follower's feed. It commits the post and an
outbox row, and everything after that flows through the `post.created` topic.
A fan-out worker outage delays feeds; it cannot lose a post, because the
outbox row stays pending until the relay publishes it.

```block
preset: event
dir: LR
groups:
  - { id: workers, col: 3, row: 1, cols: 1, rows: 2, label: Fan-out consumer group }
nodes:
  - { id: postapi, col: 1, row: 1, kind: producer, name: Post API, tech: post.created }
  - { id: postsdb, col: 1, row: 2, kind: store, name: Posts DB, tech: Postgres + outbox }
  - { id: topic, col: 2, row: 1, kind: topic, name: post.created, tech: "Kafka, key = author_id" }
  - { id: w1, col: 3, row: 1, kind: consumer, name: Fan-out worker, replicas: 12 }
  - { id: graph, col: 3, row: 2, kind: store, name: Follow graph, tech: Postgres }
  - { id: feed, col: 4, row: 1, kind: store, name: Feed cache, tech: Redis sorted sets }
  - { id: celeb, col: 4, row: 2, kind: store, name: Pull-account posts, tech: Redis list per author }
  - { id: feedapi, col: 5, row: 1, kind: service, name: Feed API }
edges:
  - postapi -> postsdb: insert post + outbox row
  - postsdb --> topic: outbox relay publishes
  - topic -> w1
  - w1 -> graph: page follower ids
  - w1 -> feed: push post id per follower
  - w1 -> celeb: append when author is pull
  - feed -> feedapi: read cached feed
  - celeb --> feedapi: merge at read time
```

## One post, hop by hop

The client waits only for the database commit. Every hop after `201 Created`
is asynchronous, so a slow fan-out never slows the author. The feed cache
write is a `ZADD` keyed by post id, so a redelivered event writes the same
member again and changes nothing.

```sequence
id: post-fanout
actors:
  - { id: Client, name: Client }
  - { id: PostAPI, name: Post API }
  - { id: PostsDB, name: Posts DB }
  - { id: Relay, name: Outbox relay }
  - { id: Topic, name: post.created }
  - { id: Worker, name: Fan-out worker }
  - { id: Graph, name: Follow graph }
  - { id: Feed, name: Feed cache }
messages:
  - Client -> +PostAPI: POST /posts
  - PostAPI -> +PostsDB: insert post and outbox row in one transaction
  - PostsDB --> -PostAPI: committed
  - PostAPI --> -Client: 201 Created with post_id
  - Relay -> PostsDB: poll pending outbox rows
  - { from: Relay, to: Topic, label: "publish post.created (key = author_id)", kind: async }
  - { from: Topic, to: Worker, label: "deliver post.created", kind: async }
  - Worker -> Graph: followers of author_id, active in last 30 days
  - Graph --> Worker: follower ids in pages of 1,000
  - loop: per page of 1,000 followers
  - { from: Worker, to: Feed, label: "ZADD feed:{follower_id} post_id, score = created_at" }
  - Feed --> Worker: OK
  - end
  - alt: feed cache write fails
  - Worker -x-> Feed: timeout
  - Worker -> Feed: retry with backoff, 5 attempts max
  - else: 5 attempts exhausted
  - { from: Worker, to: Topic, label: "move event to post.created.dlq", kind: async }
  - end
  - Worker -> Topic: commit offset
```

## Why push stops at 10,000 followers

Push costs one cache write per follower per post. For a median author that is
a few hundred writes and the fleet absorbs it. For one account with five
million followers, a single post occupies the whole fleet for minutes and every
other author's post waits behind it. The threshold falls out of the delivery
budget, not out of taste.

```envelope
title: Push cost per post
assumptions:
  - { label: Posts per day, value: 2M }
  - { label: Median followers per author, value: "300" }
  - { label: Followers of one large account, value: 5M }
  - { label: "Feed cache write throughput, whole fleet", value: "30,000 writes/s" }
  - { label: Concurrent posts in fan-out at peak, value: "~15" }
  - { label: Delivery target for a pushed post, value: "5 s (p95)" }
steps:
  - { label: "Feed writes per day, push for everyone", calc: "2M × 300", result: 600M/day }
  - { label: Average write rate, calc: "600M / 86,400 s", result: "≈ 7,000/s" }
  - { label: Peak write rate, calc: "7,000 × 3 (peak factor)", result: "≈ 21,000/s, inside the 30,000/s fleet" }
  - { label: "One large-account post, pushed", calc: "5M / 30,000 per s", result: "≈ 170 s with the whole fleet, everyone else waits" }
  - { label: Write share per post at peak, calc: "30,000 / 15 concurrent posts", result: "≈ 2,000/s per post" }
  - { label: Followers that fit the budget, calc: "2,000/s × 5 s", result: "10,000 followers" }
result: { label: "Push below 10,000 followers", value: "Pull at or above; 5M followers would take 34× the budget" }
```

## What the worker does with each event

The worker decides once per event, from the author's follower count at that
moment. A pull author's post is one list append; the followers pay a small
read-time cost instead. A push author's post is paged through the follow graph
in batches so one slow batch retries alone.

```flow
dir: LR
nodes:
  - { id: start, col: 1, row: 1, kind: start, label: post.created consumed }
  - { id: count, col: 2, row: 1, kind: process, label: Count followers of author }
  - { id: dec, col: 3, row: 1, kind: decision, label: "10,000 followers or more?" }
  - { id: celeb, col: 4, row: 1, kind: process, label: Append post id to author's pull list }
  - { id: page, col: 4, row: 2, kind: process, label: "Load next 1,000 active followers" }
  - { id: write, col: 5, row: 2, kind: process, label: Write post id to each follower's feed cache }
  - { id: more, col: 6, row: 2, kind: decision, label: More followers? }
  - { id: done, col: 7, row: 1, kind: end, label: Commit offset }
  - { id: retry, col: 5, row: 3, kind: decision, label: Fewer than 5 attempts? }
  - { id: dlq, col: 6, row: 3, kind: end, label: post.created.dlq }
edges:
  - start -> count
  - count -> dec
  - dec -> celeb: "yes, pull author"
  - dec -> page: "no, push author"
  - celeb -> done
  - page -> write
  - write -> more
  - more -> page: "yes"
  - more -> done: "no"
  - write -x-> retry: cache write fails
  - retry --> write: "yes, back off and retry"
  - retry -x-> dlq: "no"
```

An account that crosses the threshold switches to pull for its next post; its
older pushed posts stay in follower caches until they age out. It switches back
to push only under 8,000 followers, so an account that hovers at the line does
not flip on every post.

## The numbers we hold to

```spec
title: Fan-out contract
accent: teal
rows:
  - { label: Delivery target, value: "A pushed post is in every active follower's feed cache within 5 s (p95)." }
  - { label: Push or pull, value: "Push below 10,000 followers. Pull at or above. Switch back to push only under 8,000." }
  - { label: Batch size, value: "1,000 follower ids per feed cache write batch." }
  - { label: Delivery guarantee, value: "At-least-once from post.created. The write is ZADD keyed by post_id, so a redelivery changes nothing." }
  - { label: Ordering, value: "Partition key = author_id. One author's posts arrive in order; two authors' posts do not." }
  - { label: Feed cache entry, value: "One Redis sorted set per follower, score = created_at, trimmed to 800 post ids, TTL 30 days." }
  - { label: Inactive followers, value: "No push to a follower with no session in 30 days. The feed rebuilds from the follow graph on the next login." }
  - { label: Read path, steps: [Read one page of the feed cache, "Fetch the 20 latest post ids from each followed pull author", Merge by created_at, Hydrate post bodies] }
  - { label: Retry and dead letter, value: "5 attempts, backoff 200 ms to 3 s, then post.created.dlq. Page on-call at 10 dead letters in 5 min." }
```

The read-time merge is bounded. A reader follows at most a few dozen pull
authors, so the Feed API adds a few dozen small list reads to one cache read.
That bound is what makes the split safe. If pull authors ever number in the
thousands per reader, the threshold or the merge has to change.