all_lessons / system_design / cases / C11 C11 / C44

Design Twitter-style timelines

"Build Twitter's feed" is really three different feeds wearing one name. The senior move is to say which one you're building before you draw a single box — because each is built by a completely different machine.

Satellite of C08 (feed fanout) Case drill Name-the-timeline first
First principle
A timeline is a materialised join between a stream of posts and a social graph, and the only question that matters is when you pay for that join. Pay at write time (push the post into every follower's list) and reads are a dumb lookup but a single viral post triggers millions of writes. Pay at read time (pull each followee's posts and merge) and writes are trivial but a user who follows thousands does an expensive graph fan-in on every refresh. The push/pull crossover is derived in C08 — here we run that same machinery at production graph scale, where the graph is so skewed that no single answer survives. The deltas this case adds are the three-timelines framing and cache invalidation.

Don't re-derive the crossover — point at C08

The classic interview trap is to spend ten minutes re-deriving fanout-on-write vs fanout-on-read. Don't. That derivation — the break-even where writes_per_post · followers overtakes reads · followees — lives in C08 (news feed), and a senior candidate references it in one sentence: "this is the C08 crossover; I'll use the hybrid push/pull split it lands on." What C08 does not stress, and what makes Twitter specifically hard, is two things:

The three timelines (the meta-signal)

Before any architecture, separate the three things the prompt calls "timeline." Saying this out loud is the strongest early signal you can send.

THREE TIMELINES — three different machines (1) PROFILE timeline — "everything @alice posted" build: APPEND-BY-AUTHOR. One author → one ordered stream. Trivial. ┌────────────┐ │ tweets by │ WRITE: append tweet to alice's own stream │ @alice │ READ : range-scan alice's stream by time └────────────┘ → no graph, no fanout. A single-partition append-log. (2) HOME timeline — "everything the people @bob follows posted" build: PERSONALISED-ACROSS-FOLLOWS. The hard one. followees → → → ┐ @alice ──┐ │ HYBRID: @carol ──┼──▶ │ push ordinary authors' IDs into bob's home cache @VIRAL ··┘ │ pull viral authors' streams at read time ▼ ┌──────────────── HYDRATION ────────────────┐ bob reads: │ merge(pushed IDs, pulled-viral IDs) by time │ → IDs │ filter blocked/deleted/visibility │ │ hydrate IDs → bodies from tweet store/cache │ → feed └─────────────────────────────────────────────┘ (3) MENTIONS timeline — "everyone who @-mentioned @bob" build: NOTIFICATION-LIKE. Write on mention-parse, not on follow-graph. tweet text contains "@bob" → append tweet ID to bob's mentions list → small, write-driven, independent of who follows whom.

Profile is a single-author append log — the easy case, near-free at any scale. Mentions is notification plumbing: when a post is parsed, any @handle it contains gets the post ID appended to that handle's mentions list, completely independent of the follow graph. Home is the only one that needs the C08 hybrid, because it's the only one that joins a stream against an arbitrary, skewed set of follows. Designing all three with the same fanout machinery is the mistake; the rest of this case is about home.

Clarify the contract

Treat the prompt as a product contract before a box diagram. The system must:

What it need not do, and saying so buys credibility: home need not be perfectly chronological (ranking is allowed and expected), need not be strongly consistent (a post showing up 2s late is fine), and need not retain infinite scrollback (a bounded window — a few hundred recent IDs — covers nearly all reads; older reads fall back to the pull path).

Put numbers on it

Two numbers force every later decision: the read/write asymmetry and the IDs-vs-bodies storage gap.

Reads dominate writes. Take 200M daily actives each opening the app ~10×/day and refreshing a few times per open — call it ~50 home reads/user/day. That's 200M · 50 = 1010 reads/day ≈ 1010 / 86400 ≈ 116k reads/s, and several× that at peak. Writes are far rarer — if 10% of users post 2×/day that's 20M · 2 / 86400 ≈ 460 posts/s. Reads outrun writes by ~250×, which is the entire argument for fanout-on-write for ordinary authors: do the join once at write time so the 116k reads/s become cheap list lookups.

