Part 2 — Read-Heavy Feed & Fan-out
Chapter 16

Design Twitter / News Feed

Our first real interview. We'll run it like one: clarify the prompt with the interviewer, converge on requirements, do the actual math, then design — starting from the obvious approach and letting each failure push us to the next idea. By the end you'll have rebuilt Twitter's timeline the way it's really discovered, not memorized a diagram.

The prompt — and don't panic

The interviewer says: “Design Twitter.” Two words, a blank whiteboard, forty-five minutes. The instinct is to start drawing boxes. Resist it. The first thing a strong candidate does is recognize the shape of the problem and set a plan. This is a read-heavy feed archetype, so out loud: “Let me clarify the scope first, then size it, then design toward the read path — I expect the interesting decision to be how we assemble each timeline.” You've just told the interviewer you've seen this shape before.

Clarify before you build

You never design the system in your head — you design the one the interviewer actually has in mind. So ask. Each question below narrows the problem; click to see how a typical interviewer answers and what that pins down. Notice these aren't trivia — every answer removes a whole branch of work.

InterviewerYes. Focus on posting a tweet, following users, and viewing the home timeline (tweets from people you follow). Skip search, DMs, trends, and ads.
→ pins: Scope = post · follow · home timeline

Converge on the requirements

Now turn that conversation into an explicit spec, said back to the interviewer so you both agree before any design. Separating functional from non-functional matters: the functional list bounds what you build, and the non-functional list is what you'll keep returning to in the deep dive.

The spec we agreed on
Functional — in scope
Post a tweet (text + optional media)
Follow / unfollow a user
View the home timeline — tweets from people you follow, newest first
Infinite scroll (paginate older tweets)
Out of scope (for now)
Search & trends
Direct messages
Ads & ranking ML
Notifications
Non-functional
Scale
200M DAU
Read : write
~100 : 1
Feed latency
< 200 ms
Consistency
Eventual (stale OK)
Availability
High (99.9%+)
⇄ The most important requirement is a trade-off we chose on purpose
“Eventual consistency is fine” is not a detail — it's a deliberate choice of availability over consistency. By accepting that a freshly posted tweet may take a few seconds to appear in followers' feeds, we earn the right to cache aggressively and do feed updates asynchronously. If the interviewer had said “users must see tweets the instant they're posted,” the entire design below would change. Always make this trade explicit.

Size it with real numbers

Now do the math out loud — but only the numbers that change a decision. The calculator below starts from the assumptions we pinned. Each line shows the actual arithmetic and ends in a verdict. Try editing the assumptions: notice that nothing changes the headline conclusion — this is a read-dominated system.

Estimation — edit any assumption, the rounded math updates live
Feed reads per second≈ 20K/s
200M × 10 ≈ 2.0B/day ÷ 100K s
→ peak ≈ 60K/s — this is the hot path, optimize it
Tweet writes per second≈ 1.0K/s
200M × 0.5 ≈ 100M/day ÷ 100K s
→ well under a single primary's ~10–50K/s ceiling — writes are not the problem
Read : write ratio≈ 20 : 1
20K/s ÷ 1.0K/s ≈ 10 ÷ 0.5
→ reads dominate → spend the complexity budget on reads
Feed read bandwidth (egress)≈ 400 MB/s
20K/s × ~20 tweets × 1 KB
→ text alone is heavy, and media is far bigger → serve feeds from cache, push media to a CDN
Feed cache size (RAM)≈ 3 TB
200M users × ~800 ids × 16 B
→ terabytes of RAM → the feed cache can't live on one Redis node, it must be sharded by user
Tweet storage / year≈ 39 TB/yr
100M tweets/day × 1 KB × ~400 days
→ 195 TB over 5 yr → the tweet store must be sharded

The data, and the one request that matters

With the scale in hand, name the data. It's small. There are users; there are tweets, each with one author and a timestamp; and there are follow edges — directed, from a follower to the followed. Notice what's missing: the timeline itself. A timeline isn't stored, it's derived from tweets plus the follow graph — and how we derive it is the whole problem.

