A news feed is the screen most people open first: an endless scroll of posts from friends, family, Pages and groups they follow, ordered by what the app predicts they will care about, with like and comment counts under each one. It is also one of the most instructive problems in system design, because three hard things meet in it. Every post must reach hundreds of readers — or a hundred million, if a film star or a cricket board posted it. Every app open must pick the best twenty posts out of about a thousand candidates in a few hundred milliseconds. And the list keeps changing under the reader’s thumb while they scroll. Here you design a Facebook-style feed for a billion daily users: how posts are delivered (fan-out), where feeds live, how they are ranked, and how they are paged, filtered and filled in with counts.
Understand the problem
A feed has two halves that scale differently. Getting each post to the right readers is a write problem sized by follower counts. Choosing what one reader sees right now is a read and compute problem sized by how many candidates you score. Strong answers name both halves early; weak ones design a single “feed table” and discover the celebrity problem twenty minutes in.
Functional requirements
- Users should be able to create posts — text, a photo or a link — for an audience they choose, such as public or friends only.
- Users should be able to follow people and Pages, or become friends (a two-way follow), and unfollow them.
- Users should be able to open a personalised, ranked feed of posts from the people, Pages and groups they follow, and keep scrolling for more.
- Users should be able to like and comment on posts and see like and comment counts on every post in their feed.
Non-functional requirements
- Fast feed loads. The server returns the first page in under 250 ms at p50 and 600 ms at p99, and later pages in under 150 ms at p99.
- Scale. 2 billion monthly and 1 billion daily active users; about 460k feed pages a second and 35k new posts a second at peak (derived below). Follower counts are extremely skewed: most people have a few hundred, a few thousand accounts have tens of millions.
- Freshness. A friend’s post shows up in followers’ feeds within 10 seconds at p99. Authors see their own post immediately (read-your-own-writes).
- Availability over consistency. Feed reads target 99.95%. If ranking or a store is struggling, serve a less personalised or slightly stale feed rather than an error. Feeds and counts are eventually consistent.
- Durability. An acknowledged post, follow or like is never lost. Feed lists are derived data and may be rebuilt.
- Privacy is strict. A viewer must never see a post they are not allowed to see — not even briefly after the author changes the audience or blocks them. This is the one place where “eventually” is not good enough.
In the interview
Ask four questions before you draw: “Is the feed ranked or chronological?”, “Are relationships two-way friendships with a cap, or one-way follows with no limit?” (one-way follows create celebrities), “How fresh must it be — seconds or minutes?” and “Does the feed include recommended posts from accounts I don’t follow?” The answers decide whether you need fan-out tricks, a ranking pipeline and a candidate source beyond the follow graph.
Out of scope
- Recommended content from accounts you don’t follow. That is a recommendation problem with its own candidate generation; see design a recommendation feed and design TikTok. We leave a slot for it in ranking.
- Ads. The ad auction is its own system; the last ranking pass reserves positions for it.
- Media processing and delivery. Upload, transcoding and CDN delivery are covered in design Instagram and design YouTube.
- Stories, short video and live video. Different products with different freshness rules.
- Search and notifications — see design post search and design a notification system.
- Moderation classifiers. We consume their verdicts as filters and ranking signals.
The set-up
Plan the approach
Meet each functional requirement with the simplest thing that works — which, for the feed, is “look up who I follow and fetch their recent posts”. That design is correct but collapses at scale, and the deep dives are about why and what to do instead: how a post reaches followers, the celebrity case, where feeds are stored, ranking, paging, deletes and privacy, and filling a page with posts and counts. Say early that the feed stores references (post IDs), never copies of posts; almost every later decision depends on it.
Core entities
- User. Profile, name, photo, privacy settings, last-active time.
- Post.
post_id, author, creation time, text, media IDs, link preview, audience (public, friends, custom), state (live, edited, deleted). - Follow edge. Follower, followee, time. A friendship is a pair of follow edges. Stored twice: once under the follower (“who do I follow?”) and once under the followee (“who follows me?”).
- Feed entry. A reader’s pointer to a post:
post_id,author_id, timestamp. Derived, rebuildable, never the source of truth. - Like and comment. A like is an edge from user to post; a comment is an object with its own ID. Each post has like and comment counts.
A sketch of the durable tables:
-- Source of truth for posts, sharded by post_id
CREATE TABLE posts (
post_id BIGINT PRIMARY KEY, -- time-sortable 64-bit ID
author_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL,
body TEXT,
media_ids BIGINT[],
audience SMALLINT NOT NULL, -- 0 public, 1 friends, 2 custom list
state SMALLINT NOT NULL -- 0 live, 1 deleted
);
-- Follow graph, sharded by the first column; each follow is written twice
CREATE TABLE edges (
src_id BIGINT, edge_type SMALLINT, dst_id BIGINT, created_at TIMESTAMP,
PRIMARY KEY (src_id, edge_type, created_at DESC, dst_id)
);
Post IDs should be time-sortable 64-bit integers so that “newer than” is a plain comparison; how to generate them without coordination is covered in design a unique ID generator.
API
Creating a post. Photos are uploaded first, straight to blob storage through a pre-signed URL; the post refers to them by ID.
POST /v1/posts
Authorization: Bearer <token>
Content-Type: application/json
Idempotency-Key: 6f1c2a90-77d4-4d1b-9a0e-3e2f5b8c1d44
{
"text": "Finally, Kerala backwaters!",
"media_ids": [88213409123],
"audience": "friends"
}
{ "post_id": "7421938465112064", "created_at": "2026-10-27T09:12:44Z" }
Following and unfollowing are idempotent puts and deletes on an edge:
PUT /v1/me/following/{user_or_page_id}
DELETE /v1/me/following/{user_or_page_id}
Reading the feed. The cursor is opaque to the client; the server decides what is inside it.
GET /v1/feed?limit=20&cursor=eyJzIjoiZnMtOWEyYiIsInAiOjIwfQ
{
"items": [
{
"post_id": "7421938465112064",
"author": { "id": "5512", "name": "Asha Menon", "photo": "https://cdn.example.com/a/5512_96.jpg" },
"text": "Finally, Kerala backwaters!",
"media": [{ "url": "https://cdn.example.com/p/88213409123_1080.jpg", "w": 1080, "h": 810 }],
"created_at": "2026-10-27T09:12:44Z",
"like_count": 214,
"comment_count": 18,
"viewer_has_liked": false
}
],
"next_cursor": "eyJzIjoiZnMtOWEyYiIsInAiOjQwfQ",
"new_items_hint": 3
}
Likes and comments:
PUT /v1/posts/{post_id}/likes/me → 204, idempotent
DELETE /v1/posts/{post_id}/likes/me → 204
POST /v1/posts/{post_id}/comments → 201 { "comment_id": "…" }
Using PUT on likes/me makes a double tap or a retried request harmless: the edge either exists or it doesn’t. See idempotency.
Back-of-envelope estimates
These numbers decide which half of the problem dominates. (Method: back-of-envelope estimation.)
Users. 2B monthly, 1B daily active. Assume 1.2B have opened the app in the last 7 days; that matters for fan-out.
Posts. Half a post per daily user, plus as many again from Pages and groups: 1B posts a day. 1B ÷ 86,400 ≈ 11.6k posts/s. Posting spikes hard around events — a festival midnight, the last over of an IPL final — so plan for 3×: ≈ 35k posts/s at peak.
Post storage. About 1 KB of text and metadata per post: 1B × 1 KB = 1 TB a day, roughly 365 TB a year before replication. Photos are far bigger — 300M photos at about 300 KB is 90 TB a day — but they live in blob storage behind a CDN.
Feed reads. Each daily user opens the app 10 times and loads 2 pages per session: 1B × 10 × 2 = 20B pages a day. 20B ÷ 86,400 ≈ 231k pages/s, and about 460k/s at peak. Rankings happen once per session, not once per page: 1B × 10 = 10B a day ≈ 116k rankings/s, about 230k/s at peak.
Fan-out on write. For the 99.9% of posts written by ordinary accounts, assume about 300 followers, of whom two-thirds have a live feed list (active in the last week): about 200 deliveries per post. 1B × 200 = 200B list inserts a day; 200B ÷ 86,400 ≈ 2.3M inserts/s, about 7M/s at peak. That is a 200× write amplification — the number that shapes the whole design.
Feed list memory. Keep the newest 500 entries per reader at 24 bytes each (post ID, author ID, timestamp, flags): 500 × 24 B = 12 KB per reader. 1.2B × 12 KB = 14.4 TB; with about 1.5× data-structure overhead, ≈ 22 TB, and 44 TB with one replica. At about 180 GB usable per node that is 22 TB ÷ 180 GB ≈ 120 primaries plus 120 replicas. Peak inserts per primary: 7M ÷ 120 ≈ 58k/s, comfortable for an in-memory store with pipelining.
Ranking compute. Each ranking starts from about 1,000 candidates and runs the heavy model on 500. At peak: 230k × 500 = 115M heavy-model scorings a second. This is why cheap passes exist to protect the expensive one.
Hydration. A page shows 20 posts, each needing about four objects — post, author, counts, “have I liked it?”: 460k × 20 × 4 = ≈ 37M object reads/s at peak. At a 96% cache hit rate, about 1.5M reads/s still reach the databases.
Tip
Two numbers carry this problem: write amplification (deliveries per post, set by follower counts) and candidates per ranking (set by how many accounts a reader follows). Every deep dive below trades one of them against latency or memory.
High-level design
Build one requirement at a time. By the end of this section the design works for a modest user base; the deep dives make it survive a billion.
Users should be able to create posts
The client first uploads any photo directly to blob storage with a pre-signed URL, then calls POST /v1/posts with the text and media IDs. The post service validates the request, assigns a time-sortable ID, writes one row to the post store, and publishes a post.created event. It does not touch anyone’s feed. Whatever builds feeds will consume that event — which keeps posting fast no matter how many followers the author has.
The Idempotency-Key header means a retry on a flaky connection never creates the same post twice. The audience lives only on the post row and is checked whenever the post is shown — it is never copied into feeds.
Users should be able to follow people and Pages, or become friends
A follow is an edge in the social graph, owned by a small graph service. Store each follow twice: once on the shard that owns the follower (“Alice follows Bob”) and once, as an inverse edge, on the shard that owns the followee (“Bob is followed by Alice”). A friendship is just two follows. With both directions stored, “whom does Alice follow?” — asked on every feed read — and “who follows Bob?” — asked on every fan-out — are each one range scan on one shard.
Keep a follower count next to each list rather than counting on demand — TAO, the store behind Facebook’s social graph, keeps association counts in a separate table for exactly this reason and exposes them as an assoc_count call. The fan-out logic will use that count to decide how to treat an author.
Users should be able to open a personalised, ranked feed
The simplest feed is computed when it is read. The feed service asks the graph service for the reader’s 300 followees, fetches each one’s recent posts from the post store, merges them, sorts by time or a simple score, and returns the top 20.
This is fan-out on read: nothing is precomputed, so the feed is always fresh and posting is cheap. The bill arrives on every read: 231k pages a second × 300 authors ≈ 69M lookups a second, plus a merge and sort of thousands of posts each time, before any ranking. Hold that thought — it is the first deep dive.
Users should be able to like and comment on posts and see counts
A like is an edge from a user to a post, stored with a unique key on (post, user), so liking twice is a no-op. A separate count per post holds the number of likes and comments; the engagement service bumps it after writing the edge. When the feed service returns a page, it fetches the counts for those 20 posts in one batch, plus the reader’s own like edges so it can draw the heart filled or empty.
Comments work the same way: a comment object, an edge from the post to the comment, and a comment count. The feed only needs the count and perhaps the newest comment; the full thread loads when the reader taps in.
You now have a working feed: correct, fresh and fine for a few thousand users. At a billion it has four problems: reads cost 300 lookups each, nothing is ranked, scrolling a list that changes underneath you shows duplicates, and one post can collect thousands of likes a second on a single row. The deep dives fix them, starting with how posts reach feeds.
Deep dives
How should a new post reach its followers’ feeds?
There are two pure strategies and a family of hybrids between them. Fan-out on write (push) copies a reference to the post into each follower’s feed when it is created. Fan-out on read (pull) gathers posts from each followee when the feed is opened. One pays per follower, the other per followee.
Bad: query the post store on every read. This is the high-level design. Posting costs one row, but at peak each of 460k page views a second fans out to 300 authors — 138M lookups a second, every page as slow as the slowest of its 300. A database sharded by post ID was never built to answer “newest posts by these 300 authors” at that rate.
Good: push every post to every follower’s list. A fan-out worker pages through the author’s followers and appends the post ID to each follower’s list; a feed open becomes one memory read. You pay at write time: about 200 inserts per post (2.3M a second, 7M at peak), about 22 TB of memory, and wasted work for readers who never look before an entry is trimmed. And it breaks at the top of the follower distribution — the next deep dive.
Great: decide per author–reader pair. The Feeding Frenzy paper from Yahoo! Research (SIGMOD 2010) gives the cleanest argument. Let pushing one event into one feed cost H, and pulling a producer’s recent events cost L. A pushed event costs H once. A pulled event costs a share of L every time the consumer looks while the event is still among the newest, so its lifetime cost is L × (consumer’s view rate ÷ producer’s event rate). Push when that ratio is at least H/L; pull otherwise. The surprising result is that making this decision locally, pair by pair, minimises the global cost, and the decision only changes when rates change.
The paper’s experiments on Yahoo!’s PNUTS store back this up. With a high event rate, pushing everything caused 47% more system load than pulling everything; with a high query rate, pulling everything caused 23% more than pushing everything; the hybrid beat both in every scenario, and a push threshold of about 3 minimised load across the workloads they tried. They also note that Facebook reportedly treated very wide-fanout accounts such as bands and politicians differently from everyone else.
Turn that into three rules you can say in an interview:
- Push to readers whose list is live. Someone active this week looks far more often than a typical friend posts.
LPUSHXonly pushes into lists that exist, so dormant readers cost nothing. - Let dormant readers pull. Their list is rebuilt once, from authors’ outboxes, when they return.
- Pull from hot producers — huge audiences or very high posting rates. That is the celebrity case, next.
| Push (fan-out on write) | Pull (fan-out on read) | Hybrid (per pair) | |
|---|---|---|---|
| Work per post | ≈ 200 inserts; 100M for a celebrity | 1 append to the author’s timeline | Inserts for active readers of ordinary authors; 1 append for hot ones |
| Work per feed open | 1 list read | ≈ 300 timeline reads and a merge | 1 list read plus ≈ 10 cached outbox reads |
| Extra memory | ≈ 22 TB of IDs | None beyond author timelines | About the same as push |
| Freshness | Seconds behind (queue lag) | Instant | Instant for hot authors, seconds for others |
| A 100M-follower post | Minutes of backlog for everyone | One very hot read key | One hot key, served from replicated cache |
| Changing the ranking model | Free if lists hold unranked IDs | Free | Free |
| What you build | A fan-out pipeline | A fast scatter-gather index by author | Both, plus the policy |
Pull is not a dead end, though; it needs the right index. Facebook’s feed backend, Multifeed, pulls: an aggregator looks up your friends, fetches their recent actions from leaf servers holding an in-memory index of recent activity (about 20 leaves make one full replica), and ranks the result. Meta’s 2015 write-up describes putting aggregators (CPU-heavy) and leaves (memory-heavy) on separate server types, cutting RAM by 75–80% and aggregator latency by 10%. LinkedIn’s FollowFeed also chose pull, for two stated reasons: pushing made their data 62 times larger, and feeds stored in ranked order must be rewritten whenever a ranking model changes. Our hybrid avoids the second by keeping lists unranked and ranking at read time; it accepts the first, because 22 TB of memory buys a feed open that starts from one read.
In the interview
Say the rule, not just the names: “Push costs once per follower, pull costs once per view. I push to active readers of ordinary authors and pull from hot authors and for dormant readers, because the cheaper side depends on the reader’s view rate compared with the author’s post rate.”
How do we stop one celebrity post from swamping the system?
Averages hide the tail. A film star or a national cricket board can have 100M followers. Pushing one of their posts means 100M list inserts — at the fleet’s average of 2.3M inserts a second, that is 43 seconds of the entire fan-out capacity for a single post. Now let that account post 20 times during an IPL final evening, while everyone else is also posting at the 35k/s peak. The fan-out queue backs up, ordinary friends’ posts wait behind the celebrity’s, and the 10-second freshness target is gone for everyone.
The tail also dominates the totals. Suppose 10,000 accounts have more than a million followers, averaging 5M, and each posts 10 times a day: 10,000 × 10 × 5M = 500B inserts a day — 2.5 times the 200B generated by every other post combined.
Bad: push anyway, with more workers. You can buy the throughput, but each celebrity post still takes tens of seconds to land, and the bill grows with the most popular account rather than with your user count.
Good: separate lanes and active-only delivery. Split big fan-outs into chunks of, say, 10,000 followers, and run them in a separate low-priority queue so they cannot block ordinary posts. Push only to followers with a live list. Ordinary freshness is protected, but a 100M-follower post still costs tens of millions of writes and arrives over a minute or more.
Great: make hot producers pull-only. Mark an account as hot when it has more than about a million followers, or posts more than about 50 times a day. A hot account’s post is written only to its outbox — a cached list of its newest 100 post IDs — and to nobody’s feed. Each reader’s graph data includes the short list of hot accounts they follow, typically around ten. Opening the feed means reading one pushed list plus about ten outboxes, then merging.
Moving the problem to the read side creates a new one: a hot read key. Millions of readers a second now read outbox:film_star. Two fixes, both described in the TAO paper for Facebook’s own hot objects:
- Replicate the hot key. Serve popular shards from several cache servers instead of one (TAO calls it shard cloning), so reads spread out.
- Cache it in the caller. When an object’s access rate passes a threshold, TAO clients keep a local copy with its version number, and the cache server can reply “unchanged” without resending the data. A feed service that keeps hot outboxes in its own memory for a second or two turns millions of reads into one refresh per outbox per server.
Three details make the switch safe. Use hysteresis: go pull-only above 1M followers but return to push only below 800k, so an account near the line does not flap. Dedupe on merge, because posts pushed before an account turned hot are still in some lists. And always write the author’s own post into their own list and outbox synchronously, so they see it at once, whatever their mode.
Watch out
Don’t pick hot accounts by follower count alone. A news Page with 200k followers that posts 300 times a day generates 200k × 300 = 60M inserts a day — more than a film star with 10M followers who posts twice (20M). Posting rate matters as much as audience size, exactly as the Feeding Frenzy rule predicts.
Where do feeds live, and what happens on a miss?
Each active reader needs a list of the newest few hundred candidate post IDs, readable in about a millisecond and appendable 7M times a second at peak.
Bad: a feed table in the main relational database. One row per (reader, post), with an index on reader and time. Reads are fine, but 2.3M indexed row inserts a second, plus the deletes needed to stop the table growing by 200B rows a day, dwarf every other write in the product — all paying for durability that derived data does not need.
Good: a wide-column store partitioned by reader. Use a Cassandra- or DynamoDB-style table with the reader as partition key, time descending as clustering key, and a TTL on every row. Writes are cheap appends to a log-structured store, a read is one partition scan of the newest 500 rows, and the data survives restarts. You pay a few milliseconds per read from SSD, and expired or trimmed rows leave tombstones that compaction has to clean up.
Great: capped lists in memory, rebuilt on a miss. Keep each live reader’s feed as a list in an in-memory store such as Redis, sharded by reader ID so a feed is one read on one shard. Each entry is a packed 24-byte reference, never the post itself.
Three commands do all the work. LPUSHX adds the new ID at the head, but only if the list already exists — so fan-out silently skips readers whose list has expired. LTRIM 0 499 keeps the newest 500; the Redis documentation points out that trimming right after each push removes about one element at a time, so it is effectively constant-time. EXPIRE gives the key a 7-day idle lifetime, refreshed whenever the reader opens the app, so memory tracks active users only: about 22 TB on 120 primaries, as estimated. The author ID rides along in each entry so the merge and filter stages can apply per-author rules (spacing, unfollows, blocks) without loading posts. The same cluster holds every author’s outbox — their newest 100 post IDs — which the hot-producer pull and the rebuild path both read.
The miss path. A reader who returns after three weeks has no list. The feed service then runs the pull path once: fetch the 300 followees, read each one’s outbox in parallel batches, keep posts from the last few days, merge, write the result back as a fresh list with a 7-day expiry, and carry on ranking. That adds roughly 50–100 ms to one feed open, and afterwards fan-out keeps the list current. Guard it with a single-flight lock per reader (a short-lived key), so a phone and a laptop opening at the same moment don’t rebuild twice.
Derived does not mean disposable. If a shard holding 10M readers dies without a replica and 20% of them open the app in the next ten minutes, that is 2M rebuilds × 300 outbox reads = 600M reads in a burst — a self-inflicted cache stampede. Keep one replica per shard so a failure is a few seconds of promotion, not a rebuild storm, and rate-limit rebuilds so the rest of the system degrades gracefully if it happens anyway.
How do we rank a thousand candidates in about 200 milliseconds?
Meta’s engineering write-up on feed ranking gives the scale of the problem: more than 1,000 candidate posts per user per day on average, for more than 2 billion people, and without ranking, feeds get flooded with promotional content or with acquaintances who post a lot, burying posts from the people you are closest to. Our merge step hands ranking about 1,000 candidates per feed open.
Bad: reverse-chronological only. Cheap, explainable and fully cacheable. Facebook still offers it as an option — its Feeds tab sorts newest first — but as the default it rewards whoever posts most, and your cousin’s wedding photos sink under fifty posts from a meme Page.
Good: one strong model scores everything. Run a large model on all 1,000 candidates. Quality is good, but at peak that is 230k × 1,000 = 230M heavy scorings a second, and the slowest of a thousand predictions sets your latency.
Great: a funnel of increasingly expensive passes. This is the structure Meta describes for its feed:
- Inventory. Every non-deleted post from friends, groups and Pages since the last visit — plus unread bumping (posts ranked for you last session that you never saw) and action bumping (posts you saw that have since drawn a lively conversation among your friends).
- Integrity and eligibility filters. Drop posts already seen (unless bumped), from blocked or unfollowed authors, or flagged by integrity systems; apply demotions.
- Pass 0. A lightweight model picks about 500 posts with high recall, so later passes can afford heavier models.
- Pass 1. The main scoring pass. Multitask neural networks predict the probability of several actions for each post — like, comment, share, hide — and the predictions are combined into one value, a weighted sum. Meta describes setting the weights from what people say is meaningful to them in surveys. Scoring runs in parallel on many predictor machines.
- Pass 2. A contextual pass over the ordered list: content-type diversity (no five videos in a row), spacing between posts from the same author, and slots for ads or recommended posts.
In code, the pass 1 combination is a dot product; the weights below are illustrative, not anyone’s real values:
# One value per candidate from several predicted probabilities.
WEIGHTS = {"like": 1.0, "comment": 4.0, "share": 6.0, "hide": -20.0}
def value(predictions: dict[str, float]) -> float:
return sum(w * predictions.get(action, 0.0) for action, w in WEIGHTS.items())
A linear combination has a useful property Meta points out: an action a person almost never takes has a probability near zero, so it barely influences their ranking automatically.
Features are the hidden cost. Much of pass 1’s 120 ms is fetching features. Meta’s transparency pages list the kinds of signals: your history with this author and with similar posts, the post’s type and media, how others have engaged with it so far, the author’s record, and integrity signals. Some must be fresh — a post’s likes in its first ten minutes, or the cricket posts you liked a minute ago. Keep them in a low-latency feature store, fetch them in one batch per request, and fetch viewer- and author-level features once rather than 500 times.
Deadlines and fallbacks. Give every stage a deadline. If pass 1 is late, return the pass 0 order; if ranking is down, return the merged list newest first. A slightly worse feed is always better than a spinner — that is the availability-over-consistency choice from the requirements, applied to ranking.
Rank once per session, not once per page. The first request of a session ranks and stores the ordered 500 IDs as a snapshot; later pages are slices of it. That is why rankings run at 116k a second while pages run at 231k, and why scrolling pages can return in under 150 ms. It also makes experiments cheap: because stored lists are unranked, a new model version is a traffic split, with no stored feed to rewrite.
How do we page through a feed that keeps changing?
While Asha scrolls, her friends keep posting. If the server cannot say exactly where she is, she sees the same posts twice or misses some.
Bad: offset and limit. “Give me items 20 to 39” only works if the list holds still. Three new posts at the top shift everything down three places, so page 2 repeats the last three posts of page 1; a deletion shifts things up and silently skips one. In a ranked feed it is worse: if each page re-ranks, the whole order can reshuffle between requests.
Good: a keyset cursor. For a chronological feed, the cursor is the (timestamp, post ID) of the last item shown, and the next page is “strictly older than that”. Inserts at the top no longer matter. See pagination: offset vs cursor and offset vs keyset pagination.
Great: a cursor into a ranked snapshot. A ranked feed has no natural sort key, so make one. The first request of a session stores the ranked 500 IDs under a feed-session ID in the cache with a 30-minute expiry — 500 × 8 B = 4 KB. The cursor is an opaque, signed token holding the session ID and a position; page n is a slice, hydrated on demand. Even if every one of the 230k sessions a second started at peak lived the full 30 minutes, that is 230k × 1,800 s × 4 KB ≈ 1.7 TB, a fraction of the feed lists. When the snapshot runs out or expires, rank again, excluding what the reader has already seen.
Three rules finish the job:
- Never insert above the reader. New posts wait. The client asks a cheap
new-countendpoint every minute or so (or gets a hint over an existing push channel — see real-time updates) and shows a “New posts” pill. Tapping it, or pulling to refresh, starts a new session. - Track what was actually seen. The client reports impressions in batches — post IDs that were on screen for about a second. A per-reader seen set for the last few days feeds the filter stage, and unseen posts from the last session become eligible again, which is the unread bumping described above.
- Re-check at hydration. A snapshot is a list of IDs from up to 30 minutes ago. Each page is hydrated and permission-checked when requested, never served from a cached rendering.
How do deletes, privacy changes and unfollows take effect?
Changes come in two shapes. Post-side changes — a delete, an edit, an audience narrowed from public to friends — affect every reader who might have the post. Reader-side changes — an unfollow, a mute, a block — affect one reader’s view of one author.
Bad: retract from every feed list. Treat a delete as a reverse fan-out and remove the ID from 200 lists. It costs as much as the post did, it races with the original fan-out (a lagging partition can insert the post after the delete removed it), and a privacy change would need you to work out which readers just lost access.
Good: filter at read time. Hydration loads every post object anyway, so check it there: is it still live, does its current audience include this viewer, has the author blocked the viewer or been suspended? Drop failures and fill the page from the next candidates — hydrate 24 to show 20. This is correct by construction, because the check uses the current post object, not a copy. The TAO paper explains why Facebook works this way: content is so customised per viewer that aggregation and privacy checks are done each time content is viewed, not when it is created.
Great: filter at read time for correctness, and clean up only where it is cheap.
| Change | Who is affected | Retract from lists | Filter at read time | Do this |
|---|---|---|---|---|
| Post deleted | Every reader who has it | ≈ 200 removals; races with inserts | Post state checked on hydration | Filter; drop dead IDs lazily |
| Audience narrowed | Readers who lost access | Must compute who lost access | Current audience checked per viewer | Filter |
| Post edited | Every reader | Nothing — lists hold IDs | New version hydrated | Nothing to do |
| Reader unfollows | One reader | One list of ≤ 500 entries | Follow check per candidate | Remove from that list, plus a 24-hour filter |
| Reader blocks author | Both people | Two lists | Block check per candidate | Remove both ways and filter permanently |
Reader-side changes are cheap to retract because they touch one list: drop that author’s entries from the reader’s 500, using the author ID stored in each entry. Then keep the author in a short-lived “recently unfollowed” set, because fan-out messages already in the queue may still insert a post after the cleanup. Post-side changes are never retracted; the next read filters them, and a background task strips dead IDs from a list whenever it is next read.
Watch out
Read-time filtering is only as fresh as the post object it reads. A delete or audience change must update or invalidate every cached copy of the post — and the ranked snapshot must never be trusted for permissions. Cache the post, never the decision “this viewer may see this post”.
How do we fill a page with posts, authors and counts quickly?
Ranking returns 20 post IDs. The page needs, for each one, the post, its author’s name and photo, like and comment counts, and whether this viewer has liked it — about 80 objects, at a peak of 37M object reads a second.
Bad: one query per object. Fetching each post, then its author, then its counts, one after another, is the N+1 query problem: 80 sequential round trips of about a millisecond each eat a third of the latency budget before any work is done.
Good: batched multiget from a cache. Group the 80 keys by cache shard and send one request per shard in parallel: one round trip. A key-value lookaside cache gets you most of the way, but the TAO paper lists where it hurts for social data: an edge list is fetched and invalidated as a whole, cache-filling logic runs in thousands of uncoordinated clients that invite thundering herds, and read-after-write is awkward with asynchronous database replicas.
Great: a graph-aware cache. Facebook replaced lookaside caching for the social graph with TAO, and its model fits a feed perfectly. Posts, users and comments are objects; likes, follows and authorship are associations (typed edges with a time). A tiny API covers hydration: get the objects by ID, assoc_get for “has this viewer liked this post?”, assoc_count for the like count, and assoc_range for the newest comments.
The published numbers explain why this shape works. TAO’s workload is 99.8% reads, it sustains a billion reads and millions of writes a second, and its read hit rate was 96.4% in the paper’s measurements. Followers in each region answer reads; one leader per shard serialises writes, shields the database from thundering herds, and sends refill or invalidation messages to the followers. The writer’s own follower tier is updated synchronously, so Asha sees her like at once; other regions see it after asynchronous replication — under one second 85% of the time, under three seconds 99% of the time. At our 37M reads a second and about 96% hits, roughly 1.5M reads a second reach MySQL.
Counters for likes. A post from a cricketer after a century can collect 20,000 likes a second.
- Bad:
UPDATE posts SET likes = likes + 1. Every like locks the same row. At about a millisecond per locked update, that row tops out near 1,000 a second; the rest queue, time out and slow down every reader of the post. - Good: an edge plus an asynchronously aggregated count. Insert the like edge synchronously — a unique key on (post, user) makes double taps harmless — and emit “+1” only if the insert created a new edge. A stream partitioned by post ID collects the increments; a consumer sums them per post every second and applies one “+N”. 20,000 likes a second become one counter write a second, kept in its own table as TAO does with association counts. The count lags by about a second, which nobody can see in “20K”.
- Great: sharded counters for the very hottest posts, plus reconciliation. Split a hot post’s counter into 16 sub-counters, add to a random one, and sum all 16 (cached) on read. A periodic job recounts edges for recently active posts to repair drift from lost or replayed increments. The viewer’s own heart comes from the edge, not the count, so it is always exact.
The final design
Put the pieces together. Writes are cheap and asynchronous; reads start from one list, pull a few hot outboxes, rank once per session and hydrate from a graph-aware cache.
Follow a post, then a feed open, through the system:
- Post. The client uploads any photo to blob storage, then calls
POST /v1/posts. The post service assigns a time-sortable ID. - Store once. The post and an “authored” edge go to the graph store, and the ID goes onto the author’s own list and outbox so they see it at once. The client is acknowledged here.
- Announce. The post service publishes
post.created, partitioned by author ID so one author’s posts are processed in order. - Fan out. Workers check the author’s mode. For an ordinary author they page through followers from the social graph and pipeline
LPUSHXandLTRIMinto each live list, shard by shard, in chunks of 10,000 followers. For a hot author they do nothing more — the outbox already has it. - Gather. When Asha opens the app, the feed service reads her list, the outboxes of the ten or so hot accounts she follows, and her seen set from the feed cache, and merges about 1,000 candidates. A missing list triggers a one-time rebuild.
- Rank. The ranking service filters, runs pass 0, pass 1 and pass 2 within their deadlines, and the feed service stores the ranked 500 IDs as a session snapshot behind an opaque cursor.
- Hydrate and check. For each page, the feed service multigets posts, authors, counts and Asha’s own like edges from the graph store’s follower tier, drops anything deleted or no longer visible to her, and returns 20 items.
- Engage. A like writes an edge through the engagement service; the count catches up within a second through aggregated increments.
- Media. Photos load straight from the CDN, never through the feed service.
Check it against the requirements. The first page costs one list read, about ten cached outbox reads, a 180 ms ranking funnel and one hydration round trip — about 230 ms, inside the 250 ms p50 target — and later pages skip ranking. Ordinary posts reach active followers in seconds through a queue that celebrity posts never clog; hot accounts appear instantly. Feed lists are derived and replicated, so losing one means a rebuild, not data loss, while posts, edges and likes live durably in the graph store. Every page is permission-checked against the current post, so privacy stays strict while everything else is eventually consistent.
What interviewers look for by level
| Area | Mid-level | Senior | Staff |
|---|---|---|---|
| Requirements | Lists post, follow and view feed | Separates the write and read halves; sets latency, freshness and privacy targets | Asks about follower skew and ranking first, because they decide the architecture |
| Fan-out | Picks push or pull and describes it | Compares both with numbers; proposes a hybrid for celebrities | Frames it as a per-pair cost decision (view rate versus post rate) and knows real systems chose differently and why |
| Hot keys | Not discussed | Pull for celebrities | Hot outboxes as read hot keys: replication, in-process caching, hysteresis, dedupe |
| Feed storage | “A feed table” | Capped in-memory lists of IDs with TTL | Rebuild on miss, single-flight, why derived data still needs replicas, memory sizing |
| Ranking | Sort by time | A model scores candidates | Multi-pass funnel, feature freshness, deadlines and fallbacks, rank once per session |
| Paging and freshness | Page numbers | Cursors | Ranked snapshots, “new posts” pill, seen tracking and unread bumping |
| Correctness | Deletes the post row | Filters deleted posts at read time | Privacy re-checked at hydration, cheap targeted retraction, counters that never lose the viewer’s own like |
Follow-up questions
What if users want a chronological feed?
Offer it as a mode, as Facebook does with its Feeds tab. The candidate lists are already time-ordered, so it is the merge step with ranking skipped: merge the pushed list and hot outboxes by timestamp, filter, hydrate, and page with a keyset cursor on (timestamp, post ID) — no snapshot needed. It is cheaper to serve; the cost is relevance for people who follow very active accounts.
What if we add recommended posts from accounts the user doesn’t follow?
Add a second candidate source, not a second feed. A recommendation service retrieves a few hundred posts by embedding similarity or by what similar users engaged with; they join the inventory with a “recommended” flag, go through the same filters and passes, and pass 2 caps how many appear per page. The follow graph stops being the only way into the feed — the step towards TikTok’s recommendation-first design.
What if a whole region goes down?
Route users to a healthy region. Posts, edges and likes replicate asynchronously between regions, so it already has nearly all of them — TAO measured replica lag under a second most of the time. Feed lists are regional caches: fan-out runs in every region from the replicated post stream, and anything missing is rebuilt on demand, rate-limited. The last second or so of unreplicated writes is the price of never waiting on another region; see multi-region and active-active.
What if one reader follows 5,000 Pages?
This is the celebrity problem in reverse: a reader with a huge inbound rate. Their 500-entry list covers only a few hours, and pulling 5,000 outboxes per open is expensive. Push only from the authors they actually engage with, cap entries per author, and sample the rest at read time. The Feeding Frenzy analysis predicts this: for a “newest k overall” feed, the pull cost depends on the combined posting rate of everyone you follow, so heavy followers tip towards pull.
What if a post is edited after it went out?
Nothing in the feed changes. Lists, outboxes and snapshots hold IDs, so the next hydration loads the new version once its cache entry is refreshed. Only content-derived data — the search index, text features, link previews — needs updating, from the same change event.
How to say it in the interview
“I’ll design a Facebook-style ranked feed for about a billion daily users: around 460 thousand feed pages a second at peak, a first page in about 250 milliseconds, and strict privacy.
Posts are written once to a graph store and announced on a queue. For delivery I use a hybrid. For ordinary authors, fan-out workers push the post ID into a capped list of 500 IDs per follower in an in-memory store — but only for followers whose list is live, so dormant users cost nothing and are rebuilt by pulling when they return. That is about 200 inserts per post, 2.3 million a second, and about 22 terabytes of lists. Accounts with millions of followers or very high posting rates are pull-only: their posts sit in a cached outbox and each reader merges about ten outboxes at read time. The rule underneath is push when the reader looks often relative to how often the author posts.
Ranking is a funnel: about a thousand candidates, filters, a light model to keep 500, a heavy multitask model that predicts likes, comments and shares and combines them into one value, then a diversity and ads pass. That takes about 180 milliseconds. I rank once per session and store the ordered IDs as a snapshot, so scrolling is a cursor into it with no repeats, and new posts wait behind a pill at the top.
Each page is hydrated with a batched multiget from a TAO-style two-tier cache with a hit rate around 96 percent, and every post is permission-checked at that moment, so deletes and privacy changes take effect without retracting anything from feeds. Likes are edges, and counts are aggregated per second so a viral post doesn’t melt a row.”
Concepts used in this design
- Push vs pull architecture — fan-out on write versus fan-out on read, and when to mix them.
- Redis — capped lists,
LPUSHXandLTRIM, expiry, and sharding feed lists by reader. - Hot keys, cache stampede and invalidation — hot outboxes and rebuild storms.
- Caching strategies — lookaside versus a write-through, graph-aware cache.
- Pagination: offset vs cursor — why offsets break on a live feed.
- The N+1 query problem — why hydration is one batched multiget.
- Kafka — the partitioned
post.createdstream and the like-increment stream. - Data modelling and denormalisation — storing references in feeds, and counts beside edges.
- Database sharding — sharding posts by ID and edges by source user.
- Strong vs eventual consistency — eventual feeds and counts, strict privacy checks.
- Scaling reads — precomputation, caching and replication on a read-heavy path.
- Scaling writes — aggregating like increments and spreading hot counters.
References and further reading
- Feeding Frenzy: Selectively Materializing Users’ Event Feeds (Silberstein, Terrace, Cooper and Ramakrishnan, SIGMOD 2010)jeffterrace.comThe push-versus-pull cost model, the proof that per producer–consumer decisions minimise global cost, and the measured gains of a hybrid over push-all and pull-all.
- TAO: Facebook’s Distributed Data Store for the Social Graph (Bronson et al., USENIX ATC 2013)usenix.orgObjects and associations, assoc_count and assoc_range, leader and follower cache tiers, 99.8% reads, 96.4% hit rate, shard cloning and client caching for hot objects, privacy checks at view time.
- Serving Facebook Multifeed: Efficiency, performance gains through redesign (Meta Engineering, 2015)engineering.fb.comHow the feed backend pulls recent actions from in-memory leaves through aggregators, and what splitting the two onto separate hardware saved.
- How machine learning powers Facebook’s News Feed ranking algorithm (Meta Engineering, 2021)engineering.fb.comInventory with unread and comment bumping, more than 1,000 candidate posts per user per day, pass 0 to about 500 posts, multitask pass 1 and contextual pass 2.
- Our approach to Facebook Feed ranking (Meta Transparency Center)transparency.meta.comThe signal categories used to rank connected content, user controls, and the reverse-chronological Feeds tab.
- FollowFeed: LinkedIn’s Feed Made Faster and Smarter (LinkedIn Engineering, 2016)linkedin.comWhy LinkedIn chose fan-out on read: 62 times more data with push, and re-materialisation every time a ranking model changes.
- Redis LTRIM commandredis.ioThe LPUSH plus LTRIM capped-list pattern, and why trimming one element per push is effectively O(1).