But the viral author breaks that. A push to 100M followers is 100M list writes per post. At even 460 posts/s, if a fraction come from mega-accounts the write amplification is catastrophic — a single celebrity tweet is more fanout work than millions of ordinary posts combined. So mega-accounts are flipped to fanout-on-read: their post is written once to their profile stream, and pulled in at each follower's hydration. The threshold is per-author, typically tens of thousands of followers.

Store IDs, not bodies — the ~100× number. A home timeline is a list of post IDs, not post contents. An ID (snowflake) is 8 bytes; a hydrated tweet body — text, author, media refs, counts — is easily 1–2 KB. Materialising 800 IDs per home timeline costs 800 · 8 B = 6.4 KB; materialising the bodies would cost 800 · ~1.6 KB ≈ 1.3 MB — roughly 200× more, multiplied across every follower of every author. That gap is why home timelines are stored as ID lists and bodies are hydrated on read from a shared tweet store/cache (one cached copy per tweet, not one per follower).

Home reads / sec (avg)
~116k
Posts / sec (avg)
~460
Read : write ratio
~250×
IDs vs bodies storage
~100–200×
Viral fanout / post
up to 100M writes
Home window kept
~800 IDs

Data model & API

Keep the surface small; let it express the product op, not the shard/cache layout.

Store / APIShape & why
tweetsCanonical, by (author_id, snowflake_id). Single source of truth for the body + a visibility flag (live / deleted / restricted). Hydration reads here.
profile_timelineAppend-only per author: list of that author's tweet IDs by time. Built by the author's own write. No graph.
home_timelinePer user: bounded list (~800) of tweet IDs, written by the fanout workers for ordinary authors. Cache-resident (Redis-class), rebuildable.
mentionsPer user: list of tweet IDs that @-mentioned them, written at parse time.
graphFollow edges + block/mute edges. followers(author) drives push; followees(user) drives the viral pull merge.
POST /tweets · GET /home_timeline?cursor= · GET /users/{id}/timeline · GET /mentions · POST /follow · POST /block

Linearized design — follow a post, then a read

  1. Write. A post is written once, canonically, to tweets and appended to the author's profile_timeline. Cheap, single-partition, always done.
  2. Classify the author. If followers < threshold (ordinary), enqueue a fanout job. If viral, do nothing more on write — readers will pull it.
  3. Fanout (async). Workers read followers(author) and append the new ID to each follower's home_timeline, trimming to the bounded window. This is C11's heaviest pipe; it rides the async queue (lesson 11) so a posting spike never blocks the writer.
  4. Mention parse. In parallel, parse @handles and append the ID to each mentioned user's mentions list.
  5. Read (home). Fetch the user's pushed ID list; pull recent IDs from each viral followee's profile stream; merge by time into a candidate set.
  6. Hydration. Filter the candidate IDs against block/mute and the canonical visibility flag, then hydrate surviving IDs → bodies from the shared tweet cache. Rank the bounded set, return a stable cursor.

The first bottleneck is step 3 (viral fanout) — solved by the per-author push/pull split. The second, subtler bottleneck is step 6: hydration is where correctness lives, and the next two deep dives are about not corrupting it.

Deep dives

1. Name which timeline you're designing — the meta-signal

This is worth restating as its own move because it's the differentiator between a junior and senior answer. A junior hears "design Twitter" and starts drawing a fanout pipeline. A senior says: "There are three timelines. Profile is an append log — trivial. Mentions is a notification list — write-on-parse, graph-independent. Home is the hard one, and it's the C08 hybrid at skewed graph scale. I'll design home; the other two fall out of the same tweet store." Naming the surface scopes the problem, signals you know the product, and prevents you from accidentally applying expensive fanout to the two timelines that don't need it. The whole rest of the interview is then about home.

2. Follow / unfollow don't rewrite infinite history

The naive read of "home timeline = posts from everyone you follow" implies that following someone should inject all their history into your timeline, and unfollowing should rip it back out. At graph scale that's a non-starter: a follow could rewrite an unbounded list, and an unfollow could require scanning and editing millions of cached timelines. The discipline is to make graph mutations bounded and lazy:

