Scale Interview
System DesignHard

Design Twitter

X Timeline System Design

Overview

Open Twitter (X) and your home timeline fills instantly with tweets from the accounts you follow, newest-ish first. Post a tweet and, seconds later, it surfaces in your followers' timelines. Off to the side, Trending shows the hashtags and phrases spiking right now. Three deceptively simple surfaces, and each hides a genuinely hard problem at Twitter's scale — hundreds of millions of daily users, hundreds of millions of tweets a day, and a follower graph that spans from an account with 12 followers to one with 200 million.

It is labeled "Hard" because the home timeline sits on a power-law follower distribution that defeats any single strategy. If you fan out on write — push every tweet into every follower's timeline at post time — a normal user is cheap but a celebrity with 200M followers triggers 200M cache writes per tweet, a write storm that takes minutes to drain and leaves early followers seeing the tweet while late ones wait. If you fan out on read — assemble every timeline by pulling from all followees at read time — a user following 2,000 accounts triggers 2,000 lookups per timeline load, and reads dominate the workload. The production answer is a hybrid: fan-out-on-write for normal accounts, fan-out-on-read for the handful of celebrities each user follows, merged at read time. And trending is a completely different beast — a real-time top-K / heavy-hitters problem over the tweet firehose, where exact counting is impossible and approximation is the point.

This walkthrough assumes you already know the generic feed fan-out fundamentals — if not, read the news-feed breakdown first; we lean on it rather than re-deriving it. Here we focus on what is distinctively Twitter: the hybrid timeline and the celebrity crux, the timeline read/merge path, and real-time trending.

