hub.gazar.dev · senior-to-staff · worked system design

Design Twitter / X: the home timeline

A fully worked answer to the most common system-design prompt, in the exact order the course teaches: requirements, numbers, API, data, the high-level diagram, the deep dives that actually decide the design, then reliability, observability, and the trade-offs named out loud. The lever here is fan-out: how a tweet reaches every follower's timeline, and what the celebrity case does to that plan. Architecturally it is CQRS with a precomputed timeline (a fan-out materialized view), named and taught below before we build it. Back to the course.

Architecture: the fan-out timeline (CQRS + precomputed materialized view)

The home timeline is the textbook case of a fan-out architecture: a read-heavy feed built with CQRS (separate the write model from the read model), where each user's timeline is a precomputed materialized view, kept fresh by an asynchronous pub/sub fan-out, and tuned as a hybrid push / pull model for the celebrity case. Name it in the first minute, then build it.

The pattern, first

Fan-out & CQRS: the pattern this design is an instance of

Before the boxes, learn the shape, because it transfers to almost every feed you will ever build. The problem: many writers (the people you follow) merge into one reader's view (your timeline), and reads vastly outnumber writes. The pattern that solves it has three ideas.

IdeaWhat it meansWhy it is here
CQRS
command / query split
the write model (post a tweet: normalized, durable source of truth) is separate from the read model (a per-user timeline: denormalized for the query). The two sync asynchronously.reads and writes have opposite shapes here, so optimize them independently instead of forcing one store to do both.
Materialized view
precompute the answer
each timeline is the answer to "recent tweets from everyone I follow", computed ahead of time and stored, not derived on every read.turns an expensive N-author merge into a single O(1) lookup on the hot path.
Fan-out
push vs pull
how the view is kept fresh. Push (fan-out on write): update every affected read model when a write lands. Pull (fan-out on read): assemble the view at query time.push makes reads cheap and writes expensive; pull is the reverse. The follower distribution forces a hybrid of both.

The trade-off the pattern encodes, and where else it shows up

Fan-out spends write amplification and storage (the same tweet id copied into millions of timelines) to buy a fast, single-lookup read. It is the right trade only when reads dominate and slight staleness is acceptable, exactly the timeline's shape. You will meet the same pattern in news feeds, notifications and activity streams, chat unread counts, database materialized views, analytics rollups, and cache warming. Recognizing "this is a fan-out / CQRS read-model problem" is the transferable move; the rest of this page is that pattern, built and defended.

Drawn as one picture, the pattern is a split down the middle: the command side takes writes into a normalized, durable source of truth; an asynchronous event syncs that into the query side, where each user's timeline is a materialized view served in a single lookup. The dotted edge is where all the interesting trade-offs live.

