Skip to content
Software Atlas

Design a News Feed

A system-design case study: how a home feed is built for hundreds of millions of people, why copying each post into every follower's feed works until one account has 50 million followers, and how ranking, caching and failures shape the design.

⏱ ~40 min read13 sections6 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceA news feed shows you the latest posts from everyone you follow. The whole design comes down to one question: do you collect those posts when the reader opens the app, or deliver them when the author posts?

Your home timeline (the feed) is the screen you see when you open a social app. Behind it sits a simple-sounding job: "show Bob the newest posts from the 300 accounts he follows". At a few thousand users, one database query does it. At hundreds of millions, the question of when that work happens decides almost everything else.

Pull vs push: where the work happens
PULL · fan-out on read
ABCDEBobopens the app:5 lookups, every time
PUSH · fan-out on write
Anaposts once:5 writesfeed 1feed 2feed 3feed 4feed 5
Left: Bob collects from everyone he follows each time he opens the app. Right: Ana's post is copied into each follower's stored feed once, when she posts. Each dot is one lookup or one write.

A feed system has to fan out somewhere: either the reader fans out to everyone they follow, or each post fans out to everyone following its author.

The answer, in one paragraph

Feeds are opened far more often than people post, so most designs push: when you post, background workers copy the post's ID into each follower's precomputed feed. That breaks for the few accounts with millions of followers (the Celebrity (hot-account) problem), so those accounts are pulled instead and mixed in when the reader opens the app. On top of that sit a few more pieces: a cache of feeds, a ranking step, and a plan for when parts of it fail.

The route through this page

§What you'll learn
02–03What exactly we're building, and how big it is (with the arithmetic)
04The simplest design, and what breaks at 10× and 100× the traffic
05–06Pull vs push, the celebrity problem, and the hybrid that fixes it
07–08How feeds are stored, and how one page of feed is put together
09Ranking: from "newest first" to "most interesting first"
10–11What fails, how it recovers, and what you'd watch on a dashboard
Foundations§02

What are we building?

In one sentenceBefore drawing boxes, write down what the system must do (functional requirements) and how well it must do it (non-functional requirements). The second list is what shapes the design.