Out of scope (say so): full-text tweet search (that's the post-search breakdown), the Direct Messages system, ads targeting and the "For You" recommendation model beyond a light ranking pass, media transcoding for video, and notifications. We keep the core: post a tweet, follow accounts, load a home timeline, engage, and see what's trending.

Functional requirements

  • Post a tweet. A user posts a short text tweet (optionally with media pointers, a retweet, or a reply). It becomes visible on followers' home timelines within seconds.
  • Follow / unfollow. A user follows another account; that account's future tweets appear in the follower's home timeline. Follows are directed (not mutual).
  • Home timeline. A user loads a paginated, roughly reverse-chronological (lightly ranked) stream of recent tweets from accounts they follow.
  • User timeline. View a single account's own tweets, newest first.
  • Engage. Like and retweet a tweet; counts are shown (eventually consistent is fine).
  • Trending topics. Show the top-K hashtags / phrases spiking now, per region, over a recent time window.

Out of scope (state it): tweet search, DMs, "For You" ML ranking, ads, media transcoding, notifications, account signup.

Non-functional requirements

  • Read-heavy. Home-timeline loads vastly outnumber tweets posted. The read path must be a fast cache hit, never a fan-out-on-read scan for normal users.
  • Low timeline latency. < ~200 ms p99 for the timeline metadata; media streams independently via CDN.
  • Celebrity / hot-user problem. An account with 100M+ followers cannot fan out a tweet to all follower timelines synchronously. The system must absorb extreme follower counts without stalling normal-user posts.
  • Elastic, spiky writes. A world event drives a burst of tweets and retweets; the write and fan-out paths must be backpressured through queues, never blocking the poster's request.
  • Eventual consistency is acceptable. A tweet appearing in a follower's timeline a few seconds after posting, or a like count that lags by seconds, is fine. Strict global ordering is not required.
  • Trending is approximate and fresh. Trends update every few minutes over a moving window; approximate top-K counts are acceptable — nobody can tell rank #47 from #52.
  • Availability over strong consistency. A slightly stale or briefly incomplete timeline beats an error page.

Estimations

State assumptions first; the purpose is to justify design choices, not to produce exact numbers.

Rounded assumptions

  • DAU: ~250M. Each opens the app several times/day → roughly 1.5–2B timeline loads/day, or ~25K timeline loads/sec average, ~50K/sec at peak.
  • Tweets/day: ~500M (originals + retweets + replies) → ~6K tweets/sec average, ~15K/sec at peak.
  • Read:write ratio: ~4:1 at the request level (25K timeline loads vs 6K tweets/sec), but far higher — ~80:1 — counting individual tweet reads, since each timeline load hydrates ~20 tweets. Either way, reads dominate heavily.
  • Followers per account: median a few hundred; power-law tail runs to 100–200M for the largest accounts.
  • Fan-out amplification (normal users): 6K tweets/sec × ~300 avg followers ≈ ~2M timeline-cache writes/sec — large but shardable across a Redis cluster. This is exactly why fan-out-on-write is cheap for normal accounts.
  • Timeline cache size: ~800 tweet ids per user × ~80 bytes per sorted-set member × 250M users ≈ ~16 TB — substantial, shardable.
  • Engagement writes: likes + retweets run several× the tweet rate → tens of thousands/sec (say ~30–50K/sec), heavily skewed to a few viral tweets — a hot-key problem on per-tweet counters.
  • Trending firehose: every tweet is tokenized into hashtags/phrases → tens of thousands of term-events/sec to aggregate into windowed top-K per region.

How the numbers affect the design

Signal from the numbers Design decision
25–50K timeline reads/sec (~4:1 requests, ~80:1 hydrated tweets) Precompute home timelines into a cache; reads are cache hits, not followee scans.
6K tweets/sec × ~300 followers = ~2M cache writes/sec Fan-out-on-write is cheap for normal accounts — it's what makes reads O(1).
One celebrity tweet × 200M followers = 200M cache writes Fan-out-on-write creates an intolerable write storm and inconsistency window — pull-and-merge celebrity tweets at read time instead.
Avg user follows only ~5–10 celebrities out of the ~300 accounts they follow Read-time merge is cheap — a handful of pull lookups on top of one cache read.
~30–50K likes+retweets/sec, skewed to viral tweets Buffer per-tweet counters in memory and flush periodically — don't hammer the DB row per like (hot-key problem).
Tens of thousands of hashtag-events/sec, approximate top-K Streaming aggregation with sketches over windowed counts — not exact GROUP BY over the tweet table.

Caution

Do not build the home timeline by querying the tweet table for "all tweets by accounts I follow, ordered by time" on every load. For a user following 2,000 accounts that is thousands of lookups per read, at 50K reads/sec — a fan-out-on-read scan that melts the tweet store. Precompute timelines; serve reads from cache.

Core entities / data model

Six things are stored: the user, the tweet, the follow edge, the home-timeline cache (precomputed per user), the user-timeline index (an account's own tweets, needed for the celebrity pull path), and engagement counters.

Entity Key fields
User user_id, handle, display_name, follower_count, following_count, is_hot (celebrity flag), created_at
Tweet tweet_id (time-sortable, Snowflake/ULID), author_id, text, media_urls[], type (original/retweet/reply/quote), parent_id, like_count, retweet_count, created_at
Follow (follower_id, followee_id), created_atdirected edge; stored both directions
HomeTimeline user_id → ordered list of tweet_ids (reverse-chronological, capped ~800) — the precomputed per-user cache
UserTimeline author_id → ordered list of that author's recent tweet_ids — read by the celebrity pull-and-merge path
Engagement (tweet_id, user_id, kind) where kind ∈ {like, retweet} — composite key prevents duplicates; running counts denormalized onto the tweet row

The Tweet row stores CDN URLs for media (media_urls), never the bytes — the media pipeline is the same presigned-upload → blob-store → CDN flow described in the Instagram breakdown; we don't re-derive it here.

The tweet_id is time-sortable (Snowflake/ULID). This is load-bearing: it lets the HomeTimeline sorted set score by id, makes cursor pagination fall out of the id itself, and gives retweets/replies a stable chronological key.

Two timeline structures, not one. HomeTimeline is the derived, precomputed feed each user reads. UserTimeline is the per-author list of an account's own recent tweets — cheap to maintain (append on post) and the thing the read path pulls from for the celebrities a user follows. Keeping them separate is what makes the hybrid work: normal authors get pushed into HomeTimeline; celebrity authors are pulled from UserTimeline at read time.

Caution

The HomeTimeline cache is a derived structure, never the source of truth. The tweet store and follow graph are authoritative. If a timeline cache is lost or corrupted, rebuild it from the follow graph plus UserTimelines. You can rebuild the timeline from the tweets; you can never rebuild the tweets from the timeline.

Where each lives. User and Tweet in a sharded relational/wide-column store (sharded by user_id / tweet_id). Follow edges in a sharded KV/graph store, partitioned by follower_id for the forward index and followee_id for the reverse index. HomeTimeline and UserTimeline in Redis as sorted sets scored by tweet_id. Engagement rows in a sharded store; the running like_count / retweet_count are eventually-consistent counters buffered in Redis and flushed asynchronously (the hot-key counter pattern from the Instagram breakdown).

API design

Six operations carry the system: post a tweet, read the home timeline, read a user timeline, follow, engage, and read trends.

Post a tweet

POST /api/tweets
Body: { "text": "shipping day", "media_ids": [], "type": "original", "parent_id": null }
202 Accepted
Returns: { "tweet_id": "...", "created_at": "..." }

Persists the Tweet row and enqueues a fan-out job. Returns 202 because fan-out is asynchronous — the poster never waits for the tweet to reach follower timelines. A retweet or reply is the same call with type and parent_id set.

Get home timeline

GET /api/timeline/home?cursor=<last_tweet_id>&limit=20
200 OK
Returns: { "tweets": [...], "next_cursor": "..." }

Returns a paginated slice of the caller's home timeline. Served from the precomputed HomeTimeline cache, merged with pulled celebrity tweets at read time (deep dive 2). Pagination is cursor-based on tweet_id (which encodes time) — never offset-based, which duplicates and skips rows when new tweets are inserted mid-scroll.

Get user timeline

GET /api/timeline/user/{user_id}?cursor=<last_tweet_id>&limit=20
200 OK
Returns: { "tweets": [...], "next_cursor": "..." }

Returns one account's own tweets, newest first, straight from its UserTimeline index.

Follow / unfollow

POST   /api/follows   Body: { "followee_id": "..." }   204 No Content
DELETE /api/follows/{followee_id}                       204 No Content

Writes/removes the directed edge in both index directions. Neither backfills nor scrubs the timeline synchronously — both are handled asynchronously or lazily at read time (deep dive 2).

Engage (like / retweet)

POST /api/tweets/{tweet_id}/likes      204 No Content
POST /api/tweets/{tweet_id}/retweets   201 Created

Idempotent conditional insert of (tweet_id, user_id, kind) — a second like is a no-op. A retweet also creates a lightweight retweet Tweet that fans out to the retweeter's followers. Counters increment in Redis and flush to the DB asynchronously.

Get trends

GET /api/trends?region=US&limit=25
200 OK
Returns: { "region": "US", "window": "1h", "trends": [ { "term": "#WorldCup", "count": 812004 }, ... ] }

Reads a maintained, per-region top-K structure produced by the trending pipeline (deep dive 3) — never a GROUP BY over the tweet table on demand.

High-level architecture

Three flows matter, and they are fully decoupled: a write + fan-out path (post → fan-out to normal followers' timelines), a timeline read path (cache read + celebrity merge), and a trending pipeline (tokenize the firehose → windowed top-K). We'll walk each, then combine.

1. Write + fan-out path

A posted tweet is persisted, appended to the author's UserTimeline, then fanned out to normal followers' HomeTimeline caches — unless the author is a celebrity, in which case fan-out is skipped and the tweet is picked up on the read path.

flowchart LR
    Client["Client"] -->|"POST /tweets"| TweetSvc["Tweet Service"]
    TweetSvc -->|"persist tweet"| TweetDB[("Tweet Store\n(sharded)")]
    TweetSvc -->|"append to author's\nUserTimeline"| UT[("UserTimeline\n(Redis)")]
    TweetSvc -->|"enqueue fan-out\n(tweet_id, author_id)"| FanQ[["Fan-out Queue"]]
    FanQ --> FW["Fan-out Workers"]
    FollowDB[("Follow Store\nfollowee→followers")] -->|"who follows author?"| FW
    FW -->|"ZADD tweet_id to\neach normal follower's\nHomeTimeline"| HT[("HomeTimeline\n(Redis)")]
    FW -.->|"author is_hot?\nskip fan-out"| Skip["(no push;\nmerged on read)"]
  1. The Tweet Service persists the tweet and appends its id to the author's UserTimeline.
  2. It enqueues a fan-out job and returns 202 — the poster is done.
  3. Fan-out workers read the author's follower list from the reverse follow index and ZADD the tweet_id into each follower's HomeTimeline, trimming to the cap.
  4. Celebrity exception: if the author's is_hot flag is set, workers skip the push entirely. Those tweets reach followers via the read-time merge (deep dive 1).

2. Timeline read path

A home-timeline load reads the precomputed cache and merges in recent tweets from the celebrities the user follows.

flowchart LR
    Client["Client"] -->|"GET /timeline/home"| TL["Timeline Service"]
    TL -->|"read tweet_ids\n(HomeTimeline)"| HT[("HomeTimeline\n(Redis ZSET)")]
    TL -->|"which followees\nare celebrities?"| Celeb[("Celebrity-followee\nlist (cached)")]
    TL -->|"pull recent tweets\nper celebrity"| UT[("UserTimeline\n(Redis)")]
    TL -->|"merge + rank +\nmulti-get metadata"| TweetDB[("Tweet Store")]
    TL -->|"merged timeline\n+ CDN URLs"| Client
    Client -->|"stream media"| CDN["CDN"]
  1. Read the user's HomeTimeline (a bounded sorted-set range read — O(log N + page), and crucially independent of how many accounts the user follows) — the pushed tweets from normal followees.
  2. Look up the small set of celebrities this user follows (a cached per-user list), and pull their recent tweets from UserTimeline.
  3. Merge the two id lists by tweet_id, apply a light ranking pass, multi-get tweet metadata, and return the page. Media is fetched by the client directly from the CDN.

Every tweet feeds a streaming aggregator that maintains approximate top-K hashtags/phrases per region over a moving window.

flowchart LR
    TweetSvc["Tweet Service"] -->|"tweet events"| Firehose[["Tweet Firehose\n(Kafka)"]]
    Firehose --> Tok["Tokenizer Workers\n(extract hashtags/phrases,\ntag region)"]
    Tok -->|"term events\n(term, region, event_time)"| Agg["Windowed Top-K\nAggregators\n(sketch + heap per region)"]
    Agg -->|"per-region top-K\nevery few minutes"| TrendStore[("Trends Store\n(Redis)")]
    Query["GET /trends"] --> TrendStore
  1. Tweets publish to a firehose (Kafka), decoupling trending from the write path.
  2. Tokenizer workers extract hashtags and candidate phrases and tag each with a region.
  3. Windowed aggregators maintain an approximate top-K per region using a sketch plus a heap (deep dive 3), reusing the streaming-aggregation machinery from the ad-click-aggregator breakdown.
  4. Every few minutes the current top-K per region is published to a small Trends store that /trends reads.

4. Combined architecture

flowchart LR
    Client["Client"] --> GW["API Gateway"]
    GW -->|"POST /tweets"| TweetSvc["Tweet Service"]
    TweetSvc --> TweetDB[("Tweet Store")]
    TweetSvc --> UT[("UserTimeline\nRedis")]
    TweetSvc -->|"enqueue"| FanQ[["Fan-out Queue"]]
    TweetSvc -->|"tweet events"| Firehose[["Firehose (Kafka)"]]
    FanQ --> FW["Fan-out Workers"]
    FollowDB[("Follow Store")] --> FW
    FW -->|"push (non-celebrity)"| HT[("HomeTimeline\nRedis")]
    GW -->|"GET /timeline/home"| TL["Timeline Service"]
    TL --> HT
    TL -->|"celebrity pull"| UT
    TL -->|"multi-get + rank"| TweetDB
    Firehose --> Trend["Trending Aggregators"]
    Trend --> TrendStore[("Trends Store")]
    GW -->|"GET /trends"| TrendStore
    GW -->|"POST like/retweet"| EngSvc["Engagement Service"]
    EngSvc --> EngRedis[("Counters\nRedis")]
    EngRedis -.->|"async flush"| TweetDB

The write path, read path, and trending pipeline share only the tweet store and firehose, and they don't contend. A celebrity's fan-out backlog never blocks a timeline read; a trending spike never blocks posting.

Deep dives

1. Home-timeline hybrid fan-out and the celebrity problem

This is the crux of the Twitter design and where candidates most often get stuck. When account A tweets and has N followers, how do all N followers see it quickly without melting the infrastructure? The generic fan-out fundamentals live in the news-feed breakdown; here we focus on the Twitter-specific shape — a follower graph whose tail runs to 200M — and the hybrid that resolves it.

Three strategies, worst to best for the extremes:

Pure fan-out on read — assemble every timeline by pulling from all followees at read time (avoid — read amplification)

On each timeline load, query UserTimeline for every account the user follows, merge, sort, and return the top 20.

Worked example — a user follows 2,000 accounts:

per timeline load:   2,000 UserTimeline reads + merge of thousands of tweets
at scale:            50K loads/sec × 2,000 followees = 100M reads/sec
  • Pro: zero write amplification — posting is one insert; following is instantly consistent (the next read reflects it).
  • Con: read is O(followees). At 50K loads/sec each needing thousands of reads, this is ~100M reads/sec against the tweet store — crushing — plus hundreds of ms to merge per load.
  • Verdict: acceptable only at tiny scale. The read amplification is fatal at Twitter scale.
Pure fan-out on write — push every tweet into every follower's timeline at post time (breaks for celebrities)

When any account tweets, push the tweet_id into every follower's HomeTimeline.

Worked example — an account with 200M followers tweets:

fan-out writes:  200M ZADD operations across 200M timeline keys
time at 1M writes/sec:  200 seconds per tweet

While that fan-out drains, early followers see the tweet and late followers don't — a massive inconsistency window — and the write burst overwhelms the cache tier and starves normal users' fan-out behind it.

  • Pro: reads are cheap and independent of followee count — a timeline load is one bounded sorted-set range (O(log N + page)), not a scan over everyone you follow. Fast.
  • Con: write amplification is O(followers). For celebrities this is an intolerable inconsistency window and a write storm.
  • Verdict: perfect for normal accounts (hundreds of followers), fatal for celebrities. Don't use it alone.
Hybrid — push for normal accounts, pull-and-merge for celebrities at read time (recommended)

Define a celebrity threshold on follower count (e.g. > 1M) and cache an is_hot flag on the user row. Normal accounts get fan-out-on-write into follower timelines. Celebrity tweets are not pushed; instead, when a follower loads their timeline, the Timeline Service pulls those celebrities' recent tweets from UserTimeline and merges them with the cached timeline.

Why this works: the asymmetry of the power law. A user follows hundreds of normal accounts but only a handful of celebrities. So:

  • Writes: the expensive-to-push accounts (celebrities) are exactly the ones we don't push. Normal-account fan-out stays cheap and bounded.
  • Reads: the expensive-to-pull direction (thousands of followees) is served by the O(1) cache; only the ~5–10 celebrities are pulled and merged — a trivial extra cost per read.

The hard case for one strategy is the easy case for the other, and the hybrid routes each account to the strategy that makes it cheap.

flowchart TD
    Post["Account posts a tweet"] --> Hot{"is_hot?\n(> threshold\nfollowers)"}
    Hot -->|"no (normal)"| Push["Fan-out on write:\nZADD to each follower's\nHomeTimeline"]
    Hot -->|"yes (celebrity)"| NoPush["Skip fan-out;\ntweet stays in\nauthor's UserTimeline"]
    Read["Follower loads timeline"] --> Merge["Read HomeTimeline (pushed)\n+ pull celebrity UserTimelines\n→ merge by tweet_id"]
    Push -.-> Merge
    NoPush -.-> Merge
  • Pro: reads are O(1) plus a tiny celebrity merge; normal-account writes are bounded; celebrities create no write storm.
  • Con: two code paths (push vs pull) and threshold logic to maintain; a short fan-out lag before pushed tweets appear; the read merge adds a few pulls per load.
  • Verdict: the standard production design for power-law social graphs. This is the answer — name the celebrity problem explicitly and explain the read-time merge.

The threshold is dynamic. An account crossing 1M followers flips to is_hot; its historical pushed tweets aren't backfilled or scrubbed — they age out of caches naturally, and future tweets take the pull path. A borderline account can be handled either way; the exact number matters less than having the mechanism.

Fan-out worker mechanics. For a normal author, per tweet: load the follower list from the reverse index, and for each follower ZADD homeTL:{follower} <tweet_id_score> <tweet_id> then ZREMRANGEBYRANK to trim to the cap — executed atomically per follower via a Lua script so no reader sees an over-sized set. Workers are embarrassingly parallel (shard the follower list) and idempotent (a repeated ZADD of the same member is a no-op), so at-least-once redelivery is safe.

Warning

The single most common mistake here is committing to one strategy — "push to all followers" or "pull from all followees." Neither survives the full follower distribution. The correct answer is the hybrid, and you must name the celebrity problem and describe how the read-time merge folds celebrity tweets back in.

2. The timeline read path — cache, merge, rank, paginate

The read path is where the hybrid actually pays off, and it has more moving parts than "read a cache."

Step by step, per home-timeline load:

  1. Read the precomputed cache. ZREVRANGEBYSCORE homeTL:{user} for a page of tweet_ids below the cursor. This is the pushed portion — tweets from normal followees.
  2. Identify celebrity followees. Filter the user's followee list to is_hot accounts using a cached per-user celebrity list (recomputed on follow/unfollow), avoiding a follow-graph scan on the hot path.
  3. Pull celebrity tweets. For each celebrity followee (~5–10), read recent ids from UserTimeline bounded by the same time cursor. Cache the celebrity's latest-tweets slice so many followers share one read.
  4. Merge. Merge the pushed ids and pulled ids into one list ordered by tweet_id (time). Because both are id-sorted, this is a cheap k-way merge.
  5. Rank (light). Optionally re-order the merged candidates with a lightweight score — recency decay blended with engagement and author affinity. Keep it cheap; heavy "For You" ML ranking is out of scope. Reverse-chronological with a light boost is a defensible interview answer.
  6. Hydrate. Multi-get tweet metadata (text, media CDN URLs, counts) for the final page and return it. Filter out ids that come back deleted.
flowchart LR
    Req["home-timeline request\n(cursor, limit)"] --> Cache["1. read HomeTimeline\n(pushed ids)"]
    Req --> Pull["2-3. pull celebrity\nUserTimelines"]
    Cache --> Merge["4. merge by tweet_id"]
    Pull --> Merge
    Merge --> Rank["5. light ranking pass"]
    Rank --> Hydrate["6. multi-get metadata\n+ drop deleted"]
    Hydrate --> Page["timeline page + next_cursor"]

Cursor pagination. In the reverse-chronological default (step 5 skipped), the cursor is the last tweet_id returned and the next page fetches ids strictly below it. Because tweet_id is time-sortable, this is stable under concurrent inserts — new tweets land above the cursor and never shift or duplicate the page you're scrolling. Offset pagination would double-serve and skip rows as the timeline grows underneath the reader. Caveat: the moment you apply the step-5 ranking reorder, a raw tweet_id cursor breaks — the last-returned id is no longer the page's lowest, so "ids below it" re-serves or skips items. A genuinely reranked feed needs a materialized per-session ranked candidate set (paginate by position within it) or a cursor over the rank key, not the raw tweet_id.

Note

Deep pagination on a home timeline is intentionally bounded. The HomeTimeline cache is capped (~800 ids); past that the client is scrolling into cold history, which you either serve by a slower fan-out-on-read fallback over UserTimelines or simply cut off. Most sessions never scroll past the first page or two, so optimizing infinite scroll is wasted effort.

Consistency edge cases the read path absorbs (the same lazy-at-read-time discipline Instagram applies to its feed):

  • Deleted tweet. Its id may linger in caches. Handle at read time — the metadata multi-get returns "deleted," and the read path drops it. Never scan all timelines to purge one id (O(followers) writes).
  • Unfollow. Lazy — don't scrub the cache. The read path applies a following-membership filter and drops tweets from now-unfollowed accounts; the cache self-heals as it's trimmed. A background job can rebuild badly drifted caches.
  • New follow. Optionally backfill the last ~20 tweets of the new followee (bounded, async) so recent history appears; otherwise it flows in on the next read via the merge/pull path. Never synchronous on the follow request.
  • Cold-cache rebuild. On a cache miss (expired key, replaced Redis node), rebuild from the follow graph + UserTimelines. Guard it with a single-flight lock on rebuild:{user} so N concurrent requests don't all launch the same expensive rebuild — the first populates, the rest wait briefly or serve a degraded page.

Tip

Say the split out loud: "The cache holds the many normal followees precomputed; the merge pulls the few celebrity followees live. Reads stay O(1) plus a handful of pulls, and deletes/unfollows are handled lazily at read time rather than by scrubbing millions of caches."

Trending is a different problem from the timeline, and the interviewer wants to see you recognize that. "Which hashtags are spiking in the last hour?" over a firehose of hundreds of millions of tweets/day is a streaming heavy-hitters / top-K problem, not a GROUP BY count(*) over the tweet table. The streaming machinery — a durable partitioned log, event-time windows, watermarks for late data, sharded counters for hot keys — is exactly the ad-click-aggregator pipeline; reuse it rather than re-deriving it. Here we cover only what's specific to trending.

Why exact counting is the wrong goal. There are millions of distinct terms per window (every hashtag, phrase, entity). Keeping an exact counter per term per region, updated at tens of thousands of events/sec, is a huge amount of state for a result where nobody can distinguish rank #47 from #52. Trending is inherently approximate — you want the heavy hitters (the few spiking terms), not exact counts for the long tail.

The approximate top-K structure. The standard approach pairs a frequency sketch with a small heap:

  • A Count-Min Sketch estimates each term's frequency in fixed memory (it may overcount slightly, never undercount).
  • A min-heap of size K per region tracks the current top-K terms. For each incoming term, bump the sketch, read the estimate, and if it beats the heap minimum, insert it. (The Space-Saving algorithm is a common single-structure alternative that keeps O(K) counters directly.)

This turns "top-K over millions of terms" into a few kilobytes of state per region, updatable at firehose speed.

flowchart LR
    Firehose[["Tweet Firehose\n(partitioned by region)"]] --> W["Aggregator Workers"]
    W -->|"bump per-term count"| CMS["Count-Min Sketch\n(per region, per window)"]
    W -->|"maintain top-K"| Heap["Min-heap size K\n(per region)"]
    CMS --> Heap
    Heap -->|"snapshot every\nfew minutes"| Trends[("Trends Store\n(Redis)")]

Windowing over the firehose. Trends are about now, so counts live in event-time windows — e.g. sliding one-hour windows advancing every few minutes — closed on a watermark that lags real time to absorb late/out-of-order tweets, exactly as the ad-click-aggregator handles late clicks. A term's trend score is usually not raw count but velocity — how much its rate in the current window exceeds its recent baseline — so an evergreen tag like #news doesn't permanently occupy the list; a term spiking relative to itself does.

Regional partitioning and hot terms. Trends are per-region, so partition the firehose by region and keep an independent top-K per region. The global top-K is computed by its own aggregator consuming the full firehosenot by merging the regional top-K lists: a term ranked just below every region's cutoff can still be the global leader, so unioning local top-Ks silently drops it (the classic local-top-K merge trap). A globally viral hashtag is a hot key — its term-events flood one partition; shard that term's counter across sub-partitions and sum on read, the same sharded-counter fix used for a viral ad. The summed value inherits each sub-counter's sketch overcount (the biases add), but since the structure is already approximate and ranked by velocity, that bounded bias doesn't change which terms trend.

Serving. Aggregators snapshot each region's top-K to a small Trends store (Redis) every few minutes; /trends is an O(1) read of that snapshot. The tweet firehose and store are derived — if the pipeline restarts, it rebuilds the window by replaying the log's recent tail.

Warning

Don't propose computing trends with a periodic SELECT term, COUNT(*) ... GROUP BY term ORDER BY count DESC over the tweet table. Over hundreds of millions of tweets per window that scan is far too expensive to run every few minutes, and it throws away the streaming structure that makes this cheap. Aggregate incrementally with a sketch + top-K over event-time windows.

Tradeoffs & bottlenecks

  • Fan-out on write vs on read (the timeline). Push gives O(1) reads but O(followers) write amplification and fails for celebrities; pull gives trivial writes but O(followees) reads. The production answer is the hybrid with a celebrity threshold — push the many normal accounts, pull the few celebrities at read time.
  • Two timeline structures. Maintaining both HomeTimeline (derived, pushed) and UserTimeline (per-author, pulled) costs extra Redis and write work, but it's exactly what lets the hybrid route each account to its cheap path.
  • Fan-out lag vs poster latency. Returning 202 and fanning out asynchronously keeps posting fast, at the cost of a few seconds before a tweet lands in follower timelines — acceptable eventual consistency.
  • Eager vs lazy timeline invalidation. Scrubbing deleted tweets / unfollows from all follower caches is O(followers) writes; filtering them at read time is a tiny per-read check. Lazy wins at scale.
  • Exact vs approximate engagement counts. Buffering like/retweet counts in Redis and flushing asynchronously gives eventual consistency (seconds of lag) and removes the hot-key on viral tweets; exact real-time counts would serialize DB writes — untenable at viral scale.
  • Exact vs approximate trending. Sketch-based top-K trades a little accuracy for firehose-speed aggregation in tiny memory. Exact per-term counting is neither necessary nor affordable.
  • Follow-graph hot partitions. A celebrity's reverse-index partition (their follower list) is enormous; shard it across nodes so no single partition holds 200M rows — and note this is a second, independent reason (beyond the write storm) that pushing celebrity tweets is untenable.

Extensions if asked

If the interviewer wants to go beyond the core, add only what changes the design discussion:

  • Tweet search. Free-text search over tweets is an inverted-index / scatter-gather problem, entirely separate from the timeline — see the post-search breakdown. Don't conflate trending (streaming top-K) with search (inverted index).
  • "For You" ranked timeline. Replace the light recency ranking with a full learning-to-rank model over engagement, affinity, and content features — a candidate-generation + re-rank pipeline layered on the same hybrid retrieval. Mention it; don't design the model.
  • Media. Photos/video ride the presigned-upload → blob-store → CDN pipeline from the Instagram breakdown; the tweet row stores only CDN URLs.
  • Direct Messages. A separate conversation/message store, closer to a chat system than a feed — out of the timeline path entirely.
  • Notifications. A fan-out-driven pub/sub ("someone you follow tweeted," "you were mentioned") delivered via APNS/FCM — triggered by the same fan-out workers.
  • Retweet/quote amplification. A retweet is itself a tweet that fans out to the retweeter's followers; a viral retweet chain can re-trigger the celebrity problem one hop down, handled by the same is_hot check on the retweeter.

What interviewers look for & common mistakes

The strongest answers make three moves: precompute the timeline (don't scan followees on read), reach the hybrid fan-out with a crisp celebrity-problem explanation, and recognize trending as a separate streaming top-K problem rather than a query over the tweet table.

What interviewers usually reward:

  • Reaching the hybrid fan-out — not just naming push vs pull, but identifying the celebrity problem and explaining the read-time merge, the threshold, and why the power-law asymmetry makes the hybrid cheap on both sides.
  • Treating HomeTimeline as a derived structure — rebuildable from the follow graph and UserTimelines, never the source of truth.
  • A concrete read path — cache read + celebrity pull + merge by time + light rank + hydrate, with lazy handling of deletes and unfollows.
  • Cursor pagination on a time-sortable tweet_id, not offset-based.
  • Trending as streaming top-K — sketch + heap over event-time windows with watermarks, approximate not exact, reusing the streaming machinery rather than re-deriving it.
  • Eventual consistency as a deliberate choice — seconds of fan-out lag and stale counts traded for scale.

Before you finish, do a quick mistake check:

  • Did you precompute home timelines and serve reads from cache — never a fan-out-on-read scan for normal users?
  • Did you avoid pure fan-out on write, name the celebrity problem, and describe the hybrid pull-and-merge with a threshold?
  • Did you keep a separate per-author UserTimeline that the celebrity merge pulls from?
  • Did you treat the timeline cache as derived and rebuildable, and handle deletes/unfollows lazily at read time?
  • Did you use cursor-based pagination on a time-sortable tweet id?
  • Did you design trending as an approximate streaming top-K over windows, not a GROUP BY over the tweet table?
  • Did you buffer engagement counts in Redis and flush asynchronously rather than incrementing the DB per like?

Practice this live

Run this exact question with our AI voice interviewer and get feedback.

Start the interview →