Do the work at write time or read time — fan-out-on-write fills every follower’s inbox up front; fan-out-on-read assembles the feed on demand.
A home timeline shows you recent posts from everyone you follow. The hard question is when to do the merging work: once per post (at write time) or once per app open (at read time).
Fan-out on write (push) copies each new post into every follower’s precomputed inbox, so a read is just “return my inbox.” Fan-out on read (pull) stores each post once and gathers posts from everyone you follow at open time. Reads and writes trade places; real systems do both.
Green is the cheap, active path; warm marks the expensive blow-up — one celebrity post fanning out to a million inboxes.
Pick where the cost lands. With fan-out on write, the author’s post() loops over followers and appends the post id to each follower’s precomputed feed, so read() is a single lookup. With fan-out on read, post() writes once to the author’s own outbox, and read() gathers and merges recent posts from every account you follow.
# --- fan-out on write (push): heavy post(), trivial read() ---
def post_write(author, post_id):
save_post(post_id, author)
for follower in followers_of(author): # O(followers) writes
feed[follower].prepend(post_id) # fill each inbox now
def read_write(user):
return feed[user][:PAGE] # 1 read: just your inbox
# --- fan-out on read (pull): trivial post(), heavy read() ---
def post_read(author, post_id):
outbox[author].prepend(post_id) # 1 write to your own outbox
def read_read(user):
posts = []
for author in following_of(user): # O(following) fetches
posts += outbox[author][:PAGE]
return merge_by_time(posts)[:PAGE] # merge at read time
The cost is conserved, not removed: push pays O(followers) per post and reads for free; pull posts for free and pays O(following) per open. Which is cheaper depends on the read:write ratio and on how skewed the follower counts are.
| Property | Fan-out on write (push) | Fan-out on read (pull) |
|---|---|---|
Write cost (post) | O(followers) — one append per follower | O(1) — one append to your outbox |
Read cost (open feed) | O(1) — return the precomputed inbox | O(following) — gather + merge each open |
| Storage | High — a post id is duplicated into every inbox | Low — each post stored once |
| Who it suits | Normal authors (few followers, many readers) | Celebrity authors (millions of followers) |
| Staleness / consistency | Feed is fresh; edits and deletes must propagate to every copy | Always reflects the source; nothing to propagate |
The real-world answer is hybrid: push posts from normal authors into inboxes, but leave celebrity authors on pull and merge their recent posts in at read time. You pay a small per-open merge for the handful of huge accounts and avoid the million-write storm.
A user follows three normal friends and one celebrity. The normal friends’ posts are pushed into the user’s inbox at write time; the celebrity is pulled and merged at read time.
# write time — normal authors push, celebrity does not
friend_a.post(p101) # pushed -> inbox[user].prepend(p101)
friend_b.post(p102) # pushed -> inbox[user].prepend(p102)
celeb.post(p900) # 40M followers -> NO push; outbox[celeb].prepend(p900)
# read time — open the app
inbox = feed[user][:PAGE] # 1 read: p102, p101, ... (pushed)
celeb_posts = []
for a in celebrities_followed(user): # tiny set, usually 0-5 accounts
celeb_posts += cache_or_outbox(a)[:PAGE] # p900 (pulled, cached)
home = merge_by_time(inbox + celeb_posts)[:PAGE]
The two friend posts cost two inbox writes total; the celebrity post cost zero writes instead of 40 million. At read time the user pays one inbox read plus a tiny merge over the few celebrities they follow — the cost lands where it is cheapest in each case.
A user has 50M followers and posts often. Which fan-out strategy should serve their posts?
You delete a post that was already fanned out on write. Why is that more work than under fan-out on read?