Search Tech Journey

Find topics, journeys and posts

6-month learning plan129 / 130
back to blog
systemsadvanced 55m read

S129 · Design a Newsfeed / Recommender — Pull vs Push, Ranking

The highest-QPS ML application in the world. Fanout-on-write vs fanout-on-read, the celebrity problem, the two-stage candidate → rank funnel, and the feedback loops that eat naïve recommenders.

⚙️SystemsM15 · System Design· Session 129 of 130 90 min

🎯 Design a Facebook / Instagram / Twitter-class newsfeed end-to-end, choosing between push, pull, and hybrid fanout and covering the two-stage ranking pipeline that dominates every social product.

Why this session exists

The newsfeed is the moneymaker. Every social app makes 60–90% of its revenue from the surface most people call "the feed" or "for you." It's also the highest-QPS ML application in the world — every open scores hundreds of candidates against a personalised model in under 200 ms. Nail this problem in an interview and you've shown you can reason about caching, fanout, ranking, ML infra, and product/business trade-offs in one design.

You will be able to
  • Draw the two-stage feed pipeline (candidate generation → ranking) from memory.
  • Compare fanout-on-write, fanout-on-read, and hybrid strategies and pick one based on follower distribution.
  • Explain why celebrities force you into a hybrid — the '100M followers × N tweets' problem.
  • Describe the four inputs to a modern ranker: recency, affinity, engagement prediction, diversity.
  • Name three feedback-loop failure modes and the exploration + causal-eval defences.

Prerequisites

  • S126 · System Design Framework
  • S063 · Caching · S070 · Sharding
  • S091 · Recommender Systems background (collaborative filtering, content-based)


(a) Intuition · 5 min

A newsstand vs a personal butler
🌍 Real world

A chronological newsfeed is a newsstand: every publisher's papers laid out by date. You browse, you skim, you pick. Simple.

A ranked newsfeed is a personal butler who has been watching you for years. Before you walk in, they've already selected 30 things they think you'll like, ordered by predicted enjoyment, with two ads slipped in strategically. They will replace those 30 in real time based on what you glance at first.

💻 Code world

Two architectural extremes:

Fanout-on-write (push) — when Alice tweets, immediately write the tweet into the pre-computed timeline of each of her 500 followers. Reads: blazing fast. Writes: linear in follower count. Fatal when Alice has 100 M followers.

Fanout-on-read (pull) — at read time, look up who you follow, fetch their recent posts, merge and rank on the fly. Reads: slower. Writes: O(1). No celebrity problem.

Real systems use a hybrid: push for normal users, pull for celebrities, merge at read time.

The two halves of the problem
  • Delivery — which posts even reach the candidate pool. This is where push/pull/hybrid lives.
  • Ranking — which candidates land in the top 30. This is where the ML model + feature store + re-ranker live.
  • Skip either half and you've missed 50% of the interview signal.
  1. 2006
    Facebook News Feed launches
    First mass-market algorithmic feed. Users hate it initially, then can't live without it.
  2. 2013
    Twitter's Timeline moves off pure fanout-on-write
    Celebrity accounts force the hybrid model. Merges pull for celebs into push timelines.
  3. 2016
    Instagram switches to algorithmic feed
    Chronological retires. Engagement jumps ~30%; publisher complaints erupt.
  4. 2019
    DLRM open-sourced · Meta
    Deep Learning Recommendation Model — the workhorse ranker. Everyone in FAANG builds a variant.
  5. 2021
    TikTok For You dominates
    Transformer-based sequence model + brutal real-time feedback loop redefines expectations for the industry.
  6. 2024
    LLMs enter ranking
    First LLM-driven re-rankers ship in production (LinkedIn, Pinterest experiments). Cost/latency are still hard.

(b) Visual walkthrough · 15 min

The two-stage funnel

Fanout strategy trade-off table (know this cold)

Pure Push · fanout-on-write

Fast reads, expensive writes

  • Read latency ~10 ms (Redis)
  • Write cost O(followers) per post
  • Storage O(users × timeline_size)
  • Fatal for celebrities: 100 M writes per Bieber tweet
Pure Pull · fanout-on-read

Cheap writes, slow reads

  • Read latency 500 ms – 2 s
  • Write cost O(1) per post
  • Storage O(posts)
  • No celebrity problem, but reads suffer
Hybrid · push for normal, pull for celebs

