A Twitter-style home feed looks simple: show a user the recent posts of the accounts they follow, newest first. It is one of the most instructive system design problems because the obvious implementation, a join between follows and posts at read time, collapses under real numbers, and the obvious fix, precomputing every user's feed, collapses under a different set of numbers. The right answer is a hybrid whose boundaries come straight out of arithmetic.
This article works the problem end to end the way you would in a design review: requirements, capacity estimates, the data model and ID scheme, the fan-out decision, the read path with cursor pagination, consistency for deletes and blocks, and operations. It covers a reverse-chronological Following feed. Ranking, candidate generation and the production internals of X's timelines are covered in Twitter Timeline Generation Architecture; this page is the design you would build first, and the reasoning that gets you there.
Functional requirements: a user can publish a short post, follow and unfollow accounts, read their home timeline (posts from accounts they follow, newest first, paginated) and read any user's profile timeline. Deleting a post, blocking and muting must be reflected in timelines. Non-functional requirements: home timeline reads under about 200 ms at the 99th percentile, a new post visible to followers within a few seconds, very high availability for reads, and tolerance for eventual consistency since a feed is not a ledger.
Two properties of the workload drive everything. Reads vastly outnumber writes, because every user refreshes far more often than they post. And the follow graph is extremely skewed: most accounts have a few hundred followers, while a few have tens of millions. Any design must be judged against both the average account and the extreme one.
Use round assumptions and keep the arithmetic visible so reviewers can change an input. Assume 200 million daily active users, 2 posts per user per day on average, 20 home timeline loads per user per day, and an average of 200 followers per posting account.
| Quantity | Calculation | Result |
|---|---|---|
| Posts per day | 200M x 2 | 400M |
| Average write rate | 400M / 86,400 s | about 4,600 posts/s |
| Peak write rate (3x) | 4,600 x 3 | about 14,000 posts/s |
| Timeline reads per day | 200M x 20 | 4B |
| Average read rate | 4B / 86,400 s | about 46,000 reads/s |
| Peak read rate (3x) | 46,000 x 3 | about 140,000 reads/s |
| Fan-out deliveries per day | 400M x 200 | 80B |
| Average fan-out rate | 80B / 86,400 s | about 926,000 inserts/s |
| Timeline cache size | 200M users x 800 entries x 16 bytes | 2.56 TB, about 7.7 TB with 3 replicas |
| Post metadata per day | 400M x ~300 bytes | about 120 GB/day before media |
Two conclusions fall out. First, pulling at read time means 140,000 peak requests each touching a few hundred followees, tens of millions of lookups per second against the post store, merged and sorted per request. Second, pushing at write time costs nearly a million cache inserts per second on average, which a sharded in-memory store can absorb, and keeps reads to one cache lookup. Push looks better on average, until you meet the tail: one post from an account with 50 million followers is 50 million inserts by itself, roughly a minute of the entire fleet's average fan-out budget for a single post.
Store posts in a table keyed by post_id with author, text, media references, created time and a deleted flag; shard by post ID. Store the follow graph twice, as following(user_id -> followee_id) and followers(followee_id -> follower_id), each sharded by its first column, because the read path asks who a user follows and the write path asks who follows an author. Keep follower counts on the account record so the fan-out service can classify authors cheaply.
Make post IDs sortable by time so that timelines can be ordered and paginated by ID alone. The well-known Snowflake layout packs 41 bits of milliseconds since a custom epoch, 10 bits of machine identity and 12 bits of per-millisecond sequence into a 64-bit integer:
import threading, time
EPOCH_MS = 1288834974657 # any fixed past instant; this is Twitter's original epoch
class Snowflake:
def __init__(self, machine_id):
assert 0 <= machine_id < 1024
self.machine, self.seq, self.last = machine_id, 0, -1
self.lock = threading.Lock()
def next_id(self):
with self.lock:
now = int(time.time() * 1000)
if now < self.last:
raise RuntimeError("clock moved backwards; refuse rather than duplicate")
if now == self.last:
self.seq = (self.seq + 1) & 0xFFF
if self.seq == 0: # 4,096 IDs used this millisecond
while now <= self.last:
now = int(time.time() * 1000)
else:
self.seq = 0
self.last = now
return ((now - EPOCH_MS) << 22) | (self.machine << 12) | self.seq41 bits of milliseconds last about 69 years from the epoch, and each generator can issue 4,096 IDs per millisecond. IDs are k-sorted rather than perfectly ordered across machines, which is fine for a feed. The timeline cache stores only (post_id, author_id) pairs, 16 bytes each, never post bodies, so edits and deletes never require rewriting millions of cached copies.
The write path: the post service assigns an ID, writes the post, appends an event to a log partitioned by author (see Kafka partition architecture), and acknowledges the client. Fan-out workers consume the log asynchronously. That decoupling is what lets the client get a fast acknowledgement even when delivery takes seconds.
Fan-out policy is hybrid. Authors below a follower threshold are pushed: the worker pages through their followers and prepends the post ID to each follower's cached timeline, trimming it to a fixed length. Authors above the threshold are not fanned out at all; their posts are pulled at read time. Followers who have not been active recently are skipped, and their timeline is rebuilt by pull when they return, which saves a large share of deliveries because many accounts are dormant.
CELEBRITY_THRESHOLD = 100_000
TIMELINE_LEN = 800
def on_post_created(event):
author, post_id = event.author_id, event.post_id
if follower_count(author) >= CELEBRITY_THRESHOLD:
return # pulled at read time instead
for page in followers_paged(author, page_size=5_000):
active = [f for f in page if recently_active(f)]
with timeline_cache.pipeline() as pipe: # batch writes per cache shard
for f in active:
pipe.zadd(f"tl:{f}", {f"{post_id}:{author}": post_id >> 22}) # ms score
pipe.zremrangebyrank(f"tl:{f}", 0, -TIMELINE_LEN - 1)
checkpoint(event, page.cursor) # resume here after a crashA Redis sorted set is one natural fit (see Redis data structures in practice). Scores are IEEE doubles, exact only up to 2^53, and full Snowflake IDs are larger, so score by the millisecond part (post_id >> 22) and keep the full ID in the member, breaking same-millisecond ties by member. Inserts are idempotent because re-adding the same member is a no-op, which makes replay after a worker crash safe. Shard the cache by user ID with consistent hashing so adding nodes moves only a fraction of timelines. The threshold is a tunable, not a constant: set it from your measured fan-out budget, and use hysteresis so an account hovering near it does not flap between modes.
A home timeline request carries an optional cursor, the smallest post ID the client has already seen. The timeline service then:
import heapq, itertools
def home_timeline(user, max_id=None, limit=50):
pushed = cache_range(f"tl:{user}", below=max_id, n=limit * 2) # newest first
pulled = [profile_ids(c, below=max_id, n=limit)
for c in celebrities_followed(user)]
merged = heapq.merge(pushed, *pulled, key=lambda e: -e.post_id) # all newest first
blocked, following = blocked_set(user), following_set(user)
out = []
for e in merged:
if e.author_id in blocked or (e.author_id not in following and e.author_id != user):
continue
out.append(e)
if len(out) == limit * 2: # headroom for deletes
break
posts = [p for p in multi_get_posts([e.post_id for e in out]) if not p.deleted][:limit]
next_cursor = posts[-1].post_id - 1 if posts else None
return posts, next_cursorCursors beat offsets here. An offset of 50 means something different once ten new posts arrive, so users see duplicates or gaps; a max-ID cursor is stable under insertion, and a since-ID cursor serves the pull-to-refresh case.
Trace one request. Alice follows 300 accounts; two are celebrities with 30 and 80 million followers. Her cached timeline holds 800 entries pushed by the other 298. She opens the app with no cursor and a page size of 50.
The service reads 100 IDs from her sorted set, one round trip to one cache shard. It fetches up to 50 recent IDs from each celebrity's profile timeline, two more cached reads. The three newest-first lists merge into 200 candidates; filtering removes one account she blocked yesterday whose posts were pushed before the block, and one deleted post. The top 50 survivors are hydrated with a multi-get of 50 post records and the distinct authors among them. The response carries a cursor equal to the 50th post ID minus one. Total work: a handful of cache round trips, independent of how many accounts she follows or how many followers the celebrities have. That independence is the point of the design.
Now Bob, with 400 followers, posts. The post service writes the post and the event, and returns in milliseconds. A fan-out worker reads his 400 followers, finds 260 active in the last 30 days, and issues 260 sorted-set inserts in pipelined batches. Within a second or two his post sits in 260 timelines, while the 140 dormant followers will see it when their timelines are rebuilt by pull.
Because timelines hold IDs and are filtered at read time, most consistency questions have cheap answers.
Operate the system around a few signals: fan-out lag (time from post to delivery, by percentile), fan-out queue depth, cache hit rate and memory per shard, read latency per stage, and rebuild rate for cold timelines.
| Choice | Gain | Cost |
|---|---|---|
| Push (fan-out on write) | Reads are one cache lookup | Write amplification, celebrity spikes, memory for every timeline |
| Pull (fan-out on read) | Cheap writes, always fresh | Expensive reads that grow with number followed |
| Hybrid | Bounded work on both paths | Two code paths, a threshold to tune, merge logic |
The same hybrid reasoning recurs in other social systems; LinkedIn's architecture makes a different choice, pull then rank, because its feed is ranked rather than chronological.