Functional: what users can do

  1. Post a short text post (photos and videos are stored elsewhere; see the note below).
  2. Follow and unfollow other accounts. Following is one-way: Bob can follow a pop star without her following him back.
  3. Read the home feed: posts from everyone you follow. Version 1 is newest first; later (§09) it's ranked by "most interesting".
  4. Scroll: the feed comes in pages of about 20 posts, and "load more" continues where you stopped.
  5. See new posts quickly: within a few seconds is fine. Nobody needs a live, to-the-millisecond stream (that's what Design a Chat System is about).

Out of scope here: likes and comments themselves, search, notifications (Design a Notification System), ads, and recommending accounts you don't follow.

Non-functional: how well

RequirementTarget (approximate)Why it matters for the design
Feed loads fastFirst page in under ~200 ms on the server, p99Opening the feed is the main action of the app
Read-heavy~100 feed loads for every postSpend effort at write time to make reads cheap
Highly availableFeed works even if some parts are downAn old feed beats an error page
Eventually consistentA new post may reach followers a few seconds lateLets us do the delivery work in the background
Huge skewMost accounts have ~200 followers; a few have 50 millionOne design has to handle both

That last-but-one row is a deliberate trade. A feed doesn't need every reader to see a post at the same instant; different followers seeing it a few seconds apart is fine. That puts feeds low on the consistency ladder, close to "eventual" (CAP §05 shows the whole ladder). One exception is worth keeping: you should always see your own new post right away (§11 shows the trick).

The data

Three kinds of data, shown as tables of a relational database (any database would do):

schema.sql
CREATE TABLE users (
  id        bigint PRIMARY KEY,
  name      text NOT NULL
);

CREATE TABLE posts (
  id         bigint PRIMARY KEY,          -- time-sortable: bigger id = newer (§07)
  author_id  bigint NOT NULL REFERENCES users(id),
  body       text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE follows (
  follower_id bigint NOT NULL REFERENCES users(id),  -- Bob
  followee_id bigint NOT NULL REFERENCES users(id),  -- the account Bob follows
  PRIMARY KEY (follower_id, followee_id)
);

The follows table is the follow graph. Every design on this page asks it two questions over and over: "whom does Bob follow?" (needed to read a feed by pulling) and "who follows Ana?" (needed to deliver Ana's post by pushing).

The API

Three endpoints cover the requirements:

POST /posts                 { "body": "hello" }              → 201 { "id": 9001 }
PUT  /follows/{userId}                                       → 204   (DELETE to unfollow)
GET  /feed?limit=20&cursor=… → 200 { "posts": [...], "next_cursor": "…" }

201 means Created and 204 means No Content (it worked, nothing to send back). cursor is an opaque "continue from here" token for scrolling, explained in §08.

Foundations§03

How big is it? A rough estimate

In one sentenceA back-of-the-envelope estimate uses round numbers to find out whether you need one server or a thousand. Being within 10× is good enough; the point is to see which number is scary.

All numbers below are approximate and invented for a large, typical social app. They're not any real company's figures.

Step 1: the inputs

  • 200 million daily active users (DAU).
  • Each opens their feed about 10 times a day.
  • 1 in 10 users posts once a day.
  • An account has 200 followers on average. That average hides a lot: most have fewer, and a handful have 50 million.
  • One post is about 1 KB (text, IDs, timestamps; media lives elsewhere, §02).
  • A day has 86,400 seconds. Traffic isn't spread evenly, so the busiest hour is about 3× the average: the peak factor.

Step 2: reads and writes per second

Feed loads: 200,000,000 users × 10 = 2,000,000,000 loads a day. 2,000,000,000 ÷ 86,400 ≈ 23,000 loads per second on average, ≈ 70,000/s at peak.

New posts: 200,000,000 × 1/10 = 20,000,000 posts a day. 20,000,000 ÷ 86,400 ≈ 230 posts per second, ≈ 700/s at peak.

So reads outnumber writes 2,000,000,000 ÷ 20,000,000 = 100 to 1. The feed is very read-heavy.

Step 3: the fan-out numbers

Here's where the two strategies from §01 differ. Suppose we push every post into every follower's feed:

20,000,000 posts × 200 followers = 4,000,000,000 feed inserts a day. 4,000,000,000 ÷ 86,400 ≈ 46,000 inserts per second (≈ 140,000/s at peak). Busy, but each insert is tiny.

Now one celebrity with 50,000,000 followers posts once. That single post means 50,000,000 inserts. At the normal rate of 46,000/s, that's 50,000,000 ÷ 46,000 ≈ 1,100 s ≈ 18 minutes of the whole system's normal work, for one post. Keep that number in mind for §06.

If we pull instead, each feed load reads the recent posts of everyone the reader follows. Following 200 accounts on average: 23,000 loads/s × 200 = 4,600,000 lookups per second, about 14 million at peak. That's the cost of pull, paid on every single feed open.

Step 4: storage

Posts: 20,000,000 posts × 1 KB = 20 GB a day. × 365 ≈ 7 TB a year (before keeping extra copies for safety).

Precomputed feeds (if we push): keep the newest 800 post IDs per user. An ID is a 64-bit number = 8 bytes. 800 × 8 bytes = 6,400 bytes ≈ 6.4 KB per user. 6.4 KB × 200,000,000 users ≈ 1,300,000,000,000 bytes ≈ 1.3 TB of raw IDs. With the cache's own bookkeeping, plan for roughly twice that. Too big for one machine, comfortable for a few dozen (§07).

Bandwidth: a page of 20 posts × 1 KB = 20 KB. 23,000 pages/s × 20 KB ≈ 460 MB/s ≈ 3.7 Gbit/s of feed text on average. Images and video go through the CDN, not through these servers.

Play with the inputs

Change the inputs and watch which outputs explode. Try raising average followers to 1,000, or DAU to 1 billion.

Capacity estimator
Every number is approximate. The formula with your inputs is shown next to each result.
Feed loads / s23k avg · 69k peak
= 200M × 10 ÷ 86,400
New posts / s230 avg · 690 peak
= 200M × 10% ÷ 86,400
Reads per write100 : 1
= 10 ÷ 0.1
Push: timeline inserts / s46k avg · 140k peak
= 230 posts/s × 200 followers
Pull: post-list lookups / s4.6M avg · 14M peak
= 23k loads/s × 200 followed
Timeline cache (raw IDs)1.3 TB
= 200M users × 800 IDs × 8 B
Post storage / year7.3 TB
= 20M posts/day × 1 KB × 365
Pull does 100× the work of push here, because each post is read 100 times as often as it's written. Why "average followers = average followed"? Every follow is one follower for someone and one followed account for someone else, so across all users the two averages are the same number.
Foundations§04

Version 1: one database query, and what breaks

In one sentenceStart with the simplest design that works, then grow the traffic and watch what breaks first. The thing that breaks first is the Bottleneck, and fixing it is the next design step.

The simplest feed: ask the database

One app server, one database with the three tables from §02. The feed is one query:

feed.sql
SELECT p.id, p.author_id, p.body, p.created_at
FROM follows f
JOIN posts p ON p.author_id = f.followee_id   -- posts by people Bob follows
WHERE f.follower_id = $1                      -- $1 = Bob's user id
ORDER BY p.id DESC                            -- newest first (bigger id = newer)
LIMIT 20;

Plus one more index, so the database can find "this author's newest posts" without scanning the whole table:

CREATE INDEX posts_by_author ON posts (author_id, id DESC);

This is fan-out on read, done by the database: for every feed load, it walks Bob's follow list and, for each account, jumps into the index to find that account's newest posts. Then it merges them and keeps the top 20.

At 10,000 daily users that's a handful of queries a second. It works, it's always up to date, and there's nothing to keep in sync. Ship it.

Now grow the traffic

Drag the slider from 10,000 users up to 200 million. Each step shows what breaks (highlighted in red) and the piece that gets added to fix it.

The architecture as traffic grows
Drag the slider. New parts fade in; the bottleneck at each size glows red.
ClientsApp serverPostgresusers · follows · posts
What breaks at 10k
Nothing yet. One app server and one database handle a few queries a second with room to spare.
The fix (green boxes)
Ship version 1: the feed is the SQL JOIN above (fan-out on read inside the database).

What changed at each step, in words

  • At 1 million users (100× version 1): about 1,000,000 × 10 ÷ 86,400 ≈ 120 feed loads a second, ~350 at peak (worked out as in §03). Each load visits ~200 followees, so that's ~70,000 index lookups a second, and the one database runs out of CPU, almost all of it spent on reads. Add a Load balancer with more app servers (Load Balancing), a few read replicas of the database, and a Cache for posts (Caching §03 shows the usual cache-aside pattern). The query is unchanged; it just runs on more machines. (Replicas lag behind the main database by a moment, Replication §06, which is fine here; §02 allowed that.)
  • At 20 million users (another 20×): ~7,000 loads a second at peak, ~1.4 million lookups. Each feed load is still one heavy query: ~200 index jumps plus a merge inside the database. More replicas add capacity, but they don't make that one query faster, so p99 latency stays high and climbs at peak. The fix is a different idea: build each user's feed ahead of time, in the background, so reading it is a single lookup. That's fan-out on write, §05.
  • At 200 million users: the precomputed feeds no longer fit on one cache server, and a single celebrity's post floods the background workers. Shard the feeds, and treat celebrities differently (§06–§07).
Practitioner§05

Fan-out on read vs fan-out on write

In one sentencePull pays on every feed load: one lookup per account the reader follows. Push pays on every post: one write per follower. Which is cheaper depends on how often people read versus post.

The two strategies, side by side

  • Fan-out on read (pull): when Bob opens the app, look up the newest posts of each of the F accounts he follows, merge them, and return the top 20. Posting is one write. Version 1 from §04 is this.
  • Fan-out on write (push): when Ana posts, a background worker adds the post's ID to the stored feed of each of her N followers. Opening the app is one read of your own stored feed.

With fan-out on read, the reader does the collecting. With fan-out on write, each post is delivered ahead of time into a precomputed feed, usually called the user's timeline. Both are correct; they just put the work in different places.

The cost, as formulas

Let F be how many accounts a reader follows, and N how many followers an author has.

Cost of one postCost of one feed load
Pull1 write (store the post)F lookups + a merge
Push1 + N writes (store it, then one insert per follower)1 read

Now multiply by how often each happens, using the numbers from §03 (F ≈ N ≈ 200 on average):

  • Pull, per second: 23,000 loads × 200 lookups = 4,600,000 lookups/s, all on the path where a user is waiting.
  • Push, per second: 230 posts × 200 followers = 46,000 inserts/s, done in the background where nobody waits.

Push does about 100× less work here, because reads outnumber posts 100 to 1. It also moves the work off the critical path: Bob's feed loads fast even if delivery takes a few seconds, which §02 allowed. That's why most large feeds push by default.

Try it

Pick a strategy, then press the buttons. Ana is a normal account; the star is not. Watch the dots: each one is a write or a lookup.

Fan-out simulator
Each dot is one write or one lookup. Change the follower counts, then fire an event.
Ana200 followersStar50M followersPost store+ fan-out queueBobtimelinefollower 1timelinefollower 2timelinefollower 3timelinefollower 4timelinefollower 5timelinefollower 6timelinefollower 7timelinefollower 8timelinefollower 9timelinefollower 10timelinefollower 11timelinefollowers' timelines (each box stands for many people)
Press a button to see what one event costs with this strategy.
StrategyAna posts
writes
Star posts
writes
Bob opens
reads
Pull11200
Push ◂20150,000,0011
Writes count the post itself plus one timeline insert per follower. The fan-out fleet is assumed to do 1,000,000 inserts/s. Current choice: Push, so Bob's feed costs 1 read.

Notice the star's row in push mode. We'll come back to it in §06.

How push works in practice

Writing the post and delivering it are separate steps. The post API saves the post, publishes a small "post created" message to a Message broker, and answers the user right away. A pool of fan-out workers reads those messages and does the slow part.

The push path
1 post API
save post
2 broker
event
3 worker
who follows ana?
4 timelines
insert id into N timelines
The user waits only for steps 1–2. Steps 3–4 happen in the background; if the workers fall behind, posts arrive late but nothing is lost.

The workers form a Consumer group: several copies of the same program sharing one stream of messages, each message handled by one of them (Queues §06 explains how they split the work). Here's the heart of one worker, with the follow graph and the timeline cache replaced by in-memory maps so it runs on its own:

fanout.go
package main

import "fmt"

const timelineCap = 5 // real systems keep hundreds (say 800); 5 keeps the output short

// In-memory stand-ins: the follow graph, and the timeline cache (Redis in production).
var followers = map[string][]string{
	"ana": {"bob", "cy", "dee"},
	"bob": {"ana"},
}
var timelines = map[string][]int64{} // user → post IDs, newest first

// fanOut runs once per "post created" event taken from the queue.
func fanOut(author string, postID int64) {
	for _, f := range followers[author] { // one write per follower ← the cost of push
		t := append([]int64{postID}, timelines[f]...) // add at the front (newest first)
		if len(t) > timelineCap {
			t = t[:timelineCap] // cap the list: drop the oldest
		}
		timelines[f] = t
	}
}

func main() {
	for id := int64(101); id <= 106; id++ {
		fanOut("ana", id) // ana posts six times
	}
	fanOut("bob", 107)
	fmt.Println("bob's timeline:", timelines["bob"])
	fmt.Println("ana's timeline:", timelines["ana"])
}

Output (Go, then Node):

bob's timeline: [106 105 104 103 102]
ana's timeline: [107]
bob's timeline: [ 106, 105, 104, 103, 102 ]
ana's timeline: [ 107 ]

Bob's timeline holds only the newest 5 of Ana's six posts: post 101 was pushed out the end. Real timelines are capped the same way, just at a few hundred IDs (§07).

Details a real worker needs

  • Followers come in pages. An author with 5,000 followers is read from the follow graph 1,000 at a time, and each batch of inserts goes to the cache in one round trip.
  • Messages can arrive twice. Brokers usually promise At-least-once delivery delivery: after a crash, a message is handed out again. Inserting the same post ID twice must not show the post twice. The usual fix is cheap: the read path's merge skips duplicate IDs (§06), so a repeated insert does no harm. Making a repeated operation harmless is what Idempotent means (Idempotency §08 covers consumers in depth).
  • Skip people who never come back. Pushing into the timeline of someone who hasn't opened the app in a year is wasted work. Many designs only push to recently active users and rebuild a timeline by pulling when an inactive user returns (§10).
Practitioner§06

The celebrity problem and the hybrid

In one sentencePush works because the average author has ~200 followers. A few authors have 50 million. For them, push turns one post into 50 million writes, so we pull their posts at read time instead.

What one celebrity post does to push

From §03: one post by a 50-million-follower account is 50,000,000 timeline inserts. §03 measured that against the system's normal load (46,000 inserts/s): 18 minutes' worth of normal work. A real fleet has headroom above normal, so ask a second question: how long does it take a fleet sized well above normal, say 1,000,000 inserts a second (an assumed round number)? Then:

  • The last follower gets the post 50,000,000 ÷ 1,000,000 = 50 seconds after it was posted, even if the fleet does nothing else.
  • Everyone else's posts wait in the queue behind it. A normal user's post that arrives just after the star's is delayed by those same 50 seconds.
  • Stars post more than once, often at the same moment (a big match, an awards night). Five of them posting in the same minute is 250 million inserts, and 4 minutes of backlog.

On top of that, many of those 50 million followers won't open the app today, so much of that work is wasted. This is the Celebrity (hot-account) problem: a design that's great for the typical account is terrible for the few huge ones.

Pull is cheap for exactly these accounts

Flip the formulas from §05 around. Pulling one celebrity costs each reader one extra lookup of "that account's newest posts". A reader follows only a handful of celebrities, so the extra read cost is small. Push for the same account costs N writes per post, with N in the millions.

So: hybrid fan-out.

  1. Mark accounts above a follower threshold (say 100,000; you tune it by measuring) as "celebrities".
  2. Fan-out workers skip celebrity posts. Normal posts are pushed as before.
  3. When Bob opens his feed, the server reads his pushed timeline, and the recent posts of the few celebrities he follows, and merges them.
Cost of a normal postCost of a celebrity postCost of one feed load
Pull11F lookups
Push1 + N1 + 50,000,0001
Hybrid1 + N11 + C (C = celebrities followed, typically 0–10)

Try all three

Set the star's follower count and how many stars Bob follows. Compare the cost table at the bottom: hybrid keeps every column small.

Fan-out simulator
Each dot is one write or one lookup. Change the follower counts, then fire an event.
Ana200 followersStar50M followersPost store+ fan-out queueBobtimelinefollower 1timelinefollower 2timelinefollower 3timelinefollower 4timelinefollower 5timelinefollower 6timelinefollower 7timelinefollower 8timelinefollower 9timelinefollower 10timelinefollower 11timelinefollowers' timelines (each box stands for many people)
Press a button to see what one event costs with this strategy.
StrategyAna posts
writes
Star posts
writes
Bob opens
reads
Pull11200
Push20150,000,0011
Hybrid ◂20114
Writes count the post itself plus one timeline insert per follower. The fan-out fleet is assumed to do 1,000,000 inserts/s. Current choice: Hybrid, so Bob's feed costs 4 reads.

Merging at read time

Each celebrity's recent posts are a list sorted newest first, and Bob's pushed timeline is one too once it's sorted (more on that below the code). Combining a few sorted lists into one sorted list is a merge: repeatedly take the newest item from the fronts of the lists. Post IDs grow over time (§07), so "newest" simply means "biggest ID".

Watch it on Bob's lists first. A copy of 117 was delivered twice and arrived late, so step 1 sorts his timeline, and you'll also see the duplicate skipped:

Merging newest-first lists
Each step compares the three fronts and takes the biggest ID. Use ← → or Play.
Bob's timeline
117 late copy
120
117
109
104
Pop star
▶ 119
110
Footballer
▶ 118
103
Merged output
empty
Bob's pushed timeline as stored: in arrival order. A copy of 117 was delivered a second time after a worker crash, so it arrived after 120 and sits at the front. The celebrity lists are written in ID order already.
1 / 9
merge.go
package main

import (
	"fmt"
	"slices"
)

// mergeNewest merges lists that are each sorted newest-first (biggest ID first)
// and returns the first `limit` distinct IDs. Fine for a handful of lists; use a heap for many.
func mergeNewest(limit int, lists ...[]int64) []int64 {
	pos := make([]int, len(lists)) // how far we have read into each list
	seen := map[int64]struct{}{}   // IDs already written out
	var out []int64
	for len(out) < limit {
		best := -1
		for i, l := range lists { // find the list whose next ID is the newest
			if pos[i] < len(l) && (best == -1 || l[pos[i]] > lists[best][pos[best]]) {
				best = i
			}
		}
		if best == -1 {
			break // every list is used up
		}
		v := lists[best][pos[best]]
		pos[best]++
		if _, dup := seen[v]; dup {
			continue // already written: skip the duplicate
		}
		seen[v] = struct{}{}
		out = append(out, v)
	}
	return out
}

func main() {
	// Bob's pushed timeline is in ARRIVAL order: a redelivered copy of 117 arrived last, so it's first.
	arrived := []int64{117, 120, 117, 109, 104}
	bobTimeline := slices.Clone(arrived)
	slices.Sort(bobTimeline)     // 104 109 117 117 120
	slices.Reverse(bobTimeline)  // 120 117 117 109 104: now newest-first
	popStar := []int64{119, 110} // author lists are written in ID order already
	footballer := []int64{118, 103}
	fmt.Println(mergeNewest(5, bobTimeline, popStar, footballer))
	fmt.Println(mergeNewest(5, arrived, popStar, footballer), "← without sorting first")
}

Output (Go, then Node):

[120 119 118 117 110]
[119 118 117 120 110] ← without sorting first
[ 120, 119, 118, 117, 110 ]
[ 119, 118, 117, 120, 110 ] ← without sorting first

Two things this code doesn't assume. First, Bob's pushed timeline is only roughly newest-first. LPUSH puts the newest arrival at the front, and arrivals get out of order: fan-out workers run in parallel, and a message redelivered after a crash (§05) arrives late. That's why arrived starts with a late copy of 117. The merge needs sorted input, so the feed service sorts the timeline IDs first, which is cheap for a few hundred numbers. The second output line shows what happens if you skip that: 120 lands in the middle. Second, duplicates are skipped with a seen-set, which works wherever the copies sit. Duplicates come from that late redelivery, and from an account that was just switched from push to pull (see the callout at the end of this section): its older posts are still in followers' timelines and in its pulled list.

Each output item scans every list's front, so this costs about K steps per item for K lists. That's fine for Bob's 1 timeline + 2 stars. With hundreds of lists (pure pull, §04) you'd keep the fronts in a max-heap (a heap whose top is always the largest item), which costs about log K per item. Heaps §09 builds the same merge for lists sorted smallest-first with a min-heap; flip the comparison and it becomes ours.

The celebrity's own list becomes a hot key

Under hybrid, millions of readers ask for the same thing: "the pop star's newest posts". Whichever cache server holds that key gets hammered while the others idle. That's a Hot key / hot spot (Sharding §06). Two common fixes:

  • Copy it widely. Keep several copies of each celebrity's recent-posts list on different cache servers, or a copy in each app server's memory that's refreshed every second or two. A second of staleness is fine (§02).
  • Keep it short. The merge only needs a celebrity's last few dozen posts (say 50), not their whole history, so the hot list stays small. A reader who scrolls deeper than that falls back to reading older posts from the post store, slower but rare.
Practitioner§07

Storing timelines: capped lists of IDs

In one sentenceA timeline stores post IDs, not posts: a capped list of the newest ~800 IDs per user, kept in an in-memory cache. The posts themselves are stored once and looked up when the feed is read.

Why IDs and not copies

If Ana's post were copied in full into 200 timelines and she then fixed a typo, all 200 copies would need updating. An ID is 8 bytes and never changes. Edits, deletes and like counts live in one place, the post store, and every timeline sees them the moment the post is looked up (§08 calls this lookup hydrating).

IDs that sort by time

If bigger IDs always mean newer posts, then "sort by newest" is just "sort by ID", and two sorted lists of IDs can be merged (§06) without fetching any timestamps.

A database counter (1, 2, 3, …) has this property but needs one central counter that every new post must go through. Large systems instead use a Snowflake ID: a 64-bit number whose top bits are the current time in milliseconds, followed by a machine number and a small per-millisecond counter. Any server can create one without asking anyone, and they sort roughly by time. Only roughly: two machines' clocks can disagree by a few milliseconds or more, so two posts made in the same instant on different servers may come out in either order (Clocks explains why). For a feed that's fine. UUIDv7 works the same way at 128 bits. Design a URL Shortener builds an ID generator step by step.

The timeline as a capped list

Redis (a popular in-memory data server) has a list type that fits exactly. A fan-out worker adds a new ID to the front and trims the list to 800 entries; the feed reads a range from the front:

redis-cli
> LPUSH timeline:bob 1860000000000000101
(integer) 1
> LPUSH timeline:bob 1860000000000000102 1860000000000000103
(integer) 3
> LTRIM timeline:bob 0 799
OK
> LRANGE timeline:bob 0 19
1) "1860000000000000103"
2) "1860000000000000102"
3) "1860000000000000101"

