Design a news feed
Design a news feed like Facebook, Instagram or X. Start with pull, let the numbers break it, then grow the design step by step through push, big accounts, ranking, paging, deletes and failures.
“Design the home feed for a social network. Users follow other users and see their posts.”
Everyone has used a news feed, so it looks easy. And it is easy, until you count the users.
A feed answers one question: “show me the newest posts from the people I follow.” Answering it once is cheap. Answering it when 500 million people ask ten times a day is not. So the whole problem comes down to one choice. Do we do the work when a post is written, or when a feed is read? We start with the simplest answer, and let the numbers show where it breaks. Then the design grows, one step at a time, until it survives very large accounts, ranking, scrolling, deletes and failures.
The answer in one minute
Section titled “The answer in one minute”If you only have a minute, this is the design. The rest of the page explains each part.
- When someone posts, we copy the post’s ID into a short list for each of their followers. This list is called a timeline, and it lives in a cache. Opening the feed then means reading one timeline and fetching 20 posts.
- Some accounts have millions of followers. Copying one of their posts 50 million times would hold up everyone else’s posts. So we don’t copy those. We fetch their newest posts when a follower opens the feed, and mix them in.
- We don’t keep timelines for people who haven’t opened the app in a month. If they come back, we build their timeline once.
- A ranker puts the best posts first. It gets a strict time limit. If it is slow, we show the posts newest first instead, so the feed still loads.
- Deleted posts, blocks and privacy changes are checked every time a feed is read. Old copies of a post’s ID may still sit in many timelines, but the check hides the post anyway.
Scope it
Section titled “Scope it”Before drawing anything, we need to know what we’re building. A few questions change the design a lot. For the rest, we can assume an answer and move on.
Design a news feed. Users follow other users and see their posts on a home screen.
Whether you ask how many followers the biggest account has. That one answer breaks the simple design.
These four questions matter most:
- Do people follow, or are they mutual friends? They follow, like on Instagram or X. So one account can have 100 million followers. If friendships were mutual, like on Facebook, there would be a cap (Facebook’s is 5,000). Then there would be no huge accounts, and the hardest part of this problem would go away.
- How many people, and how often? 500 million people a day, each opening the feed about ten times. At 10 million people, a much simpler design would be enough.
- Best posts first, or newest first? Best first by default, with newest first as an option.
- When must a delete or a block take effect? On the very next feed load. This sounds like a small detail, but it changes how we read the feed.
From these answers we make three promises. The rest of the design exists to keep them:
- The feed always loads, in under 300 ms for 99% of requests. A feed that is a little out of date, or not ranked, still counts as loaded.
- A new post reaches followers within 5 seconds. Usually it takes about 1 second.
- Deleted, blocked and private posts never show, starting from the next load.
We leave out ads, recommended posts, comments, likes, notifications and search.
Go deeperThe full requirements, and five more questions2 min
These questions matter less, but each answer still moves the design a little. Here is what we assume, and what would change if the answer were different.
| Question | We assume | If the answer were different |
|---|---|---|
| How fresh? | 99% of followers within 5 seconds | “Within a minute” lets fan-out wait and send posts in bigger groups |
| What content? | Text, photos, short video | Text only removes the CDN and blob storage, the biggest costs |
| Largest accounts? | Over 100 million followers | If nobody has more than about 100,000, copying to everyone works |
| Global users? | About a third in each of three regions | One region is simpler, with no delay copying data between regions |
| A cap on following? | 7,500 | Without a cap, building one person’s feed has no upper limit |
The numbers behind the three promises:
- Availability. Loading the feed works 99.99% of the time. Posting works 99.95% of the time. If a post fails, the app keeps it as a draft and tries again.
- Speed. The first page takes under 300 ms on the server for 99% of loads, and under 100 ms for half of them. That includes ranking. Posting takes under 500 ms for 99% of posts.
- Durability. When the app gets “post saved”, the post is already on 3 machines. Timelines are only a cache, so we can rebuild them.
- Consistency. Authors see their own post at once. Followers see it within the 5-second promise. Deletes, blocks and privacy changes apply from the next load.
The numbers that decide it
Section titled “The numbers that decide it”Now we know what to build. Next we ask how big it is. Two numbers matter most: how often people read the feed, and how often they post.
- Daily active users
- 500 million
- Feed loads per user a day
- 10
- Posts per user a day
- 0.2
- Followers per user, average
- 200
- Followers active this month
- 60%
- Follows of big (pulled) accounts
- 30%
- Peak over average
- 3×
- Timeline entries kept per user
- 500
- Years to plan for
- 5 years
500 million people open the feed ten times a day. That is 5 billion loads a day, or about 58,000 a second. In the evening it is three times higher.
So we reading a feed must cost only a few cache lookups, not hundreds.
Each person posts 0.2 times a day, so 100 million posts a day. For every post, the feed is read 50 times.
So we storing posts is not the hard part. What each post sets off is.
These two numbers set the shape of the problem. People read far more than they write: 50 feed loads for every post. Keep that ratio in mind. It decides the design in the first step below.
Build the design
Section titled “Build the design”We’ll start with the simplest feed that gives the right answer. Then we let the numbers break it. Each step fixes only what broke, adds only the boxes that fix needs, and says what the fix costs. The drawing grows with each step.
Step 1. The simplest feed: build it when it is opened
Section titled “Step 1. The simplest feed: build it when it is opened”The simplest idea is to do nothing when someone posts, and all the work when someone reads. This is called pull. Saving a post is one write. Opening the feed works like this:
- The feed service asks the graph service whom you follow. That’s about 200 accounts.
- It reads the newest posts of each of those accounts from the post cache.
- It merges them by time and returns the newest 20.
Stage 1: Pull: build the feed when it is opened
Stage 1 of 5
Pull: build the feed when it is opened
Why this stage
The simplest design that is correct.
Each post is saved once, and nothing is copied anywhere.
What we add
A feed service asks the graph service whom you follow, reads the newest posts of each of those accounts from the post cache, merges them by time, and returns 20.
What it costs
About 200 lookups for every feed load: 35 million a second at peak.
Every load does the same work again, even when nothing changed.
At 170k feed loads per second, Post cache is 567% busy. Stage 2 is the fix.
The numbers behind the busy bars
How busy it gets
- For every 50 feed loads, someone publishes 1 new post. Reading is the heavy side.
What one machine can do
- A feed server builds about 5,000 feeds a second when the timeline is ready. If it has to pull about 200 lists first (stage 1), it manages only about 500.
- One Redis node answers about 200,000 lookups a second with multi-gets.
- One fan-out worker copies about 20,000 posts into timelines a second.
- One ranking server ranks about 500 feeds a second, scoring 500 posts each.
How big the fleet is, per region
- The post cache needs 30 nodes. Only 2% of reads miss it and go to the post store, which has 16 shards.
- The timeline cache needs 300 nodes of 100 GB, about 30 TB of memory at about 30 bytes an entry.
Why push costs less than it looks
- A post is copied to about 84 timelines, not 200. About 40% of followers are inactive and skipped, and 30% of follows point at big accounts, which are pulled instead.
This is correct, and a new post shows up at once. Before we test it against the numbers, here is where the data lives.
Where the data lives
Section titled “Where the data lives”Three kinds of data grow at very different speeds. Over five years, posts grow to about 180 TB. The list of who follows whom grows to about 10 TB. Photos and videos grow to 37 PB. Data that grows this differently shouldn’t share one database, so each kind gets its own owner:
- The post service owns posts. It spreads them across many machines by post ID, and keeps a post cache in front of them.
- The graph service owns who follows whom. It stores each follow twice: once under “X’s followers” and once under “who Y follows”. The feed needs to ask both questions, and each one is then a single quick read.
- Photos and videos go straight from the phone to blob storage, and a CDN serves them. A post stores only the file’s key.
Post IDs are 64-bit numbers that start with the time (Snowflake-style IDs). So a bigger ID always means a newer post, and sorting by ID is the same as sorting by time. Paging relies on this later.
Where pull breaks
Section titled “Where pull breaks”Pull looks cheap per load. The problem is how often it runs.
Pull reads the recent posts of about 200 followed accounts on every load, so 35 million lookups a second at peak. Push copies each new post into each follower’s timeline ahead of time. That is 100 million posts × 200 followers, or 20 billion copies a day, about 690,000 a second at peak. Opening the feed is then one lookup.
So we push wins: 690,000 small writes are cheaper than 35 million reads.
So push is about 50 times cheaper on average. Remember those two words. Averages hide a problem, and step 3 is about it.
You can change the numbers in the calculator. If people open the feed only once a day, pull becomes cheap and we don’t need fan-out at all. If each person has 1,000 followers, push needs over 2 million copies a second.
- Feed loads, average
- 58,000/s
- Feed loads, peak
- 170,000/s
- Stateless feed servers scale out; the question is what each load costs.
- Posts, average
- 1,200/s
- Posts, peak
- 3,500/s
- Pull only: author lists read at peak
- 35,000,000/s
- Tens of millions of lookups a second just to assemble feeds: push to timelines instead.
- Push to everyone: inserts at peak
- 690,000/s
- One timeline cache cluster absorbs this; bursts from big accounts are the real limit.
- Hybrid: inserts at peak
- 290,000/s
- Skipping inactive followers and pulled accounts removes over half the work.
- Timeline cache RAM (30 day actives = 2 × DAU)
- 15 TB
- About 30 bytes an entry in a compact sorted set. Doubled for replicas, this is the biggest cache bill.
- Post store (text and metadata, 1 KB a post)
- 180 TB
- Hundreds of TB before replication: shard by post ID from day one.
- Media (200 KB a post, averaged)
- 37 PB
- Tens of petabytes: blob storage and a CDN, never the post store.
- Feed JSON egress, peak
- 3.5 GB/s
- Twenty 1 KB posts a page. Compressed, under 1 GB/s; images come from the CDN.
Go deeperThe full API2 min
The app needs only two calls. One saves a post, and one reads a page of the feed:
POST /v1/posts Idempotency-Key: 0b7c... { "text": "...", "mediaIds": ["m_9f3a"] } → 201 { "postId" }GET /v1/feed?limit=20&cursor=... → 200 { "items", "nextCursor" }The idempotency key is a unique ID the app sends with each post. Phones often send a request again after a timeout. Without the key, a repeated post would be saved twice and copied to every follower twice. With it, the server sees a key it already has, and returns the post it already saved.
Photos are uploaded first, straight to blob storage. The post then refers to them by ID. This keeps large files away from the post service.
POST /v1/media/uploads { "contentType": "image/jpeg", "bytes": 1840221 }201 { "mediaId": "m_9f3a...", "uploadUrl": "https://blob.../presigned?sig=..." }
POST /v1/postsIdempotency-Key: 0b7c1e52-...{ "text": "Sunset at the pier", "mediaIds": ["m_9f3a..."], "audience": "followers" }201 { "postId": "1849275503923314688" }
GET /v1/feed/new-count?since={postId} { "count": 12 }: asked every 30 to 60 s for the "new posts" buttonDELETE /v1/posts/{postId} author only; 204, gone from feeds on the next loadPOST /v1/users/{id}/follow 201, or 202 "requested" for a private accountDELETE /v1/users/{id}/follow 204POST /v1/users/{id}/block 204; also removes follows in both directionsThe server answers 400 for invalid content, 429 when someone posts too often, and 409 if a
photo is still being processed. A page holds at most 50 posts.
Why does the “new posts” button ask every 30 seconds, instead of the server telling the app? Because telling the app would mean keeping hundreds of millions of connections open. A small count that a cache can answer is far cheaper. A live feed that needs instant updates would use the chat problem’s connection layer instead.
Go deeperThe data model, and which stores to use2 min
First, list how the data is read and written, and how often. The stores follow from this table.
| What we do | How often | What the store must do well |
|---|---|---|
| Read my timeline | 58,000/s, 170,000/s peak | Read one list by user ID |
| Fetch posts by ID | 20 per load, 3.4M/s peak | Fetch many posts at once; the popular ones fit in RAM |
| An author’s recent posts | Big accounts, and rebuilds | Read one author’s posts, newest first |
| Followers of X | Once per post, by fan-out | Read followers 1,000 at a time |
| Create a post, follow | 3,500/s and 1,000/s peak | Insert; a follow writes both directions |
-- Posts, spread across machines by post_id. The one true copy of each post.posts (post_id BIGINT PRIMARY KEY, author_id BIGINT, text TEXT, media LIST<media_ref>, -- keys of files in blob storage, never the files audience TEXT, -- public | followers | close_friends created_at TIMESTAMP, edited_at TIMESTAMP, deleted_at TIMESTAMP) -- the delete mark: any read hides the post at once
-- One author's posts, newest first. Split by month so no single list grows forever.posts_by_author (author_id BIGINT, month INT, post_id BIGINT, PRIMARY KEY ((author_id, month), post_id)) WITH CLUSTERING ORDER BY (post_id DESC)
-- Follows, in sharded MySQL. One follow writes both rows.followers (user_id BIGINT, follower_id BIGINT, PRIMARY KEY (user_id, follower_id))following (user_id BIGINT, followee_id BIGINT, PRIMARY KEY (user_id, followee_id))user_counts (user_id BIGINT PRIMARY KEY, followers BIGINT, following BIGINT, mode TEXT) -- push | pullTimeline cache (Redis, spread across servers by user ID) timeline:{user_id} sorted set, at most 500 entries, about 30 bytes each member = post ID and author ID, packed into 16 bytes score = the time inside the post ID active:{user_id} when the user was last seen; fan-out skips anyone away for 30 daysEach timeline entry keeps the author’s ID next to the post ID. That looks like a small detail. It lets us hide posts from blocked or unfollowed people without fetching the posts first.
| Data | Store | Why it fits | What it costs |
|---|---|---|---|
| Posts | Cassandra | Handles many writes; easy to spread by ID; runs in regions | No transactions; weak lookups by other fields |
| Posts | MySQL | Familiar; works when sharded behind a cache, as at Facebook | You build and run the sharding yourself |
| Graph | Sharded MySQL + cache | A follow is a row; easy to keep exact counts | Two writes per follow, fixed up if one fails |
| Graph | Graph database | Questions several steps deep | A feed asks one step, over billions of follows |
Step 2. Copy posts ahead of time
Section titled “Step 2. Copy posts ahead of time”Pull does the same work on every read, and reads are 50 times more common than posts. So we move the work to the rarer side. When someone posts, we copy the post’s ID into a short list for each follower. This list is called a timeline. This is called push, or fan-out on write. Three new parts appear: a queue, the workers that do the copying, and a cache that holds the timelines.
Stage 2: Push: copy posts ahead of time
Stage 2 of 5
Push: copy posts ahead of time
Why this stage
Feeds are read 50 times more often than posts are written.
Pull repeats the same work on every read, at 35 million lookups a second.
What we add
The post service sends a Kafka event for each post.
Fan-out workers ask the graph service for the author’s followers and add the post ID to each follower’s timeline in a timeline cache.
Opening the feed reads one timeline.
What it costs
About 690,000 timeline inserts a second at peak and 30 TB of memory.
And a post from an account with 80 million followers is 80 million inserts in one burst.
At 170k feed loads per second, every part has headroom. Raise the traffic until something turns red.
The numbers behind the busy bars
How busy it gets
- For every 50 feed loads, someone publishes 1 new post. Reading is the heavy side.
What one machine can do
- A feed server builds about 5,000 feeds a second when the timeline is ready. If it has to pull about 200 lists first (stage 1), it manages only about 500.
- One Redis node answers about 200,000 lookups a second with multi-gets.
- One fan-out worker copies about 20,000 posts into timelines a second.
- One ranking server ranks about 500 feeds a second, scoring 500 posts each.
How big the fleet is, per region
- The post cache needs 30 nodes. Only 2% of reads miss it and go to the post store, which has 16 shards.
- The timeline cache needs 300 nodes of 100 GB, about 30 TB of memory at about 30 bytes an entry.
Why push costs less than it looks
- A post is copied to about 84 timelines, not 200. About 40% of followers are inactive and skipped, and 30% of follows point at big accounts, which are pulled instead.
Writing a post
Section titled “Writing a post”Here is what happens when someone posts:
- The post service saves the post on 3 machines. Then it sends a
PostCreatedevent to Kafka, a queue that keeps events in order. Events from the same author stay in order, so a post’s “delete” can never arrive before its “create”. The author gets their reply (201) right away. Nobody waits for the copying. - Fan-out workers read the event. They ask the graph service for the author’s followers, 1,000 at a time.
- For each follower, they add the post ID and the author ID to that follower’s timeline. A timeline is a sorted list in Redis, spread across many cache servers. It keeps only the newest 500 entries.
We copy IDs, not whole posts. The post itself is stored only once. So an edit is one write, and a delete is one write. If we copied whole posts, one edit would mean changing millions of copies.
- Color key
- Cache
- Service
- Background worker
- Queue or stream
- Database
// Published by the post service once the post is durable. Kafka keys it by author ID, so one// author's events stay in order and one consumer handles them.record PostCreated(long postId, long authorId) {}record TimelineEntry(long postId, long authorId) {} // the author ID lets readers filter without a lookuprecord FollowerPage(List<Long> ids, String next) {} // next is null on the last page
enum AuthorMode { PUSH, PULL }
interface Authors { AuthorMode mode(long authorId); }interface Graph { FollowerPage followers(long authorId, String cursor, int limit); }interface Activity { List<Long> activeSince(List<Long> userIds, Instant cutoff); }interface Timelines { // One pipelined batch per shard: ZADD timeline:{id} <ms> <postId+authorId, 16 bytes>, // then ZREMRANGEBYRANK to keep the newest maxLength. The score is the millisecond timestamp, // not the raw 64 bit ID: Redis scores are doubles and lose precision above 2^53. void addAll(List<Long> userIds, TimelineEntry entry, int maxLength);}
// Runs for every PostCreated event. Kafka delivers at least once, so this can run twice for// the same post; every write it makes must be safe to repeat.class FanoutWorker { static final int PAGE_SIZE = 1_000; // followers per batch: one pipelined round trip static final int TIMELINE_LENGTH = 500; // bounded: memory per user is fixed, about 15 KB static final Duration ACTIVE_WINDOW = Duration.ofDays(30);
private final Authors authors; private final Graph graph; private final Activity activity; private final Timelines timelines; private final Clock clock; // injected so tests control "inactive"
FanoutWorker(Authors authors, Graph graph, Activity activity, Timelines timelines, Clock clock) { this.authors = authors; this.graph = graph; this.activity = activity; this.timelines = timelines; this.clock = clock; }
void handle(PostCreated e) { // Big accounts are never pushed: one post would be tens of millions of inserts and hold // the fleet for minutes. Followers merge their posts in at read time. The mode is a flag // set by a background job with hysteresis, so an account near 1M does not flap. if (authors.mode(e.authorId()) == AuthorMode.PULL) return;
Instant cutoff = clock.instant().minus(ACTIVE_WINDOW); TimelineEntry entry = new TimelineEntry(e.postId(), e.authorId()); String cursor = null; do { // Page through followers so a 900,000 follower account never loads one giant list. // Production turns each page into its own task so many workers share one big post. FollowerPage page = graph.followers(e.authorId(), cursor, PAGE_SIZE); // About 40% of followers have not opened the app in 30 days. Writing to them is // wasted RAM and work; their timeline is rebuilt by pull if they come back. List<Long> targets = activity.activeSince(page.ids(), cutoff); // ZADD is idempotent (same member, same score), so a redelivered event changes nothing. timelines.addAll(targets, entry, TIMELINE_LENGTH); cursor = page.next(); } while (cursor != null); }}# Published by the post service once the post is durable. Kafka keys it by author ID,# so one author's events stay in order and one consumer handles them.@dataclass(frozen=True)class PostCreated: post_id: int author_id: int
@dataclass(frozen=True)class TimelineEntry: post_id: int author_id: int # lets readers filter unfollows and blocks without a lookup
@dataclass(frozen=True)class FollowerPage: ids: list[int] next: str | None # None on the last page
class AuthorMode(Enum): PUSH = "push" PULL = "pull"
class Authors(Protocol): def mode(self, author_id: int) -> AuthorMode: ...
class Graph(Protocol): def followers(self, author_id: int, cursor: str | None, limit: int) -> FollowerPage: ...
class Activity(Protocol): def active_since(self, user_ids: list[int], cutoff: datetime) -> list[int]: ...
class Timelines(Protocol): # One pipelined batch per shard: ZADD timeline:{id} <ms> <post_id+author_id, 16 bytes>, # then ZREMRANGEBYRANK to keep the newest max_length. The score is the millisecond timestamp, # not the raw 64 bit ID: Redis scores are doubles and lose precision above 2^53. def add_all(self, user_ids: list[int], entry: TimelineEntry, max_length: int) -> None: ...
# Runs for every PostCreated event. Kafka delivers at least once, so this can run twice# for the same post; every write it makes must be safe to repeat.class FanoutWorker: PAGE_SIZE = 1_000 # followers per batch: one pipelined round trip TIMELINE_LENGTH = 500 # bounded: memory per user is fixed, about 15 KB ACTIVE_WINDOW = timedelta(days=30)
def __init__(self, authors: Authors, graph: Graph, activity: Activity, timelines: Timelines, now: Callable[[], datetime]): self._authors = authors self._graph = graph self._activity = activity self._timelines = timelines self._now = now # injected so tests control "inactive"
def handle(self, e: PostCreated) -> None: # Big accounts are never pushed: one post would be tens of millions of inserts and hold # the fleet for minutes. Followers merge their posts in at read time. The mode is a flag # set by a background job with hysteresis, so an account near 1M does not flap. if self._authors.mode(e.author_id) is AuthorMode.PULL: return
cutoff = self._now() - self.ACTIVE_WINDOW entry = TimelineEntry(e.post_id, e.author_id) cursor = None while True: # Page through followers so a 900,000 follower account never loads one giant list. # Production turns each page into its own task so many workers share one big post. page = self._graph.followers(e.author_id, cursor, self.PAGE_SIZE) # About 40% of followers have not opened the app in 30 days. Writing to them is # wasted RAM and work; their timeline is rebuilt by pull if they come back. targets = self._activity.active_since(page.ids, cutoff) # ZADD is idempotent (same member, same score), so a redelivered event changes nothing. self._timelines.add_all(targets, entry, self.TIMELINE_LENGTH) if page.next is None: break cursor = page.next// Published by the post service once the post is durable. Kafka keys it by author ID, so one// author's events stay in order and one consumer handles them.record PostCreated(long PostId, long AuthorId);record TimelineEntry(long PostId, long AuthorId); // the author ID lets readers filter without a lookuprecord FollowerPage(IReadOnlyList<long> Ids, string? Next); // Next is null on the last page
enum AuthorMode { Push, Pull }
interface IAuthors { AuthorMode Mode(long authorId); }interface IGraph { FollowerPage Followers(long authorId, string? cursor, int limit); }interface IActivity { IReadOnlyList<long> ActiveSince(IReadOnlyList<long> userIds, DateTimeOffset cutoff); }interface ITimelines{ // One pipelined batch per shard: ZADD timeline:{id} <ms> <postId+authorId, 16 bytes>, // then ZREMRANGEBYRANK to keep the newest maxLength. The score is the millisecond timestamp, // not the raw 64 bit ID: Redis scores are doubles and lose precision above 2^53. void AddAll(IReadOnlyList<long> userIds, TimelineEntry entry, int maxLength);}
// Runs for every PostCreated event. Kafka delivers at least once, so this can run twice for// the same post; every write it makes must be safe to repeat.class FanoutWorker(IAuthors authors, IGraph graph, IActivity activity, ITimelines timelines, TimeProvider clock) // clock injected so tests control "inactive"{ const int PageSize = 1_000; // followers per batch: one pipelined round trip const int TimelineLength = 500; // bounded: memory per user is fixed, about 15 KB static readonly TimeSpan ActiveWindow = TimeSpan.FromDays(30);
public void Handle(PostCreated e) { // Big accounts are never pushed: one post would be tens of millions of inserts and hold // the fleet for minutes. Followers merge their posts in at read time. The mode is a flag // set by a background job with hysteresis, so an account near 1M does not flap. if (authors.Mode(e.AuthorId) == AuthorMode.Pull) return;
var cutoff = clock.GetUtcNow() - ActiveWindow; var entry = new TimelineEntry(e.PostId, e.AuthorId); string? cursor = null; do { // Page through followers so a 900,000 follower account never loads one giant list. // Production turns each page into its own task so many workers share one big post. var page = graph.Followers(e.AuthorId, cursor, PageSize); // About 40% of followers have not opened the app in 30 days. Writing to them is // wasted RAM and work; their timeline is rebuilt by pull if they come back. var targets = activity.ActiveSince(page.Ids, cutoff); // ZADD is idempotent (same member, same score), so a redelivered event changes nothing. timelines.AddAll(targets, entry, TimelineLength); cursor = page.Next; } while (cursor is not null); }}// PostCreated is published by the post service once the post is durable. Kafka keys it by// author ID, so one author's events stay in order and one consumer handles them.type PostCreated struct { PostID, AuthorID int64}
// TimelineEntry carries the author ID so readers can filter unfollows and blocks without a lookup.type TimelineEntry struct { PostID, AuthorID int64}
type FollowerPage struct { IDs []int64 Next string // "" on the last page}
type AuthorMode int
const ( Push AuthorMode = iota Pull)
type Authors interface { Mode(authorID int64) (AuthorMode, error)}
type Graph interface { Followers(authorID int64, cursor string, limit int) (FollowerPage, error)}
type Activity interface { ActiveSince(userIDs []int64, cutoff time.Time) ([]int64, error)}
// Timelines does one pipelined batch per shard: ZADD timeline:{id} <ms from the post ID>// <16 byte postID+authorID>, then ZREMRANGEBYRANK to keep the newest maxLength. The score is the// millisecond timestamp, not the raw 64 bit ID: Redis scores are doubles and lose precision above 2^53.type Timelines interface { AddAll(userIDs []int64, entry TimelineEntry, maxLength int) error}
const ( pageSize = 1_000 // followers per batch: one pipelined round trip timelineLength = 500 // bounded: memory per user is fixed, about 15 KB activeWindow = 30 * 24 * time.Hour // followers idle longer than this are skipped)
type FanoutWorker struct { Authors Authors Graph Graph Activity Activity Timelines Timelines Now func() time.Time // injected so tests control "inactive"}
// Handle runs for every PostCreated event. Kafka delivers at least once, so this can run twice// for the same post; every write it makes must be safe to repeat. An error leaves the offset// uncommitted, and the whole event is retried.func (w *FanoutWorker) Handle(e PostCreated) error { // Big accounts are never pushed: one post would be tens of millions of inserts and hold // the fleet for minutes. Followers merge their posts in at read time. The mode is a flag // set by a background job with hysteresis, so an account near 1M does not flap. mode, err := w.Authors.Mode(e.AuthorID) if err != nil || mode == Pull { return err }
cutoff := w.Now().Add(-activeWindow) entry := TimelineEntry{e.PostID, e.AuthorID} cursor := "" for { // Page through followers so a 900,000 follower account never loads one giant list. // Production turns each page into its own task so many workers share one big post. page, err := w.Graph.Followers(e.AuthorID, cursor, pageSize) if err != nil { return err } // About 40% of followers have not opened the app in 30 days. Writing to them is // wasted RAM and work; their timeline is rebuilt by pull if they come back. targets, err := w.Activity.ActiveSince(page.IDs, cutoff) if err != nil { return err } // ZADD is idempotent (same member, same score), so a retried event changes nothing. if err := w.Timelines.AddAll(targets, entry, timelineLength); err != nil { return err } if page.Next == "" { return nil } cursor = page.Next }}// Published by the post service once the post is durable. Kafka keys it by author ID, so one// author's events stay in order and one consumer handles them.interface PostCreated { postId: bigint; authorId: bigint;}
interface TimelineEntry { postId: bigint; authorId: bigint; // lets readers filter unfollows and blocks without a lookup}
interface FollowerPage { ids: bigint[]; next: string | null; // null on the last page}
type AuthorMode = 'push' | 'pull';
interface Authors { mode(authorId: bigint): Promise<AuthorMode>;}interface Graph { followers(authorId: bigint, cursor: string | null, limit: number): Promise<FollowerPage>;}interface Activity { activeSince(userIds: bigint[], cutoff: Date): Promise<bigint[]>;}interface Timelines { // One pipelined batch per shard: ZADD timeline:{id} <ms> <postId+authorId, 16 bytes>, // then ZREMRANGEBYRANK to keep the newest maxLength. The score is the millisecond timestamp, // not the raw 64 bit ID: Redis scores are doubles and lose precision above 2^53. addAll(userIds: bigint[], entry: TimelineEntry, maxLength: number): Promise<void>;}
const PAGE_SIZE = 1_000; // followers per batch: one pipelined round tripconst TIMELINE_LENGTH = 500; // bounded: memory per user is fixed, about 15 KBconst ACTIVE_WINDOW_MS = 30 * 24 * 3600 * 1000;
// Runs for every PostCreated event. Kafka delivers at least once, so this can run twice for// the same post; every write it makes must be safe to repeat.class FanoutWorker { constructor( private readonly authors: Authors, private readonly graph: Graph, private readonly activity: Activity, private readonly timelines: Timelines, private readonly now: () => Date = () => new Date(), // injected so tests control "inactive" ) {}
async handle(e: PostCreated): Promise<void> { // Big accounts are never pushed: one post would be tens of millions of inserts and hold // the fleet for minutes. Followers merge their posts in at read time. The mode is a flag // set by a background job with hysteresis, so an account near 1M does not flap. if ((await this.authors.mode(e.authorId)) === 'pull') return;
const cutoff = new Date(this.now().getTime() - ACTIVE_WINDOW_MS); const entry: TimelineEntry = { postId: e.postId, authorId: e.authorId }; let cursor: string | null = null; do { // Page through followers so a 900,000 follower account never loads one giant list. // Production turns each page into its own task so many workers share one big post. const page: FollowerPage = await this.graph.followers(e.authorId, cursor, PAGE_SIZE); // About 40% of followers have not opened the app in 30 days. Writing to them is // wasted RAM and work; their timeline is rebuilt by pull if they come back. const targets = await this.activity.activeSince(page.ids, cutoff); // ZADD is idempotent (same member, same score), so a redelivered event changes nothing. await this.timelines.addAll(targets, entry, TIMELINE_LENGTH); cursor = page.next; } while (cursor !== null); }}How much memory do timelines need? Each one is about 15 KB. For a billion people who were active this month, that’s about 15 TB, or 30 TB with a backup copy. That’s roughly 300 cache servers.
Reading the feed
Section titled “Reading the feed”Opening the feed is now short. The feed service reads the user’s timeline: up to 500 post IDs, newest first. It fetches the first 20 posts from the post cache and returns them. That’s one list read and one batch of 20 lookups, instead of 200 lookups for pull.
This is enough for a mid-level answer. Pull, why it breaks, and push, with the numbers behind each, explained clearly, passes at mid-level. The next steps are what make it a senior answer. Each one is a place where push breaks at this size.
Go deeperTimeline memory, and a worker that crashes1 min
That 15 KB holds only if Redis stores each timeline in its compact form. By default, Redis switches
a sorted set to a bigger form once it has more than 128 entries. That form costs about 3 to 5 times
more memory per entry, which would mean 1,000 servers or more. So we raise that limit
(zset-max-listpack-entries) to 512. Adding to a compact list of 500 is a little slower, but still
takes microseconds. The limit of 500 entries keeps this number under control. Almost nobody scrolls
past a few hundred posts. If someone does, we build the older pages with pull.
Interviewer asksA worker crashes halfway through a post with 400,000 followers. What happens?
The worker never marked the event as done in Kafka. So the next worker reads the same event again, and the copying runs again. That’s safe, because adding the same post ID twice still leaves one entry. This property is called being idempotent. Each group of 1,000 followers is its own small task, so a crash repeats only one group, not all 400.
Step 3. A singer with 80 million followers posts
Section titled “Step 3. A singer with 80 million followers posts”A singer with 80 million followers posts. Walk me through what your system does. Whether you see the sudden burst, not the average.
On average, push is cheap. But two problems hide behind that average.
The first problem is bursts. All the fan-out workers together copy about 1 million IDs a second. A post to 80 million followers keeps all of them busy for 80 seconds. Every other post waits in line behind it. So the 5-second promise breaks for everyone, several times a day.
The second problem is waste. About 40% of follows point at people who haven’t opened the app in 30 days. Copying posts to them costs work, and their timelines fill memory that nobody reads.
The fix is to use both push and pull, depending on the author. This is called the hybrid.
- Big accounts are pulled. If an account has more than about 1 million followers, we stop copying its posts. When someone opens their feed, the feed service fetches these accounts’ newest posts and mixes them into the timeline. This is cheap for a simple reason. There are only tens of thousands of such accounts, and millions of feeds read each one. So every feed server keeps their newest post IDs in its own memory, refreshed every second.
- Inactive people are skipped. Fan-out doesn’t copy posts to anyone who has been away for 30 days. When one of them comes back, the feed service finds no timeline. It builds one with pull, once (about 100 ms), and marks them active again.
Stage 3: Push only where it pays
Stage 3 of 5
Push only where it pays
Why this stage
One post from a very big account keeps every fan-out worker busy for over a minute and delays everyone else’s posts.
And 40% of copies go to people who have not opened the app in a month.
What we add
Accounts above about 1 million followers are pulled when the feed is read, and mixed in.
Everyone else is pushed.
Followers who have been away for 30 days are skipped and rebuilt when they return.
What it costs
Two ways to get posts that must agree, a line to tune, and a slower first load for returning users.
At 170k feed loads per second, every part has headroom. Raise the traffic until something turns red.
The numbers behind the busy bars
How busy it gets
- For every 50 feed loads, someone publishes 1 new post. Reading is the heavy side.
What one machine can do
- A feed server builds about 5,000 feeds a second when the timeline is ready. If it has to pull about 200 lists first (stage 1), it manages only about 500.
- One Redis node answers about 200,000 lookups a second with multi-gets.
- One fan-out worker copies about 20,000 posts into timelines a second.
- One ranking server ranks about 500 feeds a second, scoring 500 posts each.
How big the fleet is, per region
- The post cache needs 30 nodes. Only 2% of reads miss it and go to the post store, which has 16 shards.
- The timeline cache needs 300 nodes of 100 GB, about 30 TB of memory at about 30 bytes an entry.
Why push costs less than it looks
- A post is copied to about 84 timelines, not 200. About 40% of followers are inactive and skipped, and 30% of follows point at big accounts, which are pulled instead.
Switching an account between push and pull is always safe. When someone opens their feed, we read both kinds of posts, the copied ones and the pulled ones. Then we merge them and remove any duplicates. Here is the code for the newest-first feed. The ranked feed does the same merge, then hands the list to the ranker instead of sorting it by time.
record Entry(long postId, long authorId) {}record Post(long id, long authorId, String text) {}record Page(List<Post> posts, Long nextBefore) {} // nextBefore is null when the feed ends
interface Timelines { List<Entry> read(long userId, int max); } // pushed by fan-out// Pulled accounts (held in memory) and the viewer's own recent posts.interface Pulled { List<Entry> recent(long viewer); }interface Policy { boolean canSee(long viewer, long authorId); } // follows, blocks, privateinterface Posts { // Only posts that exist, are not deleted and whose audience includes the viewer come back. Map<Long, Post> getVisible(List<Long> ids, long viewer);}
// One page of the chronological feed: the pushed timeline merged with pulled posts, filtered// at read time, and cut at a cursor that new posts cannot shift.class FeedReader { private final Timelines timelines; private final Pulled pulled; private final Policy policy; private final Posts posts;
FeedReader(Timelines timelines, Pulled pulled, Policy policy, Posts posts) { this.timelines = timelines; this.pulled = pulled; this.policy = policy; this.posts = posts; }
Page read(long viewer, Long before, int limit) { // Candidates from both sides of the hybrid: what fan-out wrote, and the recent posts // of followed accounts that are never fanned out, plus my own (read your own write). List<Entry> candidates = new ArrayList<>(timelines.read(viewer, 500)); candidates.addAll(pulled.recent(viewer)); // Post IDs are time-sortable and unique, so sorting by ID is time order with no ties. candidates.sort(Comparator.comparingLong(Entry::postId).reversed());
List<Post> page = new ArrayList<>(limit); Set<Long> seen = new HashSet<>(); int i = 0; while (page.size() < limit && i < candidates.size()) { List<Long> batch = new ArrayList<>(); // Over-fetch: some candidates will be filtered, and one extra round trip is // cheaper than a short page. while (batch.size() < limit * 2 && i < candidates.size()) { Entry c = candidates.get(i++); // The cursor means "older than this post", not "skip N". Posts that arrive at // the top while the user scrolls cannot push items into the next page twice. if (before != null && c.postId() >= before) continue; // An author who just switched between push and pull can be in both lists. if (!seen.add(c.postId())) continue; // Unfollows and blocks apply here, so they work on the very next load even // though fan-out already wrote the entry. Cleaning the cache can be lazy. if (!policy.canSee(viewer, c.authorId())) continue; batch.add(c.postId()); } // Hydration is where deletes and audience changes are enforced: the post store is // the source of truth, the timeline only holds IDs. Map<Long, Post> found = posts.getVisible(batch, viewer); for (long id : batch) { Post p = found.get(id); if (p != null && page.size() < limit) page.add(p); } } // The next page starts below the last post shown. The client treats it as opaque. Long next = page.size() < limit ? null : page.get(page.size() - 1).id(); return new Page(page, next); }}@dataclass(frozen=True)class Entry: post_id: int author_id: int
@dataclass(frozen=True)class Post: id: int author_id: int text: str
@dataclass(frozen=True)class Page: posts: list[Post] next_before: int | None # None when the feed ends
class Timelines(Protocol): # pushed by fan-out def read(self, user_id: int, max: int) -> list[Entry]: ...
class Pulled(Protocol): # pulled accounts (in memory) and my own posts def recent(self, viewer: int) -> list[Entry]: ...
class Policy(Protocol): # follows, blocks, private accounts def can_see(self, viewer: int, author_id: int) -> bool: ...
class Posts(Protocol): # Only posts that exist, are not deleted and whose audience includes the viewer come back. def get_visible(self, ids: list[int], viewer: int) -> dict[int, Post]: ...
# One page of the chronological feed: the pushed timeline merged with pulled posts,# filtered at read time, and cut at a cursor that new posts cannot shift.class FeedReader: def __init__(self, timelines: Timelines, pulled: Pulled, policy: Policy, posts: Posts): self._timelines = timelines self._pulled = pulled self._policy = policy self._posts = posts
def read(self, viewer: int, before: int | None, limit: int) -> Page: # Candidates from both sides of the hybrid: what fan-out wrote, and the recent posts # of followed accounts that are never fanned out, plus my own (read your own write). candidates = self._timelines.read(viewer, 500) + self._pulled.recent(viewer) # Post IDs are time-sortable and unique, so sorting by ID is time order with no ties. candidates.sort(key=lambda c: c.post_id, reverse=True)
page: list[Post] = [] seen: set[int] = set() i = 0 while len(page) < limit and i < len(candidates): batch: list[int] = [] # Over-fetch: some candidates will be filtered, and one extra round trip is # cheaper than a short page. while len(batch) < limit * 2 and i < len(candidates): c = candidates[i] i += 1 # The cursor means "older than this post", not "skip N". Posts that arrive at # the top while the user scrolls cannot push items into the next page twice. if before is not None and c.post_id >= before: continue # An author who just switched between push and pull can be in both lists. if c.post_id in seen: continue seen.add(c.post_id) # Unfollows and blocks apply here, so they work on the very next load even # though fan-out already wrote the entry. Cleaning the cache can be lazy. if not self._policy.can_see(viewer, c.author_id): continue batch.append(c.post_id) # Hydration is where deletes and audience changes are enforced: the post store is # the source of truth, the timeline only holds IDs. found = self._posts.get_visible(batch, viewer) page.extend(found[pid] for pid in batch if pid in found) del page[limit:]
# The next page starts below the last post shown. The client treats it as opaque. next_before = page[-1].id if len(page) == limit else None return Page(page, next_before)record Entry(long PostId, long AuthorId);record Post(long Id, long AuthorId, string Text);record Page(IReadOnlyList<Post> Posts, long? NextBefore); // NextBefore is null when the feed ends
interface ITimelines { IReadOnlyList<Entry> Read(long userId, int max); } // pushed by fan-out// Pulled accounts (held in memory) and the viewer's own recent posts.interface IPulled { IReadOnlyList<Entry> Recent(long viewer); }interface IPolicy { bool CanSee(long viewer, long authorId); } // follows, blocks, privateinterface IPosts{ // Only posts that exist, are not deleted and whose audience includes the viewer come back. IReadOnlyDictionary<long, Post> GetVisible(IReadOnlyList<long> ids, long viewer);}
// One page of the chronological feed: the pushed timeline merged with pulled posts, filtered// at read time, and cut at a cursor that new posts cannot shift.class FeedReader(ITimelines timelines, IPulled pulled, IPolicy policy, IPosts posts){ public Page Read(long viewer, long? before, int limit) { // Candidates from both sides of the hybrid: what fan-out wrote, and the recent posts // of followed accounts that are never fanned out, plus my own (read your own write). var candidates = new List<Entry>(timelines.Read(viewer, 500)); candidates.AddRange(pulled.Recent(viewer)); // Post IDs are time-sortable and unique, so sorting by ID is time order with no ties. candidates.Sort((a, b) => b.PostId.CompareTo(a.PostId));
var page = new List<Post>(limit); var seen = new HashSet<long>(); int i = 0; while (page.Count < limit && i < candidates.Count) { var batch = new List<long>(); // Over-fetch: some candidates will be filtered, and one extra round trip is // cheaper than a short page. while (batch.Count < limit * 2 && i < candidates.Count) { var c = candidates[i++]; // The cursor means "older than this post", not "skip N". Posts that arrive at // the top while the user scrolls cannot push items into the next page twice. if (before is long b && c.PostId >= b) continue; // An author who just switched between push and pull can be in both lists. if (!seen.Add(c.PostId)) continue; // Unfollows and blocks apply here, so they work on the very next load even // though fan-out already wrote the entry. Cleaning the cache can be lazy. if (!policy.CanSee(viewer, c.AuthorId)) continue; batch.Add(c.PostId); } // Hydration is where deletes and audience changes are enforced: the post store is // the source of truth, the timeline only holds IDs. var found = posts.GetVisible(batch, viewer); foreach (var id in batch) if (page.Count < limit && found.TryGetValue(id, out var p)) page.Add(p); } // The next page starts below the last post shown. The client treats it as opaque. long? next = page.Count < limit ? null : page[^1].Id; return new Page(page, next); }}type Entry struct { PostID, AuthorID int64}
type Post struct { ID, AuthorID int64 Text string}
type Page struct { Posts []Post NextBefore int64 // 0 when the feed ends}
type Timelines interface { // pushed by fan-out Read(userID int64, max int) ([]Entry, error)}
type Pulled interface { // pulled accounts (in memory) and my own posts Recent(viewer int64) []Entry}
type Policy interface { // follows, blocks, private accounts CanSee(viewer, authorID int64) bool}
// Posts returns only posts that exist, are not deleted and whose audience includes the viewer.type Posts interface { GetVisible(ids []int64, viewer int64) (map[int64]Post, error)}
// FeedReader builds one page of the chronological feed: the pushed timeline merged with pulled// posts, filtered at read time, and cut at a cursor that new posts cannot shift.type FeedReader struct { Timelines Timelines Pulled Pulled Policy Policy Posts Posts}
// Read returns the page of posts older than before (0 means the newest page).func (r *FeedReader) Read(viewer, before int64, limit int) (Page, error) { // Candidates from both sides of the hybrid: what fan-out wrote, and the recent posts // of followed accounts that are never fanned out, plus my own (read your own write). candidates, err := r.Timelines.Read(viewer, 500) if err != nil { return Page{}, err } candidates = append(candidates, r.Pulled.Recent(viewer)...) // Post IDs are time-sortable and unique, so sorting by ID is time order with no ties. sort.Slice(candidates, func(a, b int) bool { return candidates[a].PostID > candidates[b].PostID })
page := make([]Post, 0, limit) seen := make(map[int64]bool) i := 0 for len(page) < limit && i < len(candidates) { // Over-fetch: some candidates will be filtered, and one extra round trip is // cheaper than a short page. var batch []int64 for len(batch) < limit*2 && i < len(candidates) { c := candidates[i] i++ // The cursor means "older than this post", not "skip N". Posts that arrive at // the top while the user scrolls cannot push items into the next page twice. if before != 0 && c.PostID >= before { continue } // An author who just switched between push and pull can be in both lists. if seen[c.PostID] { continue } seen[c.PostID] = true // Unfollows and blocks apply here, so they work on the very next load even // though fan-out already wrote the entry. Cleaning the cache can be lazy. if !r.Policy.CanSee(viewer, c.AuthorID) { continue } batch = append(batch, c.PostID) } // Hydration is where deletes and audience changes are enforced: the post store is // the source of truth, the timeline only holds IDs. found, err := r.Posts.GetVisible(batch, viewer) if err != nil { return Page{}, err } for _, id := range batch { if p, ok := found[id]; ok && len(page) < limit { page = append(page, p) } } } // The next page starts below the last post shown. The client treats it as opaque. var next int64 if len(page) == limit { next = page[len(page)-1].ID } return Page{page, next}, nil}interface Entry { postId: bigint; authorId: bigint;}
interface Post { id: bigint; authorId: bigint; text: string;}
interface Page { posts: Post[]; nextBefore: bigint | null; // null when the feed ends}
interface Timelines { read(userId: bigint, max: number): Promise<Entry[]>; // pushed by fan-out}interface Pulled { recent(viewer: bigint): Entry[]; // pulled accounts (in memory) and my own posts}interface Policy { canSee(viewer: bigint, authorId: bigint): boolean; // follows, blocks, private accounts}interface Posts { // Only posts that exist, are not deleted and whose audience includes the viewer come back. getVisible(ids: bigint[], viewer: bigint): Promise<Map<bigint, Post>>;}
// One page of the chronological feed: the pushed timeline merged with pulled posts, filtered// at read time, and cut at a cursor that new posts cannot shift.class FeedReader { constructor( private readonly timelines: Timelines, private readonly pulled: Pulled, private readonly policy: Policy, private readonly posts: Posts, ) {}
async read(viewer: bigint, before: bigint | null, limit: number): Promise<Page> { // Candidates from both sides of the hybrid: what fan-out wrote, and the recent posts // of followed accounts that are never fanned out, plus my own (read your own write). const candidates = [ ...(await this.timelines.read(viewer, 500)), ...this.pulled.recent(viewer), ]; // Post IDs are time-sortable and unique, so sorting by ID is time order with no ties. // bigint IDs: 64 bit IDs do not fit a JavaScript number without losing precision. candidates.sort((a, b) => (a.postId < b.postId ? 1 : a.postId > b.postId ? -1 : 0));
const page: Post[] = []; const seen = new Set<bigint>(); let i = 0; while (page.length < limit && i < candidates.length) { const batch: bigint[] = []; // Over-fetch: some candidates will be filtered, and one extra round trip is // cheaper than a short page. while (batch.length < limit * 2 && i < candidates.length) { const c = candidates[i++]; // The cursor means "older than this post", not "skip N". Posts that arrive at // the top while the user scrolls cannot push items into the next page twice. if (before !== null && c.postId >= before) continue; // An author who just switched between push and pull can be in both lists. if (seen.has(c.postId)) continue; seen.add(c.postId); // Unfollows and blocks apply here, so they work on the very next load even // though fan-out already wrote the entry. Cleaning the cache can be lazy. if (!this.policy.canSee(viewer, c.authorId)) continue; batch.push(c.postId); } // Hydration is where deletes and audience changes are enforced: the post store is // the source of truth, the timeline only holds IDs. const found = await this.posts.getVisible(batch, viewer); for (const id of batch) { const p = found.get(id); if (p && page.length < limit) page.push(p); } } // The next page starts below the last post shown. The client treats it as opaque. const nextBefore = page.length === limit ? page[page.length - 1].id : null; return { posts: page, nextBefore }; }}The hybrid has a price. There are now two ways to get posts, and they must agree on order, filtering and paging. There’s a line to tune. And someone who comes back after a month waits about 100 ms longer for their first load.
Go deeperWhere exactly to draw the line2 min
First, here is how the three choices compare at peak:
| Strategy | Copies at peak | One feed load reads | A big account’s post appears |
|---|---|---|---|
| Pure push | 690k/s + bursts | 1 list | After a minute, and delays others |
| Pure pull | 0 | ~200 lists | At once |
| Hybrid: pull above 1M, skip idle | 290k/s | 1 list + a few dozen in-memory lists | At once |
Where does 290k/s come from? Start with pure push: 690,000 copies a second at peak. Skipping inactive people removes the 40% of follows that point at them, which leaves about 414,000. Then assume accounts above 1 million followers make up about 30% of what’s left. That is an estimate, not a measured number: there are few such accounts, but each of their posts goes to so many people that they add up fast. Pulling them instead removes those copies too: 414,000 × 0.7 is about 290,000 a second.
We said “about 1 million followers”. But think about an account sitting right at 1 million. As people follow and unfollow, it would switch between push and pull every day. Each switch is safe, but it is wasted work, and it makes the system harder to reason about.
So we use two lines instead of one. An account moves to pull when it passes 1 million followers. It only moves back to push when it drops below 800,000. The gap between the two lines stops the flipping. Engineers call this gap hysteresis.
Why 1 million, and not 100,000 or 10 million? Two things set it:
- Burst size. At 1 million followers, a post takes the whole fan-out fleet about one second. That still fits inside the 5-second promise, even with a few such posts at once. At 10 million, it doesn’t.
- Memory on each feed server. Every pulled account’s newest post IDs sit in memory on every feed server. Tens of thousands of accounts × a few hundred IDs is a few hundred MB. That fits easily. If the line were 10,000 followers, there would be millions of pulled accounts, and that no longer fits.
Count active followers, not all followers. Bot farms make raw follower counts much larger than the real audience.
Step 4. The best posts first
Section titled “Step 4. The best posts first”Product usually wants the best posts first, not the newest. So ranking needs a clear place in the design, a time limit, and a plan for when it is slow.
Stage 4: The best posts first
Stage 4 of 5
The best posts first
Why this stage
Product wants a ranked feed, not newest first, and the feed must still load in 300 ms.
What we add
A ranking service scores about 500 candidates for each load, with a 100 ms time limit.
If it is slow, the feed falls back to time order.
What it costs
Ranking servers are the largest compute cost, and paging now needs a saved session, because the order is no longer by time.
At 170k feed loads per second, every part has headroom. Raise the traffic until something turns red.
The numbers behind the busy bars
How busy it gets
- For every 50 feed loads, someone publishes 1 new post. Reading is the heavy side.
What one machine can do
- A feed server builds about 5,000 feeds a second when the timeline is ready. If it has to pull about 200 lists first (stage 1), it manages only about 500.
- One Redis node answers about 200,000 lookups a second with multi-gets.
- One fan-out worker copies about 20,000 posts into timelines a second.
- One ranking server ranks about 500 feeds a second, scoring 500 posts each.
How big the fleet is, per region
- The post cache needs 30 nodes. Only 2% of reads miss it and go to the post store, which has 16 shards.
- The timeline cache needs 300 nodes of 100 GB, about 30 TB of memory at about 30 bytes an entry.
Why push costs less than it looks
- A post is copied to about 84 timelines, not 200. About 40% of followers are inactive and skipped, and 30% of follows point at big accounts, which are pulled instead.
Timelines keep posts in time order. Ranking doesn’t replace them. It uses them as the starting point. The 500 newest IDs in a timeline, plus the posts from big accounts, become the candidates. The ranker scores them and returns the best ones.
Why not score each post once, when it is copied into timelines? Because the most useful signals don’t exist yet at that moment. How many likes a post gets in its first ten minutes matters a lot. So does what this person did a minute ago. Also, if scores were stored in timelines, every new model would mean rewriting every timeline. So timelines stay in time order, and ranking happens when the feed is read.
Ranking is not free. It adds about 45 ms to each load, uses most of the feed’s CPU, and needs several hundred servers at peak. So the feed service gives the ranker 100 ms. If the ranker takes longer, the feed shows the last ranked page from the past 10 minutes, or else the posts in time order. If the ranker goes down, the feed gets worse, but it never goes empty.
Now follow one feed load through the design as it stands. Next hop moves the request one step. The bar shows how much of the 300 ms budget each step uses.
Trace a request: Load the home feed
Load the home feed: step 1 of 8
Users to API gateway
GET /v1/feed?limit=20. The gateway terminates TLS, checks the session token and the per-user rate limit, and routes to any feed server in the user’s home region.
- Users → API gateway20 ms
- API gateway → Feed service2 ms
- Feed service → Timeline cache3 ms
- Feed service → Graph service4 ms
- Feed service → Post cache3 ms
- Feed service → Ranking service45 ms
- Feed service → Post cache6 ms
- Response
Go deeperInside the ranker: two passes and a feature cache2 min
Scoring 550 posts with a large model would take too long. So the ranker works in two passes.
- A fast first pass. A small, simple model looks at every candidate and keeps about 100. It uses only cheap signals, like the post’s age and how often this viewer interacts with the author.
- A careful second pass. A larger model puts those 100 in order. It uses many more signals: how well the post is doing right now, what kind of posts this viewer likes, and whether it shows a photo or video.
All of these signals are called features. Background jobs work them out ahead of time and keep them in a feature cache. So when someone opens the feed, the ranker only reads numbers. It never calculates anything slow while the user waits.
Last, a few simple rules run after scoring. For example, don’t show three posts in a row from the same author. These rules are cheap and easy to change without training a new model.
Stress it
Section titled “Stress it”The design is complete. Now we test it against four situations every feed must handle: scrolling while new posts arrive, deletes and blocks, a returning user with no timeline, and ten times the users. Only the last one adds boxes.
Scrolling while new posts arrive
Section titled “Scrolling while new posts arrive”You are scrolling, and new posts keep arriving. You should never see a post twice, and never miss one.
The simplest idea is page numbers: page 1, page 2, page 3. It breaks quickly. Say ten new posts arrive while you read page 1. Everything moves down by ten, so page 2 now starts with ten posts you’ve already seen.
Instead, each page comes with a cursor: a bookmark that says where you stopped. What the bookmark holds depends on the feed.
In the newest-first feed, the cursor is the ID of the last post you saw. The next page is “posts older than this one”. New posts get bigger IDs, so they appear above your bookmark and never move it. (Two ID servers’ clocks can differ by a few milliseconds, so the order is only exact to within a few milliseconds. That’s fine for a feed.)
In the ranked feed, “older than this one” doesn’t work, because the order isn’t by time. So we rank once. On the first page, we rank about 500 posts and save the ordered list of IDs for 30 minutes. We call this saved list a session. The cursor holds the session and your place in it. Each next page reads the next 20 IDs from the session, without ranking again. New posts don’t enter the session. Instead, a “12 new posts” button appears. Pulling down to refresh starts a new session.
There are two small side effects. If a post is deleted during your session, that page shows 19 posts instead of 20. And if the session expires, we rank again. Almost nobody notices either.
Go deeperWhat the cursor looks like1 min
To the app, the cursor is just a string, like eyJzIjoiZjQ.... It must not try to read it. Inside,
the server packs a few fields and signs them, so nobody can edit the cursor to see other people’s
posts:
newest first: { "before": 1849275503923314688 }ranked: { "session": "f4c1...", "offset": 40, "expires": 1792000000 }Because the cursor carries everything, any feed server can answer the next page. No server needs to remember who is scrolling where. The ranked session itself sits in the cache, about 4 KB for 500 IDs. If the session has expired, the server quietly starts a new one from the top.
Deletes and blocks
Section titled “Deletes and blocks”A user deletes a post that went to 300,000 timelines, or blocks someone. How fast does it disappear?
Remember that we copy post IDs into timelines. A deleted post’s ID now sits in 300,000 timelines, plus some saved sessions. We can’t remove every copy before each of those people opens the app again. So removing copies can’t be the promise. Instead, we check every post at the moment we read it.
- Deletes. When a post is deleted, the post service marks it as deleted. This mark is called a tombstone. It also puts the tombstone in the post cache. Every feed load fetches its 20 posts from that cache. So on the next load, everywhere, the deleted post is skipped. This covers saved sessions too.
- Privacy changes work the same way. Every time we fetch a post, we check that this viewer is still allowed to see it.
- Unfollows and blocks. Each timeline entry stores the author’s ID next to the post ID. So when we merge, we drop posts from anyone the viewer no longer follows or has blocked.
We still remove the old copies, but only as cleanup. The delete sends a PostDeleted event through
Kafka, in the same order as the original post. Fan-out workers then remove the ID from timelines,
so pages don’t come up short.
- Color key
- Service
- Background worker
- Queue or stream
- Cache
This also answers “is the feed consistent?”. It depends on the action. If a friend’s post shows up one second late, nobody notices. So new posts can arrive a little late. This is called eventual consistency. But if a blocked person’s post shows up “for a few minutes”, that’s a safety problem. So blocks and deletes are checked on every read.
Go deeperWhat each action promises1 min
Each action gets its own promise. Strong promises cost more, so we spend them only where users would notice.
| Action | Promise | What users can see |
|---|---|---|
| Post | Saved on 3 machines before the reply | A post that got “saved” is never lost |
| Author sees own post | At once | The merge always includes the author’s own posts |
| Followers see a post | Usually 1 s, at most 5 s | Two friends side by side may see it a second apart |
| Order while scrolling | One ranked session | Posts don’t move while you scroll; refresh reorders |
| Delete, block, unfollow, privacy | From the next load | Gone everywhere on the next load; cleanup comes later |
| Follow | Within seconds | The new account’s last 20 posts appear after a moment |
| Another region | 1 to 2 s later | A follower abroad sees a post a little later |
A returning user with no timeline
Section titled “A returning user with no timeline”Someone comes back after two months away. Fan-out stopped copying posts to them after 30 days, so they have no timeline in the cache. Their first load must still be quick, and never empty.
The feed service sees the missing timeline and builds it with pull, the step 1 path: it reads the newest posts of the 200 or so accounts they follow, merges them, and saves the result as their timeline. That costs a few hundred milliseconds, once. From then on, fan-out copies new posts to them again. If many people come back at once, after an outage or a big event, rebuilds are limited per second, and the rest see a time-order feed until their turn.
Ten times the users
Section titled “Ten times the users”Ten times the users means about 580,000 feed loads a second on average, and 1.7 million at peak. Most parts simply grow: timelines are split by user, posts by post ID, and the fan-out workers by Kafka partition. Two parts feel it first. The post cache must answer about 36 million fetches a second, so the posts of big accounts are also kept in each feed server’s own memory for a few seconds. And the ranker grows toward thousands of servers, which makes its time limit and fallback matter more, not less.
The users are also spread over the world, a third in each of three regions. Losing a whole region is the other risk. At 99.99%, a one-hour outage uses up more than a year’s allowed downtime. So each region can serve feeds and copy posts on its own. Each person has a home region that holds their timeline, so we don’t keep three copies of every timeline. If a region fails, its users move to another region, and their feeds are rebuilt with pull, a few at a time.
Stage 5: Regions
Stage 5 of 5
Regions
Why this stage
One region is one failure away from an outage, against a 99.99% target.
Users in Asia also pay 150 ms or more to reach a US region.
What we add
Every region runs the whole stack.
Each user has a home region that holds their timeline.
Posts and post events are copied to every region.
What it costs
A new post appears a second or two later abroad, and when a region fails, the moved users’ timelines are rebuilt.
At 170k feed loads per second, every part has headroom. Raise the traffic until something turns red.
The numbers behind the busy bars
How busy it gets
- For every 50 feed loads, someone publishes 1 new post. Reading is the heavy side.
What one machine can do
- A feed server builds about 5,000 feeds a second when the timeline is ready. If it has to pull about 200 lists first (stage 1), it manages only about 500.
- One Redis node answers about 200,000 lookups a second with multi-gets.
- One fan-out worker copies about 20,000 posts into timelines a second.
- One ranking server ranks about 500 feeds a second, scoring 500 posts each.
How big the fleet is, per region
- The post cache needs 30 nodes. Only 2% of reads miss it and go to the post store, which has 16 shards.
- The timeline cache needs 300 nodes of 100 GB, about 30 TB of memory at about 30 bytes an entry.
Why push costs less than it looks
- A post is copied to about 84 timelines, not 200. About 40% of followers are inactive and skipped, and 30% of follows point at big accounts, which are pulled instead.
When things break
Section titled “When things break”Your timeline cache loses a shard. What do users see?
Whether you know the timeline is only a cache that can be rebuilt.
Our first promise was that the feed always loads. It may be a little old, not ranked, or slower, but it is never empty. Three choices we already made keep that promise:
- Timelines are only a cache. If a cache server is lost, those users’ next load is built with pull, the same way we build feeds for people coming back after a month. It’s slower, but it works.
- The ranker has a time limit. If it’s slow or down, the feed falls back to time order.
- Posts are saved before they’re copied. If Kafka or the workers get stuck, posts arrive late, but none are lost.
Click any box to break it and see what users would notice:
Break it: what fails, who notices, what users see
Failure: click any box to break it
A timeline cache shard goes down
What breaks
Timelines on that shard are unreadable until its replica takes over; if both are lost, they are gone.
How we notice
Timeline miss rate per shard.
What users see
Affected users get a feed rebuilt by pull: slower, otherwise the same.
What we do
Timelines are a cache, not a source of truth.
The rebuild path recreates them on demand, rate-limited.
When the system is overloaded, we turn things off in a fixed order. First the second ranking pass, then all ranking, then the posts from big accounts. Reading the timeline and fetching posts are the last to go.
Go deeperEvery failure, and what users see2 min
| Part | Failure | What users see | How we notice | What we do |
|---|---|---|---|---|
| Timeline cache shard | Lost | Next load built by pull, a little slower | Miss rate per shard | Backup copy takes over; rebuilds are limited per second |
| Fan-out workers | Down or slow | New posts arrive late; nothing is lost | How far behind Kafka they are | Add workers on lag; repeated inserts are harmless |
| Kafka | Down | Copied posts stop arriving; authors see their own | Errors when sending | Posts are saved first; a repair job sends them again |
| Ranker | Slow or down | Feed in time order instead of ranked | Fallback rate, ranker p99 | 100 ms limit; last ranked page, or time order |
| Post cache node | Down | Slower loads for a few seconds | Hit rate, store reads | Backup copy takes over; big accounts also in server memory |
| Post store shard | Too few copies | Some posts fail to save; a few old posts skipped | Write errors per shard | 3 copies; fetching skips a missing post, never fails the page |
| Graph service | Down | Copying stalls; big accounts’ posts missing | Graph error rate | Last cached lists; Kafka retries the copying |
| Whole region | Down | Users move in 1 to 2 minutes; feeds rebuilt by pull | Test requests from outside | Rebuilds limited per second; time-order feed meanwhile |
Where should timelines live when there are three regions?
| Where timelines live | Memory | When a region fails |
|---|---|---|
| Home region only | 30 TB | Rebuilt by pull, a few at a time |
| Plus a partner region | 60 TB | A warm copy is ready; every insert goes twice |
| Every region | 90 TB | Nothing to do; but copying runs 3 times |
We pick the home region only. Losing a whole region is rare, and rebuilding is slower but works. Paying for three times the memory every day, for a rare event, isn’t worth it.
Go deeperRunning it: alerts, abuse and cost2 min
Wake someone up when: fewer than 99.95% of feed loads work in a region for 5 minutes; the slowest 1% of loads take over 300 ms for 10 minutes; the oldest waiting post event is more than 30 seconds old; the ranker falls back more than 2% of the time; or timeline misses jump, which means a lost shard or a wave of rebuilds.
Abuse. A feed spreads whatever gets into it. So we limit how fast each account and device can post and follow. Posts flagged as spam when they are written are never copied. Posts flagged later get a tombstone, and the read-time check hides them everywhere.
Cost, largest first: sending photos and video through the CDN, ranking servers, timeline memory, then media storage. At 10 times the users, the post cache feels it first, at 36 million fetches a second, and the ranker grows toward thousands of servers.
Wrap up
Section titled “Wrap up”The whole design
Section titled “The whole design”Here is everything we built, in the order we built it: pull, then push, then the hybrid for big accounts, then ranking, then regions. Use the buttons to step through it.
The full design, stage by stage
Stage 5 of 5
Regions
Why this stage
One region is one failure away from an outage, against a 99.99% target.
Users in Asia also pay 150 ms or more to reach a US region.
What we add
Every region runs the whole stack.
Each user has a home region that holds their timeline.
Posts and post events are copied to every region.
What it costs
A new post appears a second or two later abroad, and when a region fails, the moved users’ timelines are rebuilt.
At 170k feed loads per second, every part has headroom. Raise the traffic until something turns red.
The numbers behind the busy bars
How busy it gets
- For every 50 feed loads, someone publishes 1 new post. Reading is the heavy side.
What one machine can do
- A feed server builds about 5,000 feeds a second when the timeline is ready. If it has to pull about 200 lists first (stage 1), it manages only about 500.
- One Redis node answers about 200,000 lookups a second with multi-gets.
- One fan-out worker copies about 20,000 posts into timelines a second.
- One ranking server ranks about 500 feeds a second, scoring 500 posts each.
How big the fleet is, per region
- The post cache needs 30 nodes. Only 2% of reads miss it and go to the post store, which has 16 shards.
- The timeline cache needs 300 nodes of 100 GB, about 30 TB of memory at about 30 bytes an entry.
Why push costs less than it looks
- A post is copied to about 84 timelines, not 200. About 40% of followers are inactive and skipped, and 30% of follows point at big accounts, which are pulled instead.
The decisions, and what each one costs
Section titled “The decisions, and what each one costs”| Decision | We chose | We gave up | We’d choose differently if |
|---|---|---|---|
| Fan-out | Push under ~1M followers, pull above | One simple read path | No big accounts (push), or few reads per user (pull) |
| Feed storage | 500 IDs per user in Redis | Unlimited saved history | Users often scroll thousands of posts deep |
| Inactive users | No copying after 30 days, rebuild on return | An instant first load after a break | Memory were cheap compared with rebuilds |
| Ranking | When the feed is read, two passes, 100 ms limit | Cheap reads | The product were newest-first only |
| Paging | ID cursor (newest first), session (ranked) | New posts appearing without refresh | A live event feed (push over an open connection) |
| Deletes, blocks | Checked on every read, cleaned up later | A simpler read path | Never: the other way shows deleted posts |
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 0 to 6. The four scope questions. Ask about the biggest account directly.
- 6 to 9. Reads and posts, as operations per second.
- 9 to 20. Pull first, then the number that breaks it, then push: writing a post and reading the feed. A mid-level answer can stop here.
- 20 to 26. Big accounts: the burst, the hybrid, skipping inactive users.
- 26 to 39. Ranking with a time limit, then the stress tests: paging, deletes, a returning user and ten times the users. This is where senior answers win or lose.
- 39 to 45. Losing the cache, then your own questions.
Never skip the push and pull math, the hybrid, or checking deletes on read. If time is short, drop regions and database brand names.
Follow-up questions
Section titled “Follow-up questions”Interviewer asksProduct wants posts from accounts you don't follow, like recommended reels.
That’s just another source of candidates. A recommendation service adds a few hundred posts per person, and the ranker scores them along with posts from people you follow. Fan-out doesn’t change. This is why we kept gathering posts and ranking them as separate steps.
Interviewer asksHow would this change at 10 times the users?
Feed servers, rankers and caches can simply grow. The pressure moves to three places. Fetching posts: keep the hottest posts in memory on every feed server. The ranker’s cost: fewer candidates and a smaller first pass. And timeline memory, which grows with users: shorter timelines, or a shorter “active” window. The hybrid design stays the same.
Go deeperMore follow-up questions2 min
A brand new user follows nobody. What do they see? There’s nothing to read yet. So sign-up suggests accounts to follow. Until they follow enough, the feed shows popular posts from their region, labelled as suggestions. Each new follow also copies that account’s last 20 posts into their timeline. Otherwise, following someone would show nothing until that person posts again.
A private account approves a new follower. When do they see old posts? Approval creates the follow and copies recent posts, the same as any follow. And because who can see what is checked on every read, nothing appears before approval, and everything appears right after, even if the copying is slow.
Further reading
Section titled “Further reading”- Caching, message queues, partitioning and unique IDs on this site, and the URL shortener for hot keys.
- Timelines at Scale, Raffi Krikorian (Twitter): fan-out into bounded in-memory timelines, kept only for recently active users.
- TAO: Facebook’s Distributed Data Store for the Social Graph, USENIX ATC 2013: one-hop graph queries served from MySQL behind a cache.
- Feeding Frenzy: Selectively Materializing Users’ Event Feeds, Silberstein et al., SIGMOD 2010: choosing push or pull per producer and consumer pair, which the hybrid here simplifies.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.