HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 792 min read

Fan-out on Write vs Fan-out on Read

System designsystem design / scalability

3:02 AM, and the feed is dead

Your social app has 400k users. Home feed loads in 40ms. Then a footballer with 2.1M followers signs up, posts once — and every write starts timing out. Not reads. Writes. Posting a photo now takes 90 seconds.

Nothing about your read path changed. So why did a write break?

Because you chose fan-out on WRITE

Two ways to build a feed:

Fan-out on read (pull): store the post once. At read time, fetch the people I follow and merge their recent posts.

SELECT * FROM posts WHERE author_id IN (...300 ids...)
ORDER BY created_at DESC LIMIT 50;

Fan-out on write (push): when someone posts, copy a row into every follower's inbox table. Reading is then one query on one index.

for follower in followers(author):   # 2.1 MILLION times
    insert(feed[follower], post_id)

Push makes reads O(1) and writes O(followers). That's the trade you signed for — you just never met a user with 2.1M followers before.

The numbers that decide it

Say 1 post is read by 300 people on average.

  • Pull: 1 write, 300 expensive merges.
  • Push: 300 cheap writes, 300 trivial reads.

Feeds are read-heavy (often 100:1), so push usually wins. Until the follower count is extreme — then one write becomes millions of rows, and your queue backs up behind a single celebrity.

What real systems actually do: hybrid

This is the part people miss in interviews.

  • Normal users → push. Fan out at write time into follower inboxes.
  • Accounts above a threshold (say >100k followers) → do NOT fan out. Store once.
  • At read time: read my precomputed inbox, then merge in the handful of "celebrity" accounts I follow, live.

You get O(1) reads for 99.9% of the timeline and skip the write explosion. Twitter published exactly this design; the threshold is a tunable number, not a law.

Three gotchas

  1. Deletes and edits. Push means the post is duplicated in millions of inboxes. Deleting it is now a second fan-out job. Store post_id only, never the post body — then delete = one row in posts, and reads hydrate from there (a missing post = skip it).
  2. Inactive users. Don't fan out to accounts that haven't opened the app in 30 days. Backfill on login instead. Often cuts write volume by half.
  3. Fan-out must be async. Doing it inside the POST /posts request handler is what caused the 90-second write. Enqueue a job, return 201 immediately, let workers chunk it (e.g. 1000 inserts per batch).

The generalizable idea

"Fan-out on write" = precompute at write time. It's the same decision as a materialized view, a denormalized counter, or a cached aggregate: you're moving work from the read path to the write path. The question is never "which is better" — it's "what's my read:write ratio, and what's my worst-case fan-out?"

Both numbers matter. The average tells you which to pick; the tail tells you where it breaks.

Your exercise

Pick a threshold for your own app. Given:

  • 500k users, average 200 followers
  • 5% of posts come from accounts with >50k followers
  • Write budget: 200ms p99 on POST /posts

Answer two things:

(a) With pure push and async workers, how many inbox rows does one celebrity post generate, and how long does that job run at 5k inserts/sec?

(b) At read time in a hybrid model, what extra query do you run — and what has to be true about a user's "celebrities I follow" list for that merge to stay fast?

Bonus: what breaks if a user follows 800 celebrities?