Why cap at 800? Almost nobody scrolls back through hundreds of posts, and a hard cap keeps memory predictable: 800 × 8 bytes ≈ 6.4 KB per user, as in §03. If someone does scroll past the end, fall back to pulling older posts from the post store, slower but rare. (800 is the cap Twitter described in its QCon San Francisco 2012 talk "Timelines at Scale", §06; any few hundred works.)

Spreading timelines over many cache servers

About 2 TB of timelines (§03, with overhead) won't fit on one server. Split them by user: hash(userId) % number of shards picks the cache server, so all of Bob's timeline lives on one shard and a feed load touches one server. That's ordinary hash sharding (Sharding §03); use Consistent hashing so adding a server only moves a small share of users (Load Balancing §05).

Two more savings that real designs make:

  • Only active users get a timeline. Someone who hasn't opened the app recently has no cached timeline at all. Workers skip them, and their timeline is rebuilt by pulling when they come back (§10). §03 already sized the cache for the 200 million daily active users only; without this rule you'd pay for every registered account, which is often several times more.
  • The timeline is a cache, not the truth. The real data is in the post store and the follow graph. Any timeline can be thrown away and rebuilt from those two, which is what makes losing a cache server survivable (§10).

Follow and unfollow

When Bob follows Ana, her old posts aren't in his timeline: they were pushed before he followed. A small background job backfills them: it reads Ana's ~20 most recent post IDs (from her author list, below), reads Bob's list, merges the two with the §06 merge, and writes the result back in one pipeline. A Redis list can't insert into the middle cheaply, which is why it's read-merge-write.

