Twitter-Style Feed Design, in depth: requirements, capacity math, Snowflake IDs, hybrid fan-out, cursors and consistency

By Sandeep Belgavi · 2026-10-03 · Category: System Design
Advertisement
Clientpost / readPost serviceSnowflake IDPost storepost_id -> postEvent logpartition by authorFan-out workersskip celebritiesFollower graphauthor -> followerslookupTimeline cacheuser -> last ~800 (post_id, author_id)push IDsTimeline servicemerge, filter, hydrate, cursorread IDsGET homeCelebrity postspulled at read timePost + user cachesmulti-get hydrateBlock / mute / deleteread-time filters
The hybrid feed. Writes go to the post store and an event log; fan-out workers push post IDs into followers' cached timelines except for very high-follower authors, whose posts are pulled and merged at read time.

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.

Requirements and the shape of the workload

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.

Advertisement

Capacity estimates that decide the design

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.

QuantityCalculationResult
Posts per day200M x 2400M
Average write rate400M / 86,400 sabout 4,600 posts/s
Peak write rate (3x)4,600 x 3about 14,000 posts/s
Timeline reads per day200M x 204B
Average read rate4B / 86,400 sabout 46,000 reads/s
Peak read rate (3x)46,000 x 3about 140,000 reads/s
Fan-out deliveries per day400M x 20080B
Average fan-out rate80B / 86,400 sabout 926,000 inserts/s
Timeline cache size200M users x 800 entries x 16 bytes2.56 TB, about 7.7 TB with 3 replicas
Post metadata per day400M x ~300 bytesabout 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.

Data model and time-sortable IDs

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.seq

41 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.

Write path and hybrid fan-out

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 crash

A 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.

Read path: merge, filter, hydrate, paginate

A home timeline request carries an optional cursor, the smallest post ID the client has already seen. The timeline service then:

  1. Reads up to N post IDs below the cursor from the user's cached timeline. If the user has no cached timeline, rebuilds it by pull from the accounts they follow, caches it, and continues.
  2. Looks up which followed accounts are above the celebrity threshold (a small, cacheable set per user) and fetches each one's recent post IDs below the cursor from that author's own profile timeline.
  3. Merges the lists by ID, which is time order thanks to Snowflake IDs.
  4. Filters out posts from blocked or muted accounts, from accounts the user no longer follows, and posts marked deleted.
  5. Hydrates the surviving IDs with a multi-get against post and user caches, and returns them with the new cursor.

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_cursor

Cursors 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.

Worked example: one read, one write

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.

Consistency: deletes, blocks, edits and counters

Because timelines hold IDs and are filtered at read time, most consistency questions have cheap answers.

Operations, failure modes and trade-offs

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.

ChoiceGainCost
Push (fan-out on write)Reads are one cache lookupWrite amplification, celebrity spikes, memory for every timeline
Pull (fan-out on read)Cheap writes, always freshExpensive reads that grow with number followed
HybridBounded work on both pathsTwo 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.

What to do next

  1. Write down your own capacity assumptions and recompute the table; the threshold and cache size follow from them.
  2. Implement the Snowflake generator and test it against a clock moving backwards.
  3. Build push fan-out into a Redis sorted set with trim and idempotent inserts, then add the pull path for one high-follower account.
  4. Implement max-ID cursor pagination and verify no duplicates appear when new posts arrive between pages.
  5. Add read-time filters for deletes, blocks and unfollows, and measure their cost per page.
  6. Instrument fan-out lag and alert on its 99th percentile.
  7. Read Twitter Timeline Generation Architecture to see how ranking and the For You feed sit on top of this design.
Key takeaway: Let the numbers choose the design: push post IDs into cached timelines for ordinary authors, pull high-follower authors at read time, keep only IDs in timelines so deletes, blocks and edits are handled by read-time filtering and hydration, paginate with ID cursors, and operate the system on fan-out lag and cache health.