Real-world answer

  • Read latency ~50 ms
  • Write cost O(normal-followers) per post
  • Celebrity posts pulled at read time
  • One more merge step — worth it

Hybrid feed for a normal user — step by step

1
1 · Load pre-computed timeline

Last 500 posts pushed to you by normal-follower fanout. Redis ZREVRANGE. ~5 ms.

2
2 · Query celebrity posts

For each celeb you follow, fetch recent posts directly. Parallel fan-in. ~30 ms.

3
3 · Merge candidates

Union of timeline + celeb posts → ~500 unique candidates.

4
4 · Feature fetch

Feature store returns user features (recent likes, dwell time on similar content) + post features (age, engagement so far, embedding). ~50 ms.

5
5 · Rank

500 (user, post) pairs into a DLRM or two-tower model → 500 scores. ~40 ms on TF-Serving / Triton.

6
6 · Re-rank

Apply diversity (no 5 travel posts in a row), ad insertion (every ~5th slot), safety filter. ~10 ms.

7
7 · Return top 30

Client renders. Total ~200 ms end-to-end.

Ranker model families (interview vocabulary)

Wide & Deep

Google Play, YouTube (legacy)

  • Wide = linear memorisation of sparse crosses
  • Deep = generalisation via embeddings
  • Handles sparse categorical features well
DLRM

Meta workhorse (Facebook, Instagram)

  • Dense + sparse features + interaction layer
  • Open-source PyTorch reference
  • Serves ~10 ms per 1000 candidates on GPU
Two-tower

YouTube, Pinterest, LinkedIn

  • Separate user + item towers → embeddings
  • Dot product at serve time
  • Item embeddings can be pre-computed → very fast
  • Best for candidate generation stage
Transformer sequence

TikTok, YouTube Shorts

  • Models user history as a token sequence
  • Predicts next-item engagement
  • Slower but SOTA on engagement
  • Real-time feedback the moat

Storage layout

Where data lives

Post store · Cassandra sharded by user_id (post owner)
Source of truth. Wide-column tolerates high write rate + per-user append pattern.
durable
Timeline cache · Redis sorted sets, key=timeline:{user}
Values are (post_id, score). Capped at ~500–800 entries per user via ZREMRANGEBYRANK.
hot cache
Feature store · online (Redis / RocksDB) + offline (Parquet / Delta)
Online serves ranking in ms; offline feeds nightly training.
ML infra
Interaction log · Kafka → warehouse (BigQuery / Hive)
Impressions, dwell time, likes, shares. Trains the next ranker.
data plane
Model registry + serving · TF-Serving / Triton / TorchServe
Blue/green model deployments, shadow eval, per-request feature freshness.
ML ops

Common misconception
✗ What most people think

"Precompute every user's feed on write. Reads are then a single lookup, which is what matters because reads vastly outnumber writes."

✓ What is actually true

Fanout-on-write is correct for the median user and catastrophic for the tail. A celebrity with fifty million followers turns one post into fifty million writes, arriving as a synchronous burst that saturates your write path and delays everyone else's feeds. Every real system at scale runs a hybrid: fanout-on-write for ordinary accounts, fanout-on-read for high-follower accounts, merged at query time.

Why the myth is so sticky

Because the read:write ratio argument is genuinely sound — it is the right analysis applied to the wrong distribution. Follower counts are heavy-tailed, so the average fanout is small and completely unrepresentative. Reasoning with a mean over a power-law distribution is the actual error, and it looks like careful quantitative thinking, which is why it survives review.

Prove it to yourself

Compute total fanout writes under a realistic follower distribution, not an average:

import numpy as np
rng = np.random.default_rng(0)
n = 1_000_000
followers = (rng.pareto(1.2, n) + 1) * 100        # heavy tail
print(f'mean={followers.mean():,.0f}  median={np.median(followers):,.0f}')
print(f'top 0.01% share of all fanout writes: '
      f'{np.sort(followers)[-n//10000:].sum()/followers.sum():.1%}')

# The mean is not the median, and a handful of accounts dominate
# total write volume. Designing for the mean designs for nobody.
From first principles
Start with the question