When Bob unfollows Ana, her IDs are still in his list. The list holds bare IDs, so finding which ones are Ana's means checking each ID's author, or comparing against Ana's author list. That's real work, so it runs in the background, and meanwhile the read path drops posts from authors Bob no longer follows (§08).

Each author's own list of post IDs

Several pieces of this design need "this author's newest posts": pulling a celebrity (§06), the follow backfill above, and rebuilding a lost timeline by pulling (§10). In version 1 (§04) that was an index on the posts table. But at this scale the posts table is itself split over many database servers, usually by post ID so that hydrating a batch of IDs goes straight to the right servers. Then "Ana's newest posts" would mean asking every server and combining the answers (a scatter-gather), which gets slower as servers are added.

So keep one more small list per author: their own most recent post IDs, newest first, often called the user timeline (as opposed to the home timeline). It's written once per post, by the post API, and stored in the cache sharded by author ID, so author:ana lives on one server. Every "this author's newest posts" question becomes one read of that list, followed by the usual batch hydration by ID.

Practitioner§08

Assembling one page of feed

In one sentenceA feed request is a short pipeline: IDs in, posts out. Read the IDs, merge them (skipping duplicates), look up all the posts in one batch, drop the ones that shouldn't be shown, order them, and return a page plus a cursor for the next one.

