Skip to content
BytePatterns

Design a News Feed: Fan-Out on Write vs Fan-Out on Read

9 min readBytePatterns

Design a news feed for system design interviews: fan-out on write vs read, the celebrity problem, the hybrid that fixes it, and how a feed page is ranked.

"Design a news feed" is really one decision dressed up as a big system: do you pay for a post when it is published, by copying it to every follower, or when a feed is opened, by gathering posts from everyone the reader follows? Each choice is right for most accounts and badly wrong for a few, and the answer interviewers want is the hybrid, with a clear reason for where the line sits.

The problem it solves

Show each user a page of recent posts from the accounts they follow, ranked, fast. The lesson's numbers: 10 million users, 500 posts a second, and fifty reads for every write. The ratio matters more than the totals. Feeds are opened far more often than posts are written, so work moved from reads to writes is usually a good trade.

The pieces every design has:

  • A post store, the source of truth, and each author's timeline: their own posts, newest first.
  • A follow graph: who follows whom, queried in both directions.
  • A feed service that assembles a page for a reader, and a ranker that orders it.

The intuition

Fan-out on write (push). When an author publishes, a background job copies the post id into a precomputed inbox for each follower, usually a capped list in a cache. Opening the feed is then one read of a list that is already assembled. Reads are cheap and predictable; the cost moved to publishing, and it is proportional to the author's follower count.

Fan-out on read (pull). Publishing writes only the author's timeline. Opening a feed fetches the recent timelines of everyone the reader follows and merges them. Publishing is instant; every open does a large merge, proportional to how many accounts the reader follows.

The celebrity problem breaks pure push. An account with forty million followers turns one post into forty million inbox writes, competing with everyone else's fan-out. Pure pull breaks the other way: a reader following hundreds of accounts pays hundreds of reads on every open.

The hybrid splits by audience size. Ordinary authors, the vast majority, fan out on write. Authors above a follower threshold are not fanned out at all; their posts are pulled at read time. A feed open reads the prebuilt inbox and merges in the timelines of the few large accounts the reader follows, then ranks. Both costs stay bounded: no publish writes more than the threshold, and no open pulls more than the handful of celebrities a person follows.

Ranking is a separate stage. Newest-first is a baseline, not a feed; as of September 2026, large feeds typically score candidates with learned models of predicted engagement, balanced against freshness and diversity, and fetch post bodies only for the page being shown.

Watch it run

The animation opens with the numbers: ten million users, 500 posts a second, and fifty reads for every write; the ratio decides everything. A post is written once to the author's own timeline, and that copy is the truth. With fan-out on write, the post id is copied into each follower's inbox: three followers, three tiny writes, all finished before anybody opens the app, so the read is one lookup of a list already assembled, 12 ms. Then the author has forty million followers, and one publish becomes forty million writes. Fan-out on read instead writes nothing beyond the author's timeline, but every open now merges hundreds of timelines, 600 ms every time. So it is split: ordinary authors fan out on write, and the enormous few are pulled on read. A feed open is the prebuilt inbox merged with the few pulled timelines, then the merged set is ranked and one page returned.

Design a News Feed

Step 1 of 11

Ten million users, 500 posts a second, and fifty reads for every write. The ratio decides everything.

The same interactive animation as the lesson — step through it with the controls.

The code

A toy model, not a feed service: dictionaries stand in for the timeline store, the inboxes and the follow graph. Authors above the threshold are pulled at read time; everyone else is pushed at write time. Counters show the work each side does:

import heapq
from collections import defaultdict

class Feed:
    """Toy model: hybrid fan-out. Push for ordinary authors, pull for the huge ones."""
    def __init__(self, celebrity_threshold):
        self.threshold = celebrity_threshold
        self.followers = defaultdict(set)
        self.following = defaultdict(set)
        self.timeline = defaultdict(list)  # author -> [(time, post_id)], the source of truth
        self.inbox = defaultdict(list)     # reader -> [(time, post_id)], prebuilt
        self.writes = self.reads = 0

    def follow(self, reader, author):
        self.followers[author].add(reader)
        self.following[reader].add(author)

    def is_celebrity(self, author):
        return len(self.followers[author]) > self.threshold

    def publish(self, author, time, post_id):
        self.timeline[author].append((time, post_id))
        self.writes += 1                               # the one copy that is the truth
        if not self.is_celebrity(author):
            for reader in self.followers[author]:      # fan-out on write
                self.inbox[reader].append((time, post_id))
                self.writes += 1

    def open(self, reader, page=3):
        sources = [self.inbox[reader]]                 # one prebuilt list
        self.reads += 1
        for author in self.following[reader]:
            if self.is_celebrity(author):              # fan-out on read, only for these
                sources.append(self.timeline[author])
                self.reads += 1
        merged = heapq.merge(*(reversed(s) for s in sources), reverse=True)
        return [post for _, post in list(merged)[:page]]