So follow does bounded forward work; unfollow does no eager work and leans on the filter. The cost you accept is a few wasted IDs in the cache; the cost you refuse is rewriting infinite history on every graph edge change.

3. Cache invalidation — mark canonical, filter at hydration, clean async

Deletion exposes the same trap. A tweet pushed to 5M home timelines lives as 5M cached IDs. When the author deletes it, the wrong instinct is to delete the ID from all 5M lists synchronously — a second fanout storm, and one that must hit lists that may have aged out or live on cold shards. Lesson 06's invalidation problem at fanout scale.

The correct pattern flips where truth lives. The cached ID lists are not authoritative; the tweets store's visibility flag is. So:

This is the same invariant as the unfollow case, generalised: cached timelines are a hint; the canonical tweet store is the truth; correctness is enforced at hydration, never by rewriting fanned-out copies. This is DDIA Ch. 11's derived/materialised data in its purest form — the home timeline is a derived view, and you keep a derived view correct by re-deriving (filtering) from the source on read, not by trying to keep every copy perfectly in sync.

HYDRATION as the correctness gate (delete & block, same path) cached home_timeline (hint): [ t91 t88 t87(deleted) t85 t83(by-blocked-user) t80 ] │ ▼ ┌──────────────────────── HYDRATE ───────────────────────────┐ │ for each ID: │ │ canonical visibility live? ──no──▶ DROP (t87 deleted) │ │ author/blocker relationship ok? ─no─▶ DROP (t83 blocked) │ │ else fetch body from shared tweet cache │ └─────────────────────────────────────────────────────────────┘ │ ▼ served feed: [ t91 t88 t85 t80 ] ← t87, t83 never surfaced async sweep later removes t87, t83 from the stored list

Trade-offs

ChoiceBuysCostsChoose when
Push (fanout-on-write)O(1) home reads — just a list lookupwrite storm ∝ followers; viral posts melt the queueordinary authors (followers below threshold)
Pull (fanout-on-read)trivial writes; viral-saferead-time graph fan-in; cost ∝ followeesviral authors; deep scrollback
Hybrid push/pullbounds both write storm and read fan-inmerge complexity at hydration; per-author classificationreal social graphs (the C08 answer, the default here)
Hydration-time filter vs timeline rewriteO(1) deletes/blocks; no second fanouta few wasted cached IDs; filter on every readany large-fanout system (always, in practice)
Chronological vs ranked homechrono: predictable, simple MVPranked: needs a model, lower predictabilitychrono for the interview MVP; ranked for the product

The sharpest trade-off is hydration-time filtering vs timeline rewrite. Rewrite gives you "clean" stored lists at the price of turning every delete, block, and visibility change into a fresh fanout storm against millions of cached copies — exactly the write amplification you adopted hybrid fanout to avoid. Filtering at hydration accepts that stored lists contain dead pointers, in exchange for making every visibility change a single canonical write. Because reads outrun writes ~250×, you might think doing work per-read is the expensive side — but the filter is a cheap flag check per ID, while the rewrite is millions of list edits per change. Filtering wins decisively, and it's the same call every large fanout system makes.

Failure modes