Why does the hybrid approach work, given that merging at read time reintroduces exactly the read-path cost fanout-on-write was meant to eliminate?

  1. 1
    Fanout-on-write cost is proportional to the number of followers of the poster. Fanout-on-read cost is proportional to the number of accounts a reader follows.
    forced by · one pushes to followers, the other pulls from followees; the costs attach to opposite ends of the graph
  2. 2
    Follower counts are heavy-tailed and unbounded — some accounts have tens of millions. Followee counts are bounded by human behaviour and product limits, typically a few hundred to a few thousand.
    forced by · you can be followed by anyone, but you must actively choose to follow, and attention is finite
  3. 3
    So the two strategies have fundamentally different worst cases: fanout-on-write has an unbounded worst case, fanout-on-read has a bounded one.
    forced by · the distributions on each side of the edge are structurally different
  4. 4
    Therefore route by which side is expensive: push for accounts with few followers (cheap write, and it makes reads free), pull for accounts with many (avoids the unbounded write).
    forced by · each strategy is applied precisely where its cost is small
  5. 5
    The read-time merge is affordable because the number of high-follower accounts any single user follows is small — typically a handful. So a read fetches one precomputed list plus a few recent-post queries and merges them.
    forced by · celebrities are rare by definition, so the pull side of the merge stays tiny per reader
⇒ Therefore

Therefore the hybrid bounds both costs by exploiting the asymmetry between the follower and followee distributions. Neither pure strategy can do this because each has one unbounded side.

And note what this predicts: the correct threshold for classifying an account is not a fixed follower count but the point where its fanout write cost exceeds the aggregate read cost it would impose — which depends on how active its followers are. A million-follower account whose followers rarely open the app should be pull; a hundred-thousand-follower account with highly engaged followers may be cheaper to push. The threshold is an economic calculation, not a constant, and it should be measured rather than guessed.

Mental modelPush, pull, or both — decided per edge

Every follow edge is an independent choice about when to do the work: at write time (materialise into the follower's feed) or at read time (query the poster's recent posts). Push makes reads free and writes expensive; pull does the reverse.

The insight is that this is decided per edge, not per system. Look at each edge and ask which end is the expensive one — then do the work at the cheap end.

  • Feed storage holds post IDs plus ranking metadata, never post content. Content is fetched separately and cached — otherwise an edit means rewriting millions of feed entries.
  • Cap materialised feeds at a few hundred entries. Nobody scrolls further, and unbounded feeds are unbounded storage across every user.
  • Fanout is asynchronous and queue-driven. The write API returns as soon as the post is durable; delivery is eventual and that is acceptable to users.
  • Ranking is a separate stage from retrieval: gather candidates cheaply, then score. Conflating them makes both harder to change.
🔔 Fires when you see

Fire this model the moment you see: any fanout or subscription system · a heavy-tailed distribution being summarised by its mean · notification delivery design · a write path whose cost depends on someone else's follower count · a feed that lags for everyone whenever a popular account posts.

The tradeoff

How is the timeline assembled — fanout-on-write, fanout-on-read, or hybrid?

Fanout-on-write (push)
+ you gain reads become a single sequential lookup of a precomputed list, giving the lowest and most predictable read latency — which is what users actually perceive; and it moves work to write time, which is asynchronous and can absorb bursts
− you pay write amplification proportional to follower count, so the tail is unbounded; storage duplicated per follower; and a follow/unfollow or a deleted post requires touching many materialised feeds
pick when the follower distribution is bounded and the read:write ratio is high — internal tools, team feeds, ordinary accounts
Fanout-on-read (pull)
+ you gain no write amplification at all, no duplicated storage, and follows take effect instantly because nothing was materialised; deletions and edits are trivially correct
− you pay every read fans out across all followees, so read latency scales with how many accounts a user follows and is hard to keep predictable; caching is far less effective because each user's query is unique
pick when write-heavy workloads, users following very few accounts, or any case where freshness beats read latency
Hybrid
+ you gain bounds both costs by routing each edge to the cheaper strategy — the only approach that survives a heavy-tailed follower distribution
− you pay two code paths, a merge step at read time, and a classification threshold that must be tuned and can flap for accounts near the boundary; noticeably more complex to reason about and to debug
pick when follower counts span orders of magnitude, which is true of every public social product
What a senior engineer actually does

Start with pure fanout-on-write because it is simpler and correct for the overwhelming majority of accounts, then add the pull path for accounts above a measured threshold. Building the hybrid on day one is premature; not having a plan for it is negligent — a single viral account will find the limit for you.

The transferable idea is that heavy-tailed distributions break designs justified by averages. Whenever a cost depends on a count you do not control, ask what happens at the 99.99th percentile, not the mean — that is where systems actually fail, and it is where the interesting engineering is.


(c) Hands-on · 25 min

