Backend
Designing a News Feed
The classic 'design Facebook's feed' interview question — fan-out-on-write vs. fan-out-on-read, the hybrid systems actually run, and why ranking is a separate problem from assembly.
"Design a news feed" shows up in system design interviews so often it's become a genre of its own — and for good reason. Strip away the product-specific dressing (Facebook's wall, Twitter's timeline, Instagram's home tab) and the core problem is the same everywhere: for every user, on every app open, assemble a personalized, roughly-ranked list of content pulled from everyone and everything they follow — and do it fast enough that it feels instant, at a scale where "everyone they follow" can mean anywhere from a dozen people to tens of millions.
That single sentence hides two genuinely different sub-problems that are worth separating from the start: assembly (which posts even end up in the feed) and ranking (what order they appear in). Most of the interesting engineering — and most of the interview signal — lives in assembly, so that's where we start.
The core challenge
A user follows some number of other users, pages, or topics. Each of those sources produces posts independently and asynchronously. The feed has to merge all of it into one ordered list, on demand, in well under a second. The naive approach — "when the user opens the app, go fetch the last N posts from everyone they follow, then merge and sort" — is correct but slow: a user following a thousand accounts means a thousand lookups on every single feed load. The entire design space here is really just different answers to when that merge work happens: ahead of time, at request time, or some blend of the two.
Fan-out-on-write: precompute the feed
The first approach flips the work to happen at write time instead of read time. When a user publishes a post, the system immediately pushes that post into the precomputed feed of every one of their followers — typically by writing the post's ID into a per-user feed list stored in something fast, like Redis. By the time any follower opens the app, their feed already exists as a ready-to-serve list; loading it is a single cheap lookup.
The trade-off is squarely on the write side. Reads become essentially free, but a single post from an account with millions of followers triggers millions of individual writes — a write storm that has to fan out across the entire follower graph before it's done. For a typical user with a few hundred followers this is trivial. For a celebrity account, it can mean the system is still fanning out a post from ten minutes ago when the next one arrives.
Fan-out-on-read: assemble on demand
The second approach does none of that precomputation. A post is simply saved once, in one place. When a user opens the app, the system queries every source they follow, pulls their recent posts, and merges the results into a feed right there, on the spot. Writes are now as cheap as they can possibly be — one write, always, regardless of how many followers the author has.
The cost moves entirely to reads. Every feed load now has to fan out queries across however many accounts the user follows, then merge and rank the results before anything can be shown. For a user who follows a lot of accounts, that's a lot of scatter- gather work to redo on every single app open — the exact cost the first approach was designed to avoid, just shifted to the other side of the request.
The hybrid most real systems land on
Neither pure strategy survives contact with a real social graph, because the graph is heavily skewed: the overwhelming majority of accounts have a modest follower count, while a tiny number of celebrity accounts have an enormous one. So production systems typically run both strategies at once, split by account size.
For ordinary users, fan-out-on-write is used: their posts go to a normal number of followers, so precomputing every follower's feed is cheap and keeps reads fast. For celebrity or high-follower-count accounts specifically, the system falls back to a fan-out-on-read path — that post is not pushed to millions of feeds. Instead it's fetched and merged in at read time, alongside the follower's already-precomputed feed of everyone else they follow.
Best of both, not a compromise between them
This isn't a middle ground where every account gets a mediocre version of each approach — it's applying the right strategy to the right shape of account. Normal accounts get cheap reads via precomputation; celebrity accounts avoid the write storm by staying out of precomputation entirely. A user's final feed is the merge of both: their precomputed list, plus a small, targeted read-time fetch for the handful of celebrities they follow.
A sketch of the write path
The branching logic on the write side is the crux of the hybrid design — everything else follows from this one decision made at post time:
def on_new_post(author_id, post):
post_id = feed_store.save_post(post)
follower_ids = social_graph.get_followers(author_id)
if len(follower_ids) > CELEBRITY_THRESHOLD:
# Skip the write storm entirely — celebrity posts are
# merged in at read time instead (see below).
celebrity_posts.mark(author_id, post_id)
return
# Fan-out-on-write: push the post_id into every follower's
# precomputed feed right now, while it's cheap to do so.
for follower_id in follower_ids:
feed_store.prepend(follower_id, post_id)Where the threshold sits (ten thousand followers? a hundred thousand?) is a tuning knob, not a fundamental constant — it's set wherever the write amplification of fan-out-on-write starts to outweigh the read amplification of fan-out-on-read for that platform's traffic pattern.
Ranking is a different problem, layered on top
It's tempting to treat "assemble the feed" and "order the feed" as one problem, but they're not. Assembly answers which posts are even candidates for a user's feed; ranking answers what order those candidates appear in. A feed can be assembled by pure fan-out-on-write and still be ordered by strict recency, or it can be assembled the same way and ordered by a machine-learned relevance model that weighs recency, predicted engagement, and relationship strength. Swapping the ranking model never requires touching the assembly strategy, and swapping the assembly strategy never requires touching the ranking model — they compose.
This separation is also why "chronological feed" toggles exist in most products: it's not a different backend, just a different (and much simpler) ranking function running over the same assembled candidate set.
Caching the assembled feed
Even with fan-out-on-write doing most of the heavy lifting, the final assembled-and-ranked feed is worth caching as its own object, keyed by user. A user reopening the app thirty seconds after their last visit shouldn't force a full re-merge of their precomputed list with fresh celebrity posts and a re-run of the ranking model — that work should happen once, get cached, and be invalidated or incrementally updated as new posts arrive, not redone from scratch on every request.
Cache the output, not just the inputs
It's easy to focus caching effort on the raw data (posts, follower lists) and forget the assembled feed itself is an expensive derived artifact worth caching too. The rule of thumb: the more work — merging, ranking, fetching celebrity content — it takes to produce the final list, the more that final list itself deserves its own cache entry.
Key takeaways
- The core problem is assembling a personalized, ordered feed from every source a user follows, fast enough to feel instant.
- Fan-out-on-write precomputes each follower's feed at post time — cheap reads, but a write storm for high-follower accounts.
- Fan-out-on-read assembles the feed at request time from scratch — cheap, single writes, but expensive reads.
- Real systems hybridize: fan-out-on-write for typical accounts, fan-out-on-read for celebrity accounts, merged together per user.
- Ranking (what order posts appear in) is a separate concern from assembly (which posts qualify) — the two compose independently.
- Cache the fully assembled feed itself, not just the underlying posts, so reopening the app doesn't force a full reassembly.