Step through one request

Bob opens the app. He follows 2 celebrities, and his pushed timeline holds the IDs fan-out workers put there. Watch each panel change:

Assembling Bob's feed, step by step
Use ← → or Play. The code is simplified; each line is one stage.
Code
1async function getFeed(bob, cursor) {
2 if (cursor) return readSnapshot(cursor); // "load more": next slice of the saved ranking
3 const pushed = await timelines.read(bob);
4 const stars = await follows.celebritiesOf(bob);
5 const pulled = await Promise.all(stars.map((s) => authorList(s)));
6 const ids = mergeNewest(50, sortNewest(pushed), ...pulled); // skips duplicates
7 const posts = await postStore.getMany(ids); // ONE batch call
8 const shown = posts.filter((p) => visibleTo(bob, p));
9 const ranked = rerank(score(shown, bob));
10 const page = ranked.slice(0, 3);
11 return { page, cursor: saveSnapshot(bob, ranked, 3) };
12}
Bob's timeline (cache)
not read yet
Celebrity lists (pulled)
not read yet
Working list
—
Response
—
Bob opens the app. The feed service runs getFeed(bob, null). No cursor, so this is the first page and we build the feed. (With a cursor, line 2 would just return the next slice of a ranking saved earlier, step 9.)
1 / 9

Hydrate in one batch, not one lookup per post

Turning IDs into full posts is called hydrating them: hydration adds the text, author name, like count and media URLs. (Same word as the front-end term for making server HTML interactive, different meaning.)

The tempting way is a loop: for each of 20 IDs, ask the post cache for that post. That's 20 network round trips, one after another, and a textbook N+1 problem. Instead, ask for all 20 at once: Redis MGET post:120 post:119 …, or in SQL SELECT … FROM posts WHERE id = ANY($1) with the list of IDs as one parameter. One round trip, whatever the page size (N+1 §06 shows this fix in detail).

The batch comes back as a map from ID to post, so the code walks the original ID list to keep the timeline's order, and skips IDs that came back empty: those posts were deleted.

Pagination with a cursor

"Load more" must continue exactly where the reader stopped, even though new posts keep arriving at the top. Counting ("skip the first 20") breaks: if 3 new posts arrive while Bob reads page 1, page 2 starts 3 posts too early and he sees them twice.