Build a minimal newsfeed with hybrid fanout + simple ranking. Uses Redis for timeline + post storage.

#!/usr/bin/env python3
# newsfeed.py — hybrid fanout + toy ranker.
# pip install redis
import math
import random
import time
from typing import Iterable
 
import redis
 
r = redis.Redis(decode_responses=True)
r.flushdb()
 
# ---------- graph setup ----------
USERS = ["alice", "bob", "carol", "dave", "eve", "bieber"]
FOLLOWS = {
    "alice":  ["bob", "carol", "bieber"],
    "bob":    ["alice", "dave", "bieber"],
    "carol":  ["alice", "dave", "eve"],
    "dave":   ["eve", "bieber"],
    "eve":    ["alice", "bob", "carol", "dave", "bieber"],
    "bieber": [],
}
CELEBS = {"bieber"}                # would be follower_count > 1M in prod
TIMELINE_CAP = 500
 
for u, followees in FOLLOWS.items():
    for f in followees:
        r.sadd(f"followers:{f}", u)
 
# ---------- posting ----------
def post(user: str, text: str) -> int:
    post_id = int(time.time() * 1000_000) + random.randint(0, 999)
    r.hset(f"post:{post_id}", mapping={"user": user, "text": text, "ts": post_id})
    r.zadd(f"user_posts:{user}", {post_id: post_id})
 
    if user in CELEBS:
        # PULL path — no fanout on write; readers will pull from user_posts.
        print(f"[post·celeb] {user}: '{text}' (no fanout)")
        return post_id
 
    # PUSH path — fanout to normal followers' timelines.
    followers = r.smembers(f"followers:{user}")
    pipe = r.pipeline()
    for f in followers:
        pipe.zadd(f"timeline:{f}", {post_id: post_id})
        pipe.zremrangebyrank(f"timeline:{f}", 0, -(TIMELINE_CAP + 1))
    pipe.execute()
    print(f"[post·normal] {user}: '{text}' → fanned out to {len(followers)}")
    return post_id
 
# ---------- feed read ----------
def _fetch_posts(post_ids: Iterable[str]) -> list[dict]:
    if not post_ids:
        return []
    pipe = r.pipeline()
    for pid in post_ids:
        pipe.hgetall(f"post:{pid}")
    return [{**h, "id": pid} for pid, h in zip(post_ids, pipe.execute()) if h]
 
def _score(post: dict, viewer_followees: set[str]) -> float:
    now_us = time.time() * 1000_000
    age_min = max(1e-3, (now_us - int(post["ts"])) / 60_000_000)
    recency = 1.0 / (1 + math.log1p(age_min))
    affinity = 2.0 if post["user"] in viewer_followees else 0.5
    novelty = 1.2 if post["user"] in CELEBS else 1.0
    return recency * affinity * novelty
 
def feed(user: str, limit: int = 10) -> list[dict]:
    viewer_followees = set(FOLLOWS.get(user, []))
 
    # 1. PUSH candidates from precomputed timeline
    pushed_ids = r.zrevrange(f"timeline:{user}", 0, 99)
 
    # 2. PULL candidates from celebrities the user follows
    pulled_ids: list[str] = []
    for celeb in viewer_followees & CELEBS:
        pulled_ids.extend(r.zrevrange(f"user_posts:{celeb}", 0, 20))
 
    candidate_ids = list(dict.fromkeys(pushed_ids + pulled_ids))  # dedup, preserve order
    candidates = _fetch_posts(candidate_ids)
 
    # 3. RANK
    scored = sorted(candidates, key=lambda p: _score(p, viewer_followees), reverse=True)
 
    # 4. RE-RANK — diversity: no more than 2 in a row from same author
    picked: list[dict] = []
    last_author, run = None, 0
    for p in scored:
        if p["user"] == last_author and run >= 2:
            continue
        picked.append(p)
        run = run + 1 if p["user"] == last_author else 1
        last_author = p["user"]
        if len(picked) >= limit:
            break
    return picked
 
# ---------- simulate ----------
post("alice", "Just landed in Tokyo!")
post("bob", "New blog post: designing systems")
post("bieber", "hit the studio tonight")     # celeb — no fanout
post("carol", "AI is eating the world")
post("bieber", "new drop friday")            # celeb — no fanout
post("dave", "trying new sushi place")
 
print("\n--- Eve's feed ---")
for p in feed("eve"):
    print(f"[{p['user']}] {p['text']}")

What each block is doing