f = Feed(celebrity_threshold=2)
for reader in ("ana", "ben", "cy"):
    f.follow(reader, "star")                           # 3 followers: a celebrity here
f.follow("ana", "bo")
f.follow("ben", "bo")
f.publish("bo", 1, "bo-1")
f.publish("star", 2, "star-1")
f.publish("bo", 3, "bo-2")
print(f.open("ana"), f.writes, f.reads)   # ['bo-2', 'star-1', 'bo-1'] 7 2

The same trade in numbers, for one post by an account with forty million followers and one feed open by one of them:

def cost(followers, posts, opens, threshold):
    """Writes per publish and reads per open for one author's audience."""
    push = followers <= threshold
    writes = posts * (1 + (followers if push else 0))
    reads = opens * (1 + (0 if push else 1))
    return writes, reads

print(cost(40_000_000, 1, 1, threshold=10**9))   # (40000001, 1)   pure push
print(cost(40_000_000, 1, 1, threshold=10_000))  # (1, 2)          pulled on read

Checked on 300 seeded random follow graphs, thresholds and posting histories: for every user and a random page size, the hybrid feed must equal the brute-force answer, every post by every followed account sorted newest first:

import random

def brute_force(f, reader, page=3):
    posts = [p for a in f.following[reader] for p in f.timeline[a]]
    return [post for _, post in sorted(posts, reverse=True)[:page]]

random.seed(27)
ok = True
for _ in range(300):
    users = ["u%d" % i for i in range(random.randint(2, 12))]
    f = Feed(celebrity_threshold=random.randint(0, 6))
    for reader in users:
        for author in random.sample(users, random.randint(0, len(users) - 1)):
            if author != reader:
                f.follow(reader, author)
    for t in range(random.randint(0, 40)):
        author = random.choice(users)
        f.publish(author, t, "%s-%d" % (author, t))
    page = random.randint(1, 10)
    ok &= all(f.open(u, page) == brute_force(f, u, page) for u in users)
print(ok)                                  # True

The complexity

  • Publish, push: one timeline write plus one inbox write per follower, O(F), done asynchronously.
  • Publish, pull: one timeline write, O(1).
  • Open, push: one inbox read, O(page).
  • Open, hybrid: one inbox read plus one timeline read per followed celebrity, then a k-way merge of those lists.
  • Storage: inboxes hold post ids, not posts, capped at a few hundred per user, so they fit in a cache; bodies are fetched for one page only.

Where it goes wrong

  • Fanning out synchronously. The publish request should return after the timeline write; fan-out runs on a queue.
  • Storing full posts in inboxes. Edits and deletes then have to chase millions of copies. Store ids and hydrate.
  • Accounts crossing the threshold. When an author becomes a celebrity, older posts are already in inboxes and are now also pulled. Deduplicate by post id when merging.
  • Precomputing inboxes for inactive users. Many systems skip fan-out to accounts that have not logged in recently and rebuild on their next visit.

When it shows up in interviews

It is one of the standard system design prompts, often phrased as a social timeline or an activity stream. Follow-ups cover the celebrity threshold, cache sizing for inboxes, pagination with a cursor rather than an offset, and how deletes propagate. The inbox cache is an application of caching, the timeline store is usually partitioned by author as in database sharding, and the asynchronous fan-out is a queue of work like the delivery path in the chat app design.

How to say it in an interview

"Reads outnumber writes about fifty to one, so I want reads cheap. For ordinary authors I fan out on write: publishing stores the post once, then a background job pushes the post id into each follower's cached inbox, so opening the feed is one list read. That breaks for accounts with millions of followers, so above a follower threshold I skip the fan-out and pull their recent posts at read time. A feed open merges the inbox with those few timelines, deduplicates by post id, ranks the candidates and hydrates one page."