Instead the server returns a cursor that means "older than post 118". Because IDs sort by time, the next page is simply "the next IDs smaller than 118". New posts at the top don't shift anything. This is Keyset pagination, the same idea as in API Design §07.

page.go
package main

import "fmt"

type Post struct {
	ID   int64
	Text string
}

// The post store. In production: a cache in front of the posts database.
var postsDB = map[int64]Post{
	120: {120, "lunch"}, 119: {119, "new single!"}, 118: {118, "goal!"},
	117: {117, "hello"}, 110: {110, "tour dates"}, // 109 was deleted
	104: {104, "first post"},
}

// fetchMany is ONE round trip for many IDs (Redis MGET, or SQL WHERE id = ANY($1)).
func fetchMany(ids []int64) map[int64]Post {
	found := map[int64]Post{}
	for _, id := range ids {
		if p, ok := postsDB[id]; ok {
			found[id] = p
		}
	}
	return found
}

// feedPage returns one page of posts older than `before` (0 = start from the newest),
// plus the cursor for the next page. ids is the merged timeline, newest first.
func feedPage(ids []int64, before int64, size int) ([]Post, int64) {
	start := 0
	if before != 0 {
		for start < len(ids) && ids[start] >= before { // skip what the reader has seen
			start++
		}
	}
	end := min(start+size, len(ids))
	pageIDs := ids[start:end]

	found := fetchMany(pageIDs) // ← one batch call, not one call per ID
	page := make([]Post, 0, len(pageIDs))
	for _, id := range pageIDs { // keep the timeline's order
		if p, ok := found[id]; ok { // a deleted post is simply missing: skip it
			page = append(page, p)
		}
	}
	var next int64
	if end < len(ids) {
		next = ids[end-1] // "continue after the last ID on this page"
	}
	return page, next
}

func texts(ps []Post) []string {
	out := []string{}
	for _, p := range ps {
		out = append(out, p.Text)
	}
	return out
}

func main() {
	ids := []int64{120, 119, 118, 117, 110, 109, 104}
	var cursor int64 // 0 = first page
	for range 3 {
		var page []Post
		page, cursor = feedPage(ids, cursor, 3)
		fmt.Printf("%q next cursor: %d\n", texts(page), cursor)
	}
}

Output (Go, then Node):

["lunch" "new single!" "goal!"] next cursor: 118
["hello" "tour dates"] next cursor: 109
["first post"] next cursor: 0
[ 'lunch', 'new single!', 'goal!' ] next cursor: 118
[ 'hello', 'tour dates' ] next cursor: 109
[ 'first post' ] next cursor: null

Look at page 2: two posts, not three. Post 109 was deleted, so hydration dropped it. Short pages are normal in feeds. Either accept them (the app just asks for more as the reader scrolls) or over-fetch a few extra IDs and trim to the page size. Notice also that cursor 109 is the ID of the deleted post. That's fine: the cursor is a position in the ID order, not a post that must exist.

The filter step

Between hydration and the response, drop anything Bob shouldn't see:

  • Deleted posts (missing from the batch, or marked deleted, §10).
  • Posts by authors Bob has unfollowed, blocked or muted since the IDs were pushed (§07). The check is against Bob's current follow, block and mute lists, cached per user.
  • Posts that became private or were removed for breaking rules.

Filtering at read time is what makes the eventual consistency from §02 safe: the timeline may be a little out of date, but nothing the reader shouldn't see gets through.

Practitioner§09

Ranking: from newest-first to most-interesting-first

In one sentenceA ranked feed answers "what is Bob most likely to care about?" instead of "what is newest?". The engineering shape is always the same three stages: collect candidates → score each one → re-rank the top for variety.

Why rank at all

Bob follows 300 accounts and opens the app twice a day. Between visits, those accounts post 150 times. Newest-first shows him the last 20 and buries his sister's baby photo from this morning under 20 posts from a news account that posts every 10 minutes. Ranking fixes that by putting the posts he's most likely to care about first.

Stage 1: candidate generation

Candidate generation cheaply collects a few hundred posts that might be worth showing: the newest few hundred IDs from Bob's timeline plus the celebrities he follows (exactly the merged list from §06 and §08), maybe a few popular posts from accounts his friends follow. This step must be fast and generous. Anything it misses can never be shown.

Stage 2: scoring

Hydrate the candidates (§08) and give each one a number. Real systems learn the formula from data with machine learning; we'll skip how that's trained and just use a hand-written formula with three ingredients:

  • Recency: newer is better, fading over hours.
  • Affinity: how close Bob is to the author, from how often he likes, comments on or visits their posts. Computed in the background and stored per (reader, author) pair.
  • Engagement: how much everyone else is reacting. A comment usually counts more than a like.
rank.go
package main

import (
	"fmt"
	"math"
)

type Candidate struct {
	ID       int64
	AgeHours float64 // how old the post is
	Affinity float64 // 0..1: how much this reader interacts with the author
	Likes    int
	Comments int
}

type Weights struct{ Recency, Affinity, Engagement float64 }

func score(c Candidate, w Weights) float64 {
	recency := 1 / (1 + c.AgeHours/6)                             // 1.0 now, 0.5 at 6 h, 0.2 at 24 h
	engagement := math.Log10(1+float64(c.Likes+3*c.Comments)) / 4 // ~0..1; 10,000 → 1.0
	return w.Recency*recency + w.Affinity*c.Affinity + w.Engagement*engagement
}

func main() {
	w := Weights{Recency: 1, Affinity: 1, Engagement: 1}
	for _, c := range []Candidate{
		{ID: 1, AgeHours: 0.5, Affinity: 0.1, Likes: 3},                  // brand new, from an acquaintance
		{ID: 2, AgeHours: 5, Affinity: 0.9, Likes: 12, Comments: 4},      // your best friend
		{ID: 3, AgeHours: 20, Affinity: 0.2, Likes: 9000, Comments: 900}, // viral, but old
	} {
		fmt.Printf("post %d: %.2f\n", c.ID, score(c, w))
	}
}

Output (Go and Node print the same):

post 1: 1.17
post 2: 1.79
post 3: 1.45