The API is just as small: post a tweet, follow a user, and — the call the numbers above told us runs tens of thousands of times a second — GET /feed. Everything we build exists to make that one read fast. Writing the contracts out keeps us honest about what each call carries — note the feed comes back a page at a time (you can't hand someone a million tweets at once), and media isn't crammed into the tweet itself, just linked.

API design — the contract every client codes against
POST/tweets🔒 auth
Post a tweet. Media is uploaded separately and only linked, so the row stays tiny.
Request
{ "text": "hello",
  "media_id": "m_92f1" }
Response
201 { "id": "tw_8a31",
  "created_at": "…" }
returns fast — fan-out to followers happens asynchronously after this responds
POST/follows🔒 auth
Follow a user — adds one edge to the social graph.
Request
{ "followee_id": "u_alice" }
Response
200 { "ok": true }
GET/feed?limit=20&after=:cursor🔒 authHOT PATH
The home timeline — the call that runs ~20K times/s. Cursor-paginated for a live feed.
Response
200 { "tweets": [ {id, author,
  text, media_url, created_at} ],
  "next": "tw_77" }
after = last tweet the client saw (a cursor, not an offset); served O(1) from the precomputed feed cache

Start with the obvious design

Before reaching for anything clever, build the simplest thing that could possibly work — a single relational database with three tables. This is where you should always start: it's correct, it's easy to reason about, and it shows the interviewer you don't over-engineer.

We need somewhere to keep users, somewhere to keep tweets, and somewhere to record who follows whom. That last one — the follow graph — is just a table of edges: one row per “follower → followee” relationship. Here's the schema and how the tables relate:

The schema — three tables, two foreign keys into users
users
PKiduuid
handletext
nametext
created_atts
tweets
PKidsnowflake
FKauthor_iduuid
texttext
media_urltext
created_atts
follows
PKFKfollower_iduuid
PKFKfollowee_iduuid
created_atts
💡
Tip

Why a relational database for this? Users, tweets, and follows are highly structured and joined by IDs — exactly the shape SQL is built for. We get joins to walk the follow graph and secondary indexes (like one on (author_id, created_at), to find an author's recent tweets without scanning) for free. For a system whose data fits this neatly and whose correctness matters, a relational store is the obvious, boring, correct starting point.

Now, how do we build Bob's timeline? The natural query: find everyone Bob follows (a lookup in follows), grab their recent tweets (a lookup in tweets by author_id), sort by time, take the top page. In one statement:

sql
-- Bob's home timeline = recent tweets from everyone Bob follows
SELECT t.*
FROM   tweets t
WHERE  t.author_id IN (SELECT followee_id FROM follows WHERE follower_id = 'bob')
ORDER  BY t.created_at DESC
LIMIT  20;

That's it. It's correct, and at a few hundred users it returns in a millisecond. This is the approach we'll call “build on read” — the timeline is assembled fresh, on the read, every single time. The diagram below traces what that one query actually makes the server do: fan out to everyone Bob follows, fetch their tweets, then merge and sort.

Build on read — the work the server repeats on every single feed open
Build on READ — the Feed Service assembles the timeline fresh on every open
📱 Bobopens feedFeed Servicefetch → merge → sortAliceCarolDaveEveFrankGET /feed
⚠ Every feed open fires N fetches plus a big sort — and this runs ~20,000 times a second. The most expensive work sits on the most common path.

…and watch exactly where it breaks

That one query hides a lot of work. Let's trace what it costs at our numbers — ~20,000 feed reads per second, users who follow hundreds or thousands of accounts, and a tweets table growing by 100 million rows a day. Four separate things break, and each one maps to a tool from Part 1.

The one feed query fans out into four separate failures
One innocent query → four separate walls, each answered by a tool we already learned
One feed readSELECT … WHERE author_id IN (follows) ORDER BY created_at① Full-table scan on author_id→ add index (author_id, created_at) · Ch05② 1,000 fetches + a big merge-sort, per openthe fan-out-on-read cost · ×20K / sec③ ~20K heavy reads/sec crush one DB→ Cache + Read Replicas · Ch03 / Ch04④ ~40 TB/year outgrows one machine→ shard the tweets table · Ch09
⚠ Index, cache, replicas and sharding each help — but none removes the core problem: we rebuild every timeline from scratch on the most common path.

1. The tweets table is huge → it needs an index. Without an index on author_id, finding one author's tweets means scanning the entire tweets table — a full table scan over billions of rows (this is exactly the pain from the Indexes chapter). We'd add an index on (author_id, created_at) so the database jumps straight to an author's recent tweets instead of scanning. That's the first fix — and it's mandatory — but it isn't enough.

2. Each feed read does hundreds of index lookups + a big sort. Even with the index, a user who follows 1,000 people forces the database to pull recent tweets from 1,000 authors and merge-sort them to find the latest 20. That's a lot of work for one feed open — and it happens ~20,000 times a second. This is the “fan-out on read” cost the diagram above traced.

3. The read volume crushes one database → cache + replicas. ~20,000 heavy reads per second is far past what a single primary can serve. From the Caching and Read Replicas chapters, the instinct is right: put Redis in front and add read replicas. And we will — but notice the catch: the timeline is different for every user and changes every second, so there's no single hot value to cache like there was for a popular tweet. Caching the raw tweets helps a little; it doesn't make the expensive per-user merge go away.

4. The data outgrows one machine → sharding. At ~40 TB/year the tweets table must be sharded (the Database Sharding chapter). But sharding makes the timeline query worse: the 1,000 authors Bob follows are now scattered across many shards, so one feed read becomes a scatter-gather across shards plus a merge. The naive design fights sharding instead of working with it.

⚠ The expensive work is on the most common path
Indexes, cache, and replicas all help, but none of them remove the core problem: we rebuild each timeline from scratch on every read — fan out to every followee, fetch, merge, sort — and reads happen ~100× more than writes. We've put the most expensive operation on the most frequent path. No amount of bolting on Part 1 components fixes a design that's doing the wrong work at the wrong time. We need to change when the work happens.

The insight: do the work once, ahead of time

Here's the mental shift. We're recomputing each timeline from scratch on every read, even though it barely changed since the last read. What if, instead, each user simply had their timeline already built — sitting in a fast cache, ready to hand back the instant they ask? Then a feed read is just one lookup. No querying a thousand people. No merging. One read.

But a prepared timeline has to be filled in somehow. The trick is to do it at write time. When Alice posts a tweet, we immediately push a copy of it into the prepared timeline of every one of her followers. By the time Bob opens the app, the tweet is already waiting in his feed. We've moved the work from the read (which happens constantly) to the write (which is rare). Given our hundred-to-one ratio, that's a fantastic trade.

This is fan-out on write. The diagram below shows the swap, top to bottom: when Alice posts, the tweet is copied into each follower's feed at write time — so when Bob later opens the app, his read is a single instant lookup. The cost moved sides.

Build on write — pay once at write time, and the read becomes a single lookup
On WRITE — Alice posts once; a worker looks up her followers and copies the tweet into each prebuilt feed
👩 Aliceposts 1 tweetFan-out Workercopies tweet to feedsFollowers DBwho follows AliceAlice’s feedCarol’s feedDave’s feedEve’s feedFrank’s feed
✓ The copying happens once, at write time — and writes are ~100× rarer than reads, so we gladly pay here.
On READ — Bob’s feed is already built, just waiting for him
📱 Bobopens feedBob’s prebuilt feedready to hand back
✓ No fan-out, no sort — a single O(1) read. The cost moved off the common path entirely.
✓ Fan-out on write: precompute every feed
On each tweet, push it into all of the author's followers' precomputed feeds (held in a cache). Reads become O(1) — fetch your ready-made timeline. We willingly pay more at write time, because writes are rare and reads are everything.
⇄ What fan-out on write costs you
Nothing is free — we bought fast reads with three costs. Write amplification: one tweet now triggers hundreds of feed writes. Storage: each follower's feed grows by an entry per incoming tweet, multiplied across all followers, so feed storage balloons. (We keep this in check two ways: a feed stores only tweet IDs plus a sort score — not the tweet body, which lives once in the tweet store and is hydrated at read time — and each feed is capped to the most recent ~800 entries.) Staleness: fan-out runs asynchronously, so a tweet appears a second or two later — which is exactly the consistency trade we agreed to in the requirements. The win is only worth it because reads outnumber writes 100:1.

The new wall: what about celebrities?

Fan-out on write feels like a clean win — until you think about who's tweeting. For an ordinary user with a few hundred followers, pushing a tweet into a few hundred feeds is nothing. But what happens when someone with a hundred million followers tweets? That single tweet now becomes a hundred million writes — the diagram below shows that explosion.

When the author is a celebrity — one tweet becomes a hundred million writes
The catch — when the author has 100,000,000 followers
🌟 Celebrityposts 1 tweet100,000,000 feedsone tweet → 100M writes 💥
⚠ One tweet becomes a hundred million writes — a storm that can take minutes to drain and can swamp the cache while followers wait.
⚠ One celebrity tweet = a hundred million writes
Pure fan-out on write can't survive the long tail of mega-accounts. A single post from a celebrity triggers an avalanche of writes that can take minutes to drain and can swamp the cache — while their followers wait to see the tweet they're refreshing for.

So neither pure approach works. Build-on-read is too slow for everyone. Build-on-write is perfect for normal users but catastrophic for celebrities. When two strategies each fail at opposite ends, the answer is usually to use both — each where it's strong.

✓ The hybrid: push for the many, pull for the famous few
Fan out on write for ordinary accounts, so the vast majority of reads stay O(1). For the handful of celebrity accounts, skip fan-out entirely — store their tweets once. When a user opens their feed, read their precomputed timeline and then pull-merge the latest tweets from the few celebrities they follow. The common case stays instant; the explosive case is avoided. This hybrid is what Twitter actually runs.
⇄ The hybrid buys safety with complexity
We solved the write storm, but now the read path has two code paths (precomputed feed + live celebrity merge), and we need a rule for who counts as a “celebrity” (say, >100K followers) and logic to switch modes. That's real complexity and a potential source of bugs. The trade is worth it — there's no single strategy that handles both a normal user and a 100M-follower account — but name it: “I'm accepting extra read-path complexity to bound write amplification.”

Putting the high-level design together

Now the architecture almost draws itself — each box exists to serve a decision we reasoned our way to, and every store is something we already taught, not a new black box:

  • Tweet Store — the source of truth for tweets. Indexed on (author_id, created_at) from the Indexes chapter, sharded by user from the Sharding chapter.
  • Fan-out Queue — the write returns the instant the tweet is saved; the slow copying runs later, off the queue, so the user never waits for it.
  • Social Graph — a separate store for “who follows whom.” It's a different access pattern than “a user's tweets,” so it gets its own home.
  • Feed Cache — the precomputed per-user timelines. Just the Redis from the Caching chapter; workers push each new tweet in here.
  • Feed read — one O(1) lookup. The feed service hands back the ready-made timeline; the per-user merge never touches the database.
  • Dropped: read replicas for timeline assembly — the Tweet Store still keeps replicas for body hydration and celebrity pulls, but the timeline itself comes from cache.

Notice the shape: reads and writes have split into two independent paths that meet only at the Feed Cache — the write path fills it asynchronously, the read path drains it instantly.

Architecture after this chapter — how you'd draw it on the whiteboard
HTTPPOST tweetGET feedenqueueconsumeget followerspush to feedsO(1) readstore tweetcelebrity pullUsers🛡API Gateway🖥Tweet Servicewrite path🖥Feed Serviceread pathFan-out QueueNEWFan-out Workers×3NEW🗄Social Graphwho follows whomFeed Cache (Redis)per-user timelinesNEW🗄Tweet Storesharded · indexed×4
Three distinct stores, each a tool we already learned: the Tweet Store holds the source-of-truth tweets — indexed on (author_id, created_at) from Ch05 and sharded by user from Ch09; the Social Graph holds the follow edges the workers read to know whose feeds to copy into; the Feed Cache is the Redis from Ch03, holding each user's precomputed timeline. The queue + workers (Ch08) make fan-out asynchronous so tweeting stays fast. Note what's missing: because reads hit the precomputed cache, we no longer lean on read replicas for the timeline — fan-out-on-write replaced that load.
~100:1reads to writes — why we precompute
O(1)feed read after fan-out on write
100Mwrites from one celebrity tweet (pure push)
Hybridpush for most + pull for celebrities

Paging the feed as the user scrolls

A feed is effectively infinite, so we never hand back the whole thing — we send it a page at a time: the top twenty tweets, then the next twenty as the user scrolls. The only open question is how the server knows where to continue from.

In a normal app you'd reach for offset / limit — page three is LIMIT 20 OFFSET 40 (“skip 40, then give me 20”). It's the standard way to paginate, and for most APIs — a settings list, an admin table, ordinary search — it's completely fine. On a live, infinite feed it quietly falls apart, for two reasons:

  • It gets slower the deeper you scroll. OFFSET 40 doesn't skip 40 rows — the database reads them and throws them away. Page 10 reads 200 rows to return 20; page 1,000 reads ~20,000 to return the same 20. Your most engaged users scroll deepest, so they get the slowest feed.
  • New tweets make it wrong. OFFSET counts positions, but on a live feed positions keep shifting. If three tweets land at the top while you're reading page 1, page 2 (OFFSET 20) now points at rows you already saw — duplicates. Deletes shift the other way and silently skip tweets.

The fix is to page by landmark instead of by position — a cursor. The client remembers the ID of the last tweet it saw and asks for “twenty tweets older than that”: WHERE id < :last_seen_id ORDER BY id DESC LIMIT 20. Because tweet IDs are time-sortable (Snowflake-style, see Ch23) and indexed, the database jumps straight to that ID and reads exactly twenty rows — page one and page ten-thousand cost the same. And since you continue from a specific tweet rather than a row count, tweets arriving at the top can't shift your place, so the duplicates go away too.

⇄ What a cursor gives up
You can only move forward or back from where you are — you can't jump to an arbitrary page (“page 500”), because there's no offset to jump to. A feed never needs that, so it's the right call; for search results, where users do jump around, offset/limit is still the better fit.

One thing that trips people up: the feed you scroll isn't one list — it's two sources merged, the prebuilt cache plus a live celebrity pull. The merged result is never stored anywhere; it's rebuilt on every request. So what makes it paginate? The same cursor is simply re-applied to both sources — it works because both are sorted by tweet ID. Here it is page by page:

Paginating the merge — one cursor, re-applied to both sources
Feed cache · feed:u_bob
prebuilt from ordinary follows
#100#98#95#90#88
ZREVRANGEBYSCORE feed:u_bob (cursor -inf LIMIT 3
Celebrity pull · Alice
read live from the Tweet Store
#99#70
SELECT id … WHERE author_id='alice' AND id < cursor LIMIT 3
▼  merge by id, keep the newest 3  ·  the last id returned becomes the next page's cursor
Page 1after = ∞
ask each source for id < ∞, newest first, limit 3 → merge
returns#100#99#98
→ next cursor = 98 (oldest id returned)
#95 was read from the cache but lost the merge to #98 — it comes back on Page 2. Nothing is wasted, just re-fetched.
Page 2after = 98
ask each source for id < 98, newest first, limit 3 → merge
returns#95#90#88
→ next cursor = 88 (oldest id returned)
Alice's #70 is older than the cursor, so it merges in on a later page. No celebrity tweet here — that's normal, they're sparse.
from feed cache from celebrity pullthe cursor is the whole state — the client holds it, the server stores nothing

Two finishing touches: where media lives, and splitting the data

The core design is done. Two practical loose ends remain — and you've met both tools already, so this is about where to apply them, not learning anything new.

1. Images and video don't belong in the database. A tweet row is tiny text; a video is megabytes. Putting that media in the database would bloat every row and slow every read. Instead, store the file in blob storage (a service built for large files, like Amazon S3) and keep only a short link to it in the tweet row. Put a CDN in front so the file is cached close to each user and downloads fast. The database stays small and quick; the heavy bytes are served elsewhere.

2. One machine can't hold everyone's data. Past a certain size, no single machine has the storage or the throughput — this applies to both the Tweet Store and the Feed Cache. So we shard each of them — split the data across many machines — and the key choice is how we split it. We split by user ID, so all of one person's data (their tweets in the store, their precomputed feed in the cache) lands on the same shard. The common request is still served from the cache, exactly as before — sharding just means that lookup goes to the one cache shard holding this user's feed, instead of every machine having to be consulted. The fast O(1) read stays fast as the data grows.

sql
-- ✅ HOT PATH — read my prebuilt feed   (key = user_id → one cache shard)
ZREVRANGE feed:u_bob 0 19            -- Redis · single-shard · O(1)

-- ✅ one author's own tweets          (key = author_id → one store shard)
SELECT id, text FROM tweets
WHERE  author_id = 'u_alice'
ORDER  BY created_at DESC LIMIT 20;  -- single-shard · uses the (author_id, created_at) index

-- ⚠ CROSS-SHARD — merge many authors live (the celebrity pull, or analytics)
SELECT id, text FROM tweets
WHERE  author_id IN (:followee_ids)  -- hundreds of ids → touches many shards
ORDER  BY created_at DESC LIMIT 20;  -- scatter-gather + merge · the expensive path
⇄ Sharding by user ID — and what it costs
Sharding by user ID keeps one person's tweets and feed together, so the common reads hit a single shard. The cost surfaces on queries that cross users — for example, building a feed that merges many authors, or analytics across everyone, now has to fan out across shards and merge. We accept that because our hot path (one user's feed) stays single-shard; if the dominant query had been cross-user, we'd have picked a different shard key. The shard key is a bet on your access pattern.
Key Insight

Step back and see what just happened. We didn't invent new machinery — we used caching, queues, workers, sharding, and a CDN, all from Part 1. What this archetype taught us was the thinking: spot the read-heavy ratio, refuse to do expensive work on the common path, move it to write time, then patch the one case (celebrities) where that trade backfires. That reasoning transfers directly to Instagram, Reddit, and any other feed.

Tradeoffs at each layer

Pulling it together — every layer made one choice to serve the 100:1 read ratio, and each carries a cost worth naming out loud:

Tradeoffs at each layer — every box was a choice, not a default
Feed assembly
Fan-out on write (precompute feeds) instead of build-on-read
✓ gainReads become a single O(1) lookup — the expensive work leaves the hot path.
⚠ costWrite amplification (one tweet → many feed writes) and feed storage growth.
Celebrities
Hybrid: push for normal users, pull-merge for mega-accounts
✓ gainBounds the write storm; the common case stays instant.
⚠ costTwo read-path code paths + a “who is a celebrity” rule to maintain.
Feed cache
Redis storing tweet IDs only, capped to ~800 entries/user
✓ gainKeeps the cache small enough to shard; bodies hydrated once from the tweet store.
⚠ costA hydration step on read; old tweets beyond the cap fall back to a slower path.
Pagination
Cursor (WHERE id < last_seen) not OFFSET
✓ gainConstant cost at any depth; correct under inserts (no dupes/skips).
⚠ costNo jump-to-arbitrary-page — fine for a feed, wrong for search results.
Storage / sharding
Tweet store + feed cache sharded by user_id; media in blob store + CDN
✓ gainHot reads (one user’s feed/tweets) stay single-shard; DB rows stay tiny.
⚠ costCross-user queries (live multi-author merge, analytics) become scatter-gather.
Consistency
Eventual consistency — a few seconds of feed staleness
✓ gainPermits async fan-out and aggressive caching; high availability.
⚠ costA fresh tweet isn’t instantly in followers’ feeds (agreed up front).

Wrapping up, the way you'd say it out loud

“Twitter is a read-heavy feed, roughly a hundred reads per write, so I optimized the read path. The naive design that builds each timeline on read is too slow on the common path, so I precompute every user's feed at write time with asynchronous fan-out workers, making reads a single O(1) lookup. Pure fan-out on write breaks for celebrities, so I use a hybrid: push for normal users, pull-merge for the few celebrity accounts. Feeds are cursor-paginated for correctness on a live timeline, media sits behind a CDN, and everything is sharded by user ID. With more time I'd add feed ranking and a search and trends pipeline.”

💡 In the Interview

The whole interview pivots on one move: don't present fan-out on write as a fact you memorized — derive it. Start from the read-heavy ratio, show why building feeds on read fails, and let that motivate precomputing at write time. Then proactively raise the celebrity problem and resolve it with the hybrid. A candidate who narrates that reasoning, instead of reciting the final diagram, reads as someone who can design the next problem too — which is exactly what the interviewer is trying to find out.