FailureMitigation
Viral fanout stormPer-author push/pull threshold; viral authors are pull-only, never fanned out.
Fanout queue backlogAsync queue absorbs spikes (lesson 11); home is eventually consistent — a few seconds of lag is acceptable, writes never block.
Blocked/deleted content leaks into a cached feedHydration-time visibility filter against the canonical flag — the cached ID is harmless because it's never served.
Hot graph shard (mega-account's followers)That account is pull-path, so we never read its giant follower set on the hot path (lesson 07).
Home cache evicted / timeline gap after outageRebuild from profile streams (push side) + viral pull + graph snapshot — home is fully derived data, so it's reconstructible.
Ranking service downFall back to chronological merge of the candidate set — degraded, not broken.

Interview Q&A

  1. "Design Twitter's feed" — where do you start? (senior answer) By naming that "feed" is three timelines: profile (append-by-author, trivial), mentions (write-on-parse, graph-independent), and home (the hard, personalised one). I design home; the other two fall out of the same canonical tweet store. Scoping to the right surface is the first signal.
  2. Push or pull? (senior answer) Neither alone — hybrid, and I won't re-derive the crossover because it's the C08 result. Ordinary authors push (reads outrun writes ~250×, so pre-compute the join); viral authors pull (a 100M-follower push is a write storm). The threshold is per-author followers, because the graph is power-law and a single global threshold is meaningless.
  3. Why store post IDs in home timelines instead of the posts themselves? (senior answer) An ID is 8 bytes, a hydrated body is 1–2 KB — about 100–200× larger, multiplied across every follower. Storing IDs and hydrating bodies from one shared tweet cache on read keeps a single copy per tweet instead of millions, and lets visibility/edits stay correct because the body is fetched fresh.
  4. What happens to my home timeline when I follow someone? (senior answer) I backfill only a bounded recent window of their posts and start receiving their fanout — I never reconstruct their full history. Following is bounded forward work; deep scrollback into their past comes via the pull path, not a rewrite.
  5. And when I unfollow or block? (senior answer) Stop fanning future posts, and do no eager cache scrub. Anything already cached is filtered at hydration against the block edge and the canonical visibility flag, then cleaned asynchronously. Eager scrubbing of millions of cached lists is the storm we're avoiding.
  6. A blocked user's reply is sitting in a cached timeline and leaks to the user — fix it. (senior answer) The leak means visibility is being trusted in the stored list instead of enforced at hydration. The fix is not to rewrite the timeline — it's to make hydration the gate: every candidate ID is checked against the canonical visibility flag and the block edge before its body is served, so the offending ID resolves to nothing and is dropped from the response. The cached ID stays put, harmlessly, and an async sweep removes it later. Timeline rewrite would "fix" this one case by re-introducing fanout-scale write amplification on every block — the wrong trade.
  7. How do you delete a tweet that's been fanned out to 5M timelines? (senior answer) Flip one canonical visibility flag in the tweet store. The 5M cached IDs become stale-but-harmless pointers that hydration drops; a background sweep cleans them lazily. One write, not 5M. This is DDIA Ch. 11's derived-data discipline: re-derive correctness from the source on read.
  8. Is home timeline strongly consistent? (senior answer) No, and it shouldn't be. Home is derived/materialised data delivered through an async fanout queue; a post arriving a couple of seconds late is fine, and that slack is exactly what lets the queue absorb posting spikes without blocking writers. The strong guarantees live in the canonical tweet store; the timeline is an eventually-consistent view of it.

Related lessons

This case is a satellite of C08 (news feed) — the push/pull crossover is derived there; here we run the same machinery at skewed graph scale and add the three-timelines framing and cache invalidation. The async fanout pipe is lesson 11 (async messaging)'s queue, sized so a posting spike never blocks writers. Hydration's "one cached body per tweet, filter on read" is lesson 06 (caching)'s invalidation problem at fanout scale. Keeping the mega-account's follower set off the hot read path is lesson 07 (partitioning)'s hot-shard avoidance. DDIA threads through: Ch. 1 frames this exact fanout example; Ch. 11 (derived/materialised data) is why hydration-time filtering is correct; Ch. 5 (replication/read replicas) underpins serving 116k reads/s off cached views.

C08 · News feed (anchor) Caching Async messaging Partitioning
Takeaway
"Build Twitter" is three timelines, not one: profile (append log), mentions (write-on-parse), and home (the C08 hybrid at power-law graph scale). Don't re-derive the push/pull crossover — point at C08 and spend your time on the deltas. Store home timelines as 8-byte IDs, not 1–2 KB bodies (~100× cheaper), and hydrate from one shared cache. Above all, never rewrite fanned-out copies to enforce a change: follows backfill a bounded window, unfollows and deletes flip canonical state, and hydration is the gate where visibility is enforced. The cached timeline is a hint; the tweet store is the truth.