Anatomy of the newsfeed

CELEBS set + branched post()
The whole hybrid trick in five lines: if the poster is a celeb, skip fanout entirely; otherwise fanout to normal followers.
delivery
ZREMRANGEBYRANK to TIMELINE_CAP
Cap timeline size or Redis memory grows unbounded. Real systems keep the last 500–800 per user.
cost control
_fetch_posts uses a pipeline
One round-trip fetches N posts. Naive per-key HGETALL is 100× slower.
latency
_score = recency × affinity × novelty
The toy ranker. Real systems: DLRM / two-tower / transformer, but the shape of the inputs is the same.
ranking
Diversity re-rank loop
'No more than 2 in a row from the same author.' Simple constraint; huge UX win.
re-rank
user_posts:{user} sorted set
Per-user append log for the pull path. Also useful for profile-page timelines.
pull path
Try itFeel the celebrity problem

Temporarily add 100 000 synthetic followers to Bieber (loop: r.sadd('followers:bieber', f'user_{i}')). Remove Bieber from CELEBS so he takes the push path. Time a single post('bieber', 'hi') with time.perf_counter(). Then put him back in CELEBS and re-time. Then measure feed('eve') under both configs. Numbers will make the hybrid answer feel obvious.

💡 Hint · Give Bieber 100 000 hypothetical followers and re-enable fanout for celebs. Time the post() call. Then switch back to pull. Then hybrid — see the numbers.

(d) Production reality · 15 min

War story Instagram · 2019 (industry-common failure)15% engagement drop for 48 h
🔥 What broke

A model update to Instagram Explore's recommender caused the ranker to over-index on video content and starve image posts of impressions overnight. Creator complaints exploded; engagement dropped ~15% for 48 hours.

Root cause: offline eval used click-through as the sole metric, but production feedback loops weren't caught — fewer image impressions → less data on images → model prefers video even more.

🧯 The fix
Added causal-impact eval (holdout populations that stay on the old model) so real-world drift is visible before full rollout. Added explicit diversity guardrails at re-rank time (max ratio per media type). Public write-up on Meta engineering blog documents the funnel change.
🎓 Lesson to steal
Offline metrics lie about live behaviour. Every recommender change needs a holdout + causal comparison in production before ramp. Diversity constraints must live in the re-rank layer, not the model — they're a business decision, not a learnable one.
War story Twitter · 2013celebrity write amplification
🔥 What broke
Pure fanout-on-write worked until a top-100 celebrity account tweeted. Writing 20–50 million cache entries in one request meant seconds-long post latency and hot-shard hell in the cache layer.
🧯 The fix
Moved to the hybrid model documented above. Celebrity accounts skip fanout entirely; their followers pull celeb posts at read time and merge with their push timeline. The threshold (~1M followers) is empirical.
🎓 Lesson to steal
Follower-count distribution is heavy-tailed. Any design must handle the top 0.01% of accounts differently, or they'll take down the system. This applies to every follow-graph product — Twitter, Instagram, TikTok, YouTube.
War story TikTok-class systems · common patternreal-time feature staleness
🔥 What broke
Feature staleness: user click features updated once a day, but posts changed every minute. Ranker used stale user features and mis-ranked fresh content for users whose interests shifted that day.
🧯 The fix

Two-tier feature store:

user-side features streaming (Kafka Redis, seconds-fresh)item-side features batch (Spark Parquet, daily)model reads both at inference time

TikTok's real advantage over Instagram is how tight this loop is: your dwell time on video N informs video N+1's score in seconds.

🎓 Lesson to steal
Real-time features on the volatile side (usually user), batch on the stable side (usually item). Getting this split right is worth more than most model architecture changes.

Common failure modes

Where this shows up in the rest of the plan

Newsfeed sits at the intersection of systems + ML + product
S126 · System Design Framework
The four-step method you just used at scale.
S127 · URL Shortener
Stateless read-heavy; contrast with the stateful feed cache.
S128 · Chat System
Similar inbox-pull vs push trade-off; different SLO shape.
S130 · AI Chat Product
Capstone — feed thinking transfers to conversation-history ranking.
S091 · Recommender Systems
The ML background for the ranker.
S122 · LLM Evaluation
The causal-impact eval pattern applies to recommender rollouts too.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

  1. Why does the celebrity problem force a hybrid fanout?
  2. Describe the two-stage candidate → rank funnel in one breath.
  3. What is a feedback loop and how do you fight it in production?

What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.