Your best friend's 5-hour-old post (2) wins, the viral post (3) comes second despite being 20 hours old, and the brand-new post from an acquaintance (1) comes last.

Stage 3: re-rank for variety

Pure scores often put four posts from the same close friend in a row. The last stage adjusts the top of the list with simple rules: no author twice in a row, mix in a video between text posts, don't show the same link twice. It's a quick pass over the 20–50 best posts, not a re-score.

Try it

Move the weights and watch the feed reorder. Then turn on "no author twice in a row" and see what moves.

Ranking playground
score = w₁·recency + w₂·affinity + w₃·engagement, with the formulas from the code above.
#1
Mia · Brunch photo
1 h old · affinity 0.9 · 8 likes · 2 comments
2.05
#3
Mia · Gym check-in
2 h old · affinity 0.9 · 3 likes · 0 comments
1.80
#2
Mia · Sunset from the roof
4 h old · affinity 0.9 · 15 likes · 3 comments
1.85
#5
Tech Daily · Chip launch: live blog
18 min old · affinity 0.15 · 400 likes · 60 comments
1.79
#4
Pop star · Tour dates announced
6 h old · affinity 0.3 · 7,000 likes · 900 comments
1.80
#6
Brother · Baby's first steps
9 h old · affinity 0.85 · 40 likes · 15 comments
1.73
#8
Old classmate · Got a new job!
20 h old · affinity 0.25 · 300 likes · 80 comments
1.16
#7
Neighbour · Lost cat, please share
12 min old · affinity 0.1 · 2 likes · 1 comments
1.26
Try Newest first: the neighbour's lost cat and the live blog lead. Friends first fills the top with Mia, three posts in a row; turn on no author twice in a row to break that up. Posts marked moved ↑ were pulled forward past a post by the same author as the one above them. If only one author's posts are left at the end, repeats can't be avoided. The bar shows how much each ingredient adds to the score.

Why ranking makes caching harder

A newest-first timeline is the same list for every request until a new post arrives, so it caches well and pages cleanly with the "older than ID 118" cursor from §08. A ranked feed is different:

  • Scores change on their own. Recency decays every minute and engagement grows as people react, so yesterday's ranking is wrong today even if no one posted.
  • It's personal. Affinity is per reader, so there's no shared answer to cache across users; a ranked feed costs a scoring pass per user per visit.
  • "Older than ID 118" no longer means "the next page". A ranked list isn't sorted by ID. The usual fix is to rank once when Bob opens the app, store that ranked list of IDs for a few minutes under a session ID, and make the cursor mean "session abc, position 20". Pull-to-refresh starts a new ranking.

That's why the stored timeline stays chronological: it's the cheap, cacheable candidate source. Ranking happens on top of it, at read time, on a few hundred candidates.

Staff-level§10

When things fail

In one sentenceEvery piece of a feed system will fail at some point. The design is good if each failure makes the feed late or slightly incomplete, never wrong and never an error page.

Pick a failure and see what users notice, how the system recovers, and which number on the dashboard tells you it's happening:

Failure toggles
Pick a failure, see what users notice, then apply the design's fix.
Post API→Queue→Fan-out workers→Timeline cache→Feed API→Post store
fan-out lag (seconds)no fix
failure starts
What users see
Every new post, from anyone, shows up minutes late. Nothing looks broken; feeds just feel stale.
The fix
Split huge fan-outs into chunks of ~10,000 followers; separate fast and bulk lanes; add workers when lag in seconds rises; auto-flag the account as pull (§06).

1. A fan-out backlog

A celebrity who isn't flagged yet (say, someone who went viral this morning) posts, and 20 million inserts land in the fan-out queue. Every post behind it waits. Users see nothing broken; they just see new posts minutes late.

How to spot it: Consumer lag, measured in seconds (how old is the oldest message nobody has handled yet?), not just in message count. A queue holding 10 million inserts is fine if workers clear it in about 10 seconds (at the 1,000,000 inserts/s fleet from §06); a queue of 10,000 that hasn't moved for a minute is not.

How to fix and prevent it:

  • Split big fan-outs. One "deliver post 9001 to 20 million followers" message becomes 2,000 messages of 10,000 followers each, so every worker in the Consumer group can help.
  • Two lanes. Posts from authors with few followers go into a fast queue; huge fan-outs go into a bulk queue. A normal user's post never waits behind a celebrity's.
  • Scale workers on lag. Add workers when lag in seconds passes a threshold, and remove them when it drops (Worker Pools covers sizing and backpressure).
  • Flag hot accounts automatically (§06) when their follower count or fan-out time crosses the line.

2. A deleted or edited post

Bob's timeline holds IDs, so an edit shows up everywhere the moment the post is next hydrated. Only the cached copy of the post itself must be updated or dropped (Caching §05).

A delete is harder: the ID is in 200 timelines (or 50 million). Removing it from all of them is another fan-out. Instead, mark the post as deleted in the post store, a Tombstone, and let hydration drop it (§08). A cleanup job may remove the ID from timelines later, or not at all: it falls off the end of the 800-entry cap eventually. For legal removals that must happen fast, the tombstone is what makes it immediate: the post stops being served everywhere at once, because every read goes through the post store.

3. An unfollow

Bob unfollows Ana; her IDs are still in his timeline. The read-path filter (§08) checks Bob's current follow list and hides them right away, and a background job removes them from his list. This is the general pattern: the timeline may be stale; the read path enforces the current truth.

4. A timeline cache server dies

With 50 cache shards, losing one wipes the timelines of about 1 in 50 users. Nothing is lost for good: timelines are a cache built from the post store and the follow graph (§07). On a miss, the feed service rebuilds that user's timeline the slow way, by pulling from the author lists of the accounts Bob follows (§07), writes it back, and serves it. Users on that shard see one slow feed load, then normal service.