flowchart LR
    subgraph CMD["Command side · optimised for writes"]
      P["POST /tweets"] --> WM[("Write model
normalized · durable SoT")] end subgraph QRY["Query side · optimised for reads"] RM[("Read model
per-user materialized timeline")] --> G["GET /timeline
single O(1) lookup"] end WM -. "async event · fan-out
(eventual consistency)" .-> RM classDef db fill:#DCEBFE,stroke:#09244B,color:#09244B classDef cache fill:#FFEDD5,stroke:#C2410C,color:#09244B class WM db class RM cache
CQRS in one line: the durable write model (blue) and the denormalized read model (orange) are separate stores synced by an asynchronous event. Reads never touch the write model; the dotted edge is where staleness is traded for speed.

Pattern maps to S7 (architectural styles: CQRS, event-driven fan-out) and S1 (read vs write optimisation, precompute vs derive).

The prompt, scoped

"Design Twitter" is too big to answer well, so we narrow to the part that carries the product and the interview: the home timeline, the merged feed of the people you follow. Posting, following, and reading that feed are the spine. Everything else (search, DMs, trends, ads, notifications) is named as out of scope in the first minute, so the design stays honest about what it is solving.

Step 1

Requirements: functional and non-functional

Bound the problem first. The functional core is three verbs:

Out of scope (say it out loud): search, DMs, trends, ads, notifications, media transcoding. We will design for text and ids; media is a blob-store-plus-CDN aside, not the hard part here.

The non-functional shape is what actually drives the architecture:

read-heavy ~ 100:1 timeline p99 < 200ms availability 99.9%+ eventual consistency OK hundreds of M DAU

Two facts decide everything downstream: reads vastly outnumber writes, and a tweet showing up a couple of seconds late is completely acceptable. That second fact is permission to precompute and to choose availability over strong consistency.

Maps to S2 (requirements to numbers) and S1 (the consistency vs availability axis).

Step 2

Estimation: the numbers that bound the design

Back-of-envelope, with round numbers you can defend:

QuantityAssumptionResult
Daily active users200M DAUthe population
Timeline readseach opens ~10x/day2B reads/day ~ 25k QPS avg, ~100k+ peak
Tweets written~5% of DAU post ~5/day~500M tweets/day ~ 6k writes/sec
Read : write25k vs 6k, but reads hit cacheread path dominates by far
Tweet storage~300 bytes each, 500M/day~150 GB/day of text, ~55 TB/year
Fan-out amplificationavg ~200 followers1 tweet ~ 200 timeline writes (the real cost)

The number that decides the architecture

It is not QPS, it is fan-out amplification. One tweet by an average user becomes ~200 timeline writes; one tweet by a celebrity with 100M followers would become 100M. That single asymmetry is why the whole design splits into a normal path and a celebrity path. Hold it.

The asymmetry is easier to feel than to read. One tweet is one row in the tweet store, but N rows in N follower timelines, and N is drawn from a heavy-tailed distribution:

flowchart TD
    T["1 tweet written
(6k / sec total)"] --> STORE[("+1 row in tweet store
always cheap")] T --> AMP{"author's
follower count?"} AMP -->|"~200 (typical)"| OK["~200 timeline writes
push it · cheap"] AMP -->|"100,000,000 (celebrity)"| BAD["100M timeline writes
fan-out storm · never push"] classDef db fill:#DCEBFE,stroke:#09244B,color:#09244B classDef ok fill:#D6F5E3,stroke:#15803D,color:#09244B classDef bad fill:#FEE2E2,stroke:#B91C1C,color:#7F1D1D class STORE db class OK ok class BAD bad
The write cost of a tweet is not the tweet, it is the fan-out. Typical authors amplify ~200×; a celebrity amplifies 100,000,000×. That single fork is why the design later splits into a push path and a pull path.

Maps to S2 (capacity estimation and the estimation cheat sheet).

Step 3

API: the handful of endpoints

Name the contracts that carry the product, not every route.

EndpointDoesNotes
POST /v1/tweetscreate a tweetidempotency key so a retry does not double-post; returns a Snowflake id
POST /v1/followsfollow a user{followeeId}; idempotent
DELETE /v1/follows/:idunfollowidempotent
GET /v1/timeline?cursor=read home timelinecursor pagination, not offset; returns hydrated tweets + next cursor

The timeline read is the hot path, so it is cursor-paginated (stable under new tweets) and returns fully hydrated tweets so the client makes one call, not N.

Maps to S5 (API design): idempotent writes, cursor pagination, one round trip. See the API cheat sheet.

Step 4

Data model and store choices

Four entities, three stores, each chosen from its access pattern rather than habit.

User(id PK, handle, name, follower_count)
Tweet(id PK = Snowflake, author_id, text, created_at)      access: by id, by author
Follow(follower_id, followee_id, created_at)            access: "who do I follow", "who follows X"
TimelineCache[user_id] -> [tweet_id, tweet_id, ...]       access: read my precomputed feed

stores
  Tweets ........ sharded KV / wide-column (by tweet_id)   immutable, huge, point lookups   -> Cassandra / Manhattan-style
  Social graph .. who-follows-whom, both directions        adjacency, fan-out source        -> sharded store + graph index
  Timeline cache  per-user list of recent tweet ids        in-memory, fast, evictable       -> Redis

Why these stores

  • Tweets are immutable, write-once, read by id at huge volume: a sharded key-value / wide-column store sharded on tweet id (a Snowflake id sorts by time, so inserts stay sequential).
  • The graph is queried both ways (my followees to build my feed; X's followers to fan out), so store both adjacency directions.
  • The timeline is precomputed and disposable, so it lives in Redis: lose it and you rebuild it from the graph plus the tweet store.

The four entities and how they relate. Note the two edges out of User into Follow: the graph is read in both directions, and that double edge is exactly what the fan-out step walks.

erDiagram
    USER ||--o{ TWEET : authors
    USER ||--o{ FOLLOW : "follows (as follower)"
    USER ||--o{ FOLLOW : "is followed (as followee)"
    USER ||--|| TIMELINE : "owns cache"
    TWEET }o--o{ TIMELINE : "id referenced by"
    USER {
        snowflake id PK
        string handle
        int follower_count
    }
    TWEET {
        snowflake id PK
        snowflake author_id FK
        string text
        timestamp created_at
    }
    FOLLOW {
        snowflake follower_id FK
        snowflake followee_id FK
        timestamp created_at
    }
    TIMELINE {
        snowflake user_id PK
        list tweet_ids "recent-first"
    }
Four entities. Follow is joined to User twice (follower and followee) because the graph is queried both ways: "who do I follow" builds a feed, "who follows X" drives the fan-out. Timeline holds only ids and is rebuildable from Follow + Tweet.

Maps to S3 (picking a store from access patterns) and S4 (sharding the tweet store and the social graph). See the data-stores and data-at-scale cheat sheets.

Step 5

High-level architecture

Two paths share the stores. The write path (top) takes a tweet, persists it, and fans it out to followers' timeline caches through an async queue. The read path (bottom) serves the precomputed timeline from Redis, hydrates the tweet bodies, and ranks. Splitting write from read is what lets the cheap, frequent reads stay fast while the expensive fan-out happens off the critical path.

WRITE PATH · fan-out READ PATH · serve timeline Client web / mobile API gateway L7 LB + auth Tweet service write + id Fan-out queue async Fan-out workers push to timelines Timeline svc assemble + rank Ranking svc Timeline cache Redis · per user Tweet store sharded by id Social graph followers of X Celebrity cache pulled at read POST /tweets GET /timeline push ids persist tweet read ids hydrate merge celebs
Write path (green): a tweet is persisted, then fanned out asynchronously to each follower's Redis timeline. Read path (orange): the timeline service reads precomputed ids from Redis, hydrates bodies from the tweet store, merges in celebrity tweets pulled at read time, and ranks. Blue = durable stores, orange = caches, green = the async queue.

The same two paths as a sequence diagram, so you can see the ordering the box diagram only implies: the write is acknowledged before the fan-out runs (the queue decouples them), and the celebrity branch is pulled at read time instead of pushed at write time.

sequenceDiagram
    autonumber
    actor U as User
    participant GW as API gateway
    participant TW as Tweet service
    participant Q as Fan-out queue
    participant FW as Fan-out workers
    participant G as Social graph
    participant TL as Timeline cache
    participant TS as Tweet store
    participant RK as Ranking

    Note over U,TS: WRITE PATH · fan-out on write (push)
    U->>GW: POST /tweets
    GW->>TW: create tweet
    TW->>TS: persist tweet (durable)
    TW-->>U: 201 + id (fast, before fan-out)
    TW->>Q: enqueue fan-out job
    Q->>FW: deliver job
    FW->>G: followers of author?
    alt normal author
        FW->>TL: push tweet id into each follower timeline
    else celebrity (millions of followers)
        FW-->>FW: skip fan-out, pull at read
    end

    Note over U,RK: READ PATH · assemble + hybrid pull
    U->>GW: GET /timeline?cursor
    GW->>TL: read precomputed tweet ids
    GW->>TS: hydrate tweet bodies
    GW->>G: which celebrities do I follow?
    GW->>TS: pull their recent tweets (fan-out on read)
    GW->>RK: score and merge (fallback: reverse-chron)
    RK-->>GW: top-K ranked timeline
    GW-->>U: hydrated, ranked timeline
The write is acknowledged before the asynchronous fan-out runs. Normal authors are pushed into follower timelines; celebrity tweets are skipped on write and pulled at read, then merged and ranked. Ranking fails open to reverse-chronological.

Maps to S7 (architectural styles) and S6 (queues and async fan-out).

Step 6

Deep dive A: fan-out on write vs fan-out on read

This is the decision the whole design turns on. When does the merge of many authors into one feed happen, at write time or at read time?

FAN-OUT ON WRITE (push)                 FAN-OUT ON READ (pull)
─────────────────────────              ─────────────────────────
on POST: copy tweet id into            on GET: gather followees,
every follower's timeline cache        query each author's recent
                                        tweets, merge + sort now
reads:  O(1), just read my list        reads:  O(followees), expensive
writes: O(followers), expensive        writes: O(1), cheap
great for: the common reader           great for: rare readers,
                                        users who follow huge numbers

The call: hybrid, because reads dominate

Reads outnumber writes ~100:1 and must be fast, so the default is fan-out on write: precompute every timeline so a read is a single Redis lookup. You pay for it in write amplification and storage, which is the right trade when reads are the expensive, latency-sensitive side. The exception is the next deep dive.

The same choice as two flows. Push does the expensive merge once, at write time, so the read is trivial; pull defers everything to read time, so the write is trivial. Where you put the work is the entire decision.

flowchart LR
    subgraph PUSH["Fan-out on write · PUSH"]
      direction TB
      wA["POST tweet"] --> wB["copy id into every
follower's timeline
(work here · O(followers))"] wB --> wC["GET timeline
= 1 lookup · O(1)"] end subgraph PULL["Fan-out on read · PULL"] direction TB rA["POST tweet
= 1 write · O(1)"] --> rB["GET timeline"] rB --> rC["gather followees, query each,
merge + sort now
(work here · O(followees))"] end classDef work fill:#FFEDD5,stroke:#C2410C,color:#09244B classDef cheap fill:#D6F5E3,stroke:#15803D,color:#09244B class wB,rC work class wC,rA cheap
Orange marks where the expensive merge happens. Push (left) pays it once per write and reads free; pull (right) pays it on every read and writes free. Because reads outnumber writes ~100:1, push wins the default, everywhere except the celebrity tail.

Maps to S1 (latency vs cost, read vs write optimisation) and S4 (precompute and cache).

Step 6 · continued

Deep dive B: the celebrity hot-key problem

Pure fan-out-on-write breaks on one input: a user with 100M followers. One tweet would trigger 100M timeline writes, a fan-out storm that floods the queue, lags every other write, and hammers one slice of the cache (a hot key).

normal user (200 followers)   ->  push: 200 writes        cheap, do it
celebrity (100,000,000)       ->  push: 100M writes        NO

hybrid solution
  - do NOT fan out tweets from high-follower accounts
  - store celebrity tweets in a small "celebrity cache"
  - at READ time, merge: my precomputed timeline  +  recent
    tweets from the few celebrities I follow (pulled live)
  => write cost bounded, read cost bounded (few celebs followed)

Why hybrid is the senior answer

Neither pure strategy survives the follower distribution, which is heavy-tailed. Push for the millions of normal accounts (cheap writes, fast reads), pull for the tiny number of mega-accounts (avoids the storm). The merge at read time is a few extra lookups, bounded because nobody follows millions of celebrities. Naming this split is the move interviewers score.

The hybrid drawn as one decision. Write time forks on follower count; read time always merges both sources. The celebrity's tweets simply take the pull path while everyone else takes the push path, and the reader stitches them together.

flowchart TD
    NT["New tweet"] --> Q{"author a
high-follower account?"} Q -->|"no · normal"| PUSH["push id into every
follower's timeline cache"] Q -->|"yes · celebrity"| CC[("store in celebrity cache
· do NOT fan out")] PUSH --> TL[("per-user timeline cache
(precomputed)")] RD["GET /timeline"] --> M["merge at read"] TL --> M CC -. "pull recent tweets from
the few celebs I follow" .-> M M --> OUT["bounded write cost
+ bounded read cost"] classDef cache fill:#FFEDD5,stroke:#C2410C,color:#09244B classDef ok fill:#D6F5E3,stroke:#15803D,color:#09244B class CC,TL cache class OUT ok
One fork at write time (normal → push, celebrity → skip and cache), one merge at read time (precomputed timeline + live celebrity pulls). Write cost is bounded because no account fans out to millions; read cost is bounded because nobody follows millions of celebrities.

Maps to S4 (hot keys, skew) and S9 (a fan-out storm is a self-inflicted overload).

Step 6 · continued

Deep dive C: ranking the feed

Reverse-chronological is the honest baseline and a fine first answer. A ranked feed adds a scoring step over the candidate set (the merged timeline) at read time: features like recency, author affinity, and predicted engagement produce a score, and the top-K are returned. Keep it a separate, optional service so the timeline still works when ranking is degraded.

candidate set (merged: precomputed + celebrity pulls)
        │
        ▼
  ranking service  ── score(recency, affinity, engagement) ──►  top-K
        │                                                         │
        └─ if ranking is down / slow ─► fall back to reverse-chron ┘

Ranking sits as an optional stage between the candidate set and the response, with a hard fallback edge so a ranking outage degrades the feed's order, never its availability.

flowchart LR
    CS["candidate set
precomputed + celebrity pulls"] --> RK{"ranking service
healthy & in time?"} RK -->|yes| SC["score(recency,
affinity, engagement)"] --> TK["top-K, ranked"] RK -->|"down / slow / over budget"| RC["reverse-chronological
(fail open)"] TK --> OUT["timeline response"] RC --> OUT classDef opt fill:#DCEBFE,stroke:#09244B,color:#09244B classDef fb fill:#D6F5E3,stroke:#15803D,color:#09244B class SC,TK opt class RC fb
Ranking is a separable stage on the read path. If it is healthy the reader gets a scored top-K; if it is down, slow, or over its latency budget, the timeline falls open to reverse-chronological, unranked but present.

Maps to S1 (simplicity vs capability: ship reverse-chron first) and S9 (ranking fails open to reverse-chron).

Step 7

Reliability & failure design

Name the blast radius and the failure mode per path. The timeline is a place where partial service clearly beats none.

If this fails…ModeWhat the user sees
Ranking servicefail openreverse-chronological timeline, unranked but present
Fan-out workers / queuedegradetweets appear with lag; reads unaffected (queue absorbs the burst, bulkheaded from reads)
Timeline cache (Redis) missdegraderebuild from graph + tweet store on read (slower, still correct)
Tweet writefail closedthe post errors and is retried; never silently lost

Every cross-service call is bounded: timeouts with a budget, retries with backoff and jitter on idempotent calls, a circuit breaker around ranking, and a bulkhead so the fan-out queue can back up without touching the read pool. The fan-out storm from deep dive B is itself the cascading-failure risk; the celebrity split is the firebreak.

The failure map, drawn as blast radius. Everything on the read path is designed to degrade and keep serving; only the write path is allowed to fail closed, because durability is the one thing we refuse to trade.

flowchart TD
    subgraph READ["Read path · degrade, never go dark"]
      RANK["ranking down"] -->|fail open| RC["reverse-chron timeline"]
      MISS["Redis timeline miss"] -->|degrade| REB["rebuild from graph + tweet store
(slower, still correct)"] FAN["fan-out queue backs up"] -->|"bulkhead · isolated from reads"| LAG["new tweets appear late
reads unaffected"] end subgraph WRITE["Write path · protect durability"] TW["tweet write fails"] -->|fail closed| RETRY["error + retry w/ backoff
never silently lost"] end classDef ok fill:#D6F5E3,stroke:#15803D,color:#09244B classDef warn fill:#FFEDD5,stroke:#C2410C,color:#09244B classDef bad fill:#FEE2E2,stroke:#B91C1C,color:#7F1D1D class RC,LAG ok class REB warn class RETRY bad
Read-path failures each have a graceful mode (green = still serving, orange = slower but correct); the write path alone fails closed (red) so a post is never silently dropped. The fan-out queue is bulkheaded so its backlog can never starve the read pool.

Maps to S9 (designing for failure). See the resilience cheat sheet.

Step 7 · continued

Observability & operability

Pick SLIs the user actually feels, then alert on them.

Maps to S10 (observability and operational readiness). See the observability cheat sheet.

Step 8

Trade-offs along the six core axes

The part interviewers score: name the decision each axis bought, and the least-wrong call.

Axis (S1)The call hereWhy it is least-wrong
Consistency vs availabilityavailability + eventuala tweet a few seconds late is fine; the timeline must stay up
Latency vs costprecompute timelinesspend storage and write amplification to buy a one-lookup read
Read vs write optimisationhybrid fan-outpush for the many, pull for the few mega-accounts
Space vs timestore redundant timelinesduplicate tweet ids across millions of caches to save read time
Simplicity vs capabilityreverse-chron first, ranking optionalship the simple feed; add ranking as a degradable layer
Coupling vs cohesionasync queue between write and fan-outthe slow, bursty fan-out is decoupled from the fast write and read

The whole design is one asymmetry handled well: reads dominate and must be fast, so precompute; fan-out is cheap for the many and ruinous for the few, so split it. Say that sentence and the boxes follow from it.

Maps to S1 (the six core trade-offs) and S12 (present and defend the design).

Use this in the cohort