Two traps:

  • A stampede. A million users miss at once, and a million pull-rebuilds hit the post store together. That's a Cache stampede (Caching §07). Limit how many rebuilds run at once (a worker pool), merge identical rebuilds for the same user with Request coalescing, and serve a degraded feed (just the celebrity posts, or a "popular now" list) to whoever is waiting.
  • The half-filled list. While Bob's timeline is missing, a fan-out worker pushes a new ID for him. LPUSH creates a brand-new list with one entry, and the next feed load sees a timeline that exists, so it never rebuilds. Bob's feed now shows one post. Workers should use LPUSHX, which only adds to a list that already exists, leaving the rebuild to create it. One small gap remains: a post fanned out while the rebuild is running is dropped by LPUSHX and isn't in the rebuild's pull either. So the rebuild re-reads the newest few posts of the accounts Bob follows right after writing the list, and merges in anything it missed.

5. Slow post store

If hydration is slow, the whole feed is slow (§08 hydrates every page). Give the batch lookup a strict Timeout, return whatever posts came back in time, and let the app fetch the rest on scroll. Degrade gracefully: a feed with 15 posts instead of 20 is a non-event; a spinner is not. (Reliability & SRE §08 covers mitigating first during an incident.)

Staff-level§11

What to monitor, and the trade-offs that remain

In one sentenceWatch the two things users feel, how fast the feed loads and how late new posts arrive, plus the few internal numbers that warn you before either one goes bad.

The dashboard

MetricWhat it tells youAlert when (example)
Feed load latency, p99What the slowest 1% of users waitAbove 300 ms for 5 minutes
Fan-out lag, in secondsHow late new posts reach followersOldest unhandled message older than 30 s
Timeline cache Hit rateHow often a feed load finds a ready timelineDrops suddenly (a shard died, §10)
Timeline rebuilds per secondHow much slow pull work is happeningJumps 10× above normal
Hydration: missing or timed-out postsPost store or post cache troubleAbove 1% of IDs per page
Fan-out inserts per second, by laneLoad on the workers; spots unflagged celebritiesOne author causing a big share of all inserts
Feed errors (5xx)Outright failuresAny sustained rise

The first two are the ones to promise as SLOs ("99% of feed loads under 300 ms"; "99% of posts delivered to followers within 10 s"). The rest are early warnings. Observability §09 shows how to turn an SLO into an alert that doesn't fire on every blip.

See your own post immediately

§02 allowed followers to see a post a few seconds late. But if you post and your post isn't on your own feed when the app refreshes, it looks like posting failed, and you post again. The common fix: on every feed load, also merge in your own last few posts, read straight from the post store, just like a celebrity's list in §06. One small extra lookup gives every user read-your-own-writes for the one case where they'd notice (Replication §07 covers the same guarantee for database replicas).

The trade-offs, in one table

ChoiceYou gainYou pay
Push for normal accountsFeed loads are one cheap read~46,000 inserts/s; ~2 TB of RAM for timelines
Pull for celebritiesNo 50-million-write postsA few extra lookups per feed load; a hot key to copy widely
IDs in timelines, hydrate on readEdits and deletes apply everywhere at onceA batch lookup on every page
Capped timelines (800)Predictable memoryDeep scrolling falls back to slow pulls
Eventual consistencyDelivery can be slow without slowing readsPosts arrive seconds (or minutes, in a backlog) late
RankingA better feedHarder caching, per-user compute, cursor snapshots
Practitioner§12

Check yourself

Work out the answer before you click. Q1 is §05, Q2 §06, Q3 §07, Q4 §08, Q5 §10, Q6 §09.

QUESTION 1 / 6
Which costs less?

An app gets 1,000 posts/s and 100,000 feed loads/s. Users have about 300 followers and follow about 300 accounts. Compare pull and push.

QUESTION 2 / 6
Hybrid read cost

Bob follows 400 accounts. 3 of them are above the celebrity threshold. Under hybrid fan-out, how many lookups build the candidate list for his first page?

QUESTION 3 / 6
After a cache restart

A timeline cache shard restarts empty. Before Bob opens the app, a fan-out worker runs LPUSH timeline:bob 9005. The feed service rebuilds a timeline only when the key is missing. What does Bob see?

QUESTION 4 / 6
Load more

Bob has seen page 1: posts 120, 119, 118. Three new posts (121, 122, 123) arrive. He taps “load more”. Compare OFFSET 3 with the cursor “older than 118”.

QUESTION 5 / 6
Deleting a popular post

An author with 2 million followers deletes a post. What's the cheapest way to hide it from every feed right away?

QUESTION 6 / 6
Why keep the timeline chronological?

The app shows a ranked feed. Why is the stored timeline still a newest-first list of IDs?

Answered 0 / 6
Foundations§13

Cheat sheet

📥
Pull = fan-out on readPost: 1 write. Feed load: F lookups + merge. Fine for small apps and for celebrity accounts.
📤
Push = fan-out on writePost: 1 + N writes, in the background via a queue. Feed load: 1 read. Wins when reads ≫ writes (~100:1).
⭐
Hybrid for celebritiesAbove a follower threshold, skip fan-out; merge their recent posts at read time. Feed load: 1 + C lookups.
🧾
Timelines hold IDsCapped list of ~800 time-sortable IDs per active user, sharded by user. A cache: rebuildable by pulling.
💧
Hydrate in one batchMGET / WHERE id = ANY($1). Keep the ID order; skip missing (deleted) posts.
🔖
Cursor, not offset"Older than ID 118". Ranked feeds: cursor = ranked snapshot + position.
🎯
Rank in three stagesCandidates → score (recency, affinity, engagement) → re-rank for variety. Fall back to chronological.
🩺
Watch two numbersFeed p99 latency and fan-out lag in seconds. Then hit rate, rebuilds, hydration misses.
SituationDo this
A post must reach followersSave it, publish "post created", let fan-out workers insert the ID into each active follower's timeline
An account has millions of followersMark it as pull; readers merge its recent posts; copy its hot list to several cache servers
A user deletes a postTombstone in the post store; hydration hides it everywhere at once
A user unfollowsFilter on read now, remove the IDs in the background
A timeline shard diesRebuild on miss by pulling; limit concurrent rebuilds; workers use LPUSHX
Fan-out falls behindSplit big fan-outs into chunks, separate fast and bulk lanes, scale workers on lag in seconds
The author refreshes after postingMerge their own latest posts into their feed (read-your-own-writes)
FormulaPullPushHybrid
Writes per post11 + N1 + N (normal), 1 (celebrity)
Reads per feed loadF11 + C
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in System DesignDesign a Notification System
Read →