System Design (High-Level Design) Module 23 – Case studies: social, feeds and search
Design X (Twitter): timelines and search
Design X (Twitter) timelines and search: hybrid fan-out for huge followings, posts searchable in seconds, paged reply trees and trends.
What you will learn
- Estimate the posts, timeline reads, inbox writes and index memory of a service at the scale of X
- Choose the follower threshold for pulling posts, with numbers for writes and read latency
- Design a real-time search index that makes new posts searchable within seconds
- Read a reply tree keyed by conversation id in pages, with bounded depth and cursors
- Connect timelines, search, replies and trends to one log of posts
Before you start
What you’ll learn · Before you start
What you will learn
- Estimate the posts, timeline reads, inbox writes and index memory of a service at the scale of X
- Choose the follower threshold for pulling posts, with numbers for writes and read latency
- Design a real-time search index that makes new posts searchable within seconds
- Read a reply tree keyed by conversation id in pages, with bounded depth and cursors
- Connect timelines, search, replies and trends to one log of posts
Before you start
On this page
A service like X takes 500 million posts a day and runs four features on that one stream. Home timelines reuse the hybrid fan-out: A feed design that pushes the posts of ordinary authors into their followers' inboxes and pulls the posts of the largest accounts when a feed loads, then merges the two by time. More in “Fan-out: push, pull and hybrid timelines” of a news feed: a new post’s id is pushed into the inbox of every active follower, except when the author has a very large following; those posts are pulled and merged in when a timeline is read. Search must find a post seconds after it is written: an indexer reads the same log of new posts, appends each one to an in-memory index segment for the current hour, and readers see it after the next refresh (search index): The step that makes newly indexed and changed documents visible to search. Elasticsearch refreshes every second by default, so a write becomes searchable at the next refresh, not when it is saved. More in “Inverted indexes, analysers and BM25 ranking”. Replies hang under the post they answer, as a tree whose posts share one conversation id, read a few levels and a page at a time, and trends count terms in sliding windows. This case study sizes each part and follows every path end to end.
Not affiliated
Based on public information about X (Twitter). MySmartCoPilot is not affiliated with X (Twitter), and X (Twitter)’s real architecture may differ from what this lesson describes.
Requirements: four features on one stream
Agree on the scope first, because each feature has its own read path:
| Feature | In scope |
|---|---|
| Timelines | the home timeline shows followed accounts’ posts, newest first or ranked; a profile shows one account’s posts |
| Search | keywords and hashtags over public posts, in a Latest view and a Top view |
| Replies | a post’s conversation, ranked, a few levels deep, in pages |
| Trends | the terms rising fastest in the last hour, per region |
| Out of scope | direct messages, ads, notifications and media processing |
Functionally, people post, reply, follow, read timelines, search, open conversations and see trends. The non-functional requirements are numbers: a timeline page within 200 ms and a search within 100 ms at the 99th percentile; a new post in followers’ timelines within 5 seconds and in search within 10; and deletes, blocks and protected accounts enforced on every read. Elsewhere availability wins over strict consistency: a timeline a few seconds old is acceptable, an empty one is not.
The estimate: 5,787 posts a second, 1.1 million inbox writes
| Quantity | Assumption | Result |
|---|---|---|
| Posts written | 500 million a day | 5,787 a second; about 17,400 at a 3 × peak |
| Home timeline reads | 250 million readers × 8 loads a day | 23,148 a second; about 69,000 at the peak |
| Inbox writes, pushing every post | 5,787 posts × 190 active followers reached | about 1.1 million a second |
| Searches | 1 billion a day | 11,574 a second; about 35,000 at the peak |
| Search index in memory | 7 days × 500 million posts × 100 bytes | 350 GB; 700 GB with one replica |
| Post storage | 500 million posts × 1 KB | 500 GB a day; about 183 TB a year |
Two rows shape the design. Fan-out makes 190 inbox writes for every post, so the pull threshold is the main lever on the write path. And a week of posts fits in the memory of a few dozen servers, so search serves recent posts from memory.
Checkpoint
Check the estimate
API and data model: posts carry their thread’s id
POST /v1/postswith the text, media ids and an optionalreply_toreturns the post with its id and conversation id.GET /v1/timelines/home?cursor=…returns a page of posts and the next cursor.GET /v1/search?q=%23final&order=latest&cursor=…returns matching posts;order=topranks them.GET /v1/conversations/{id}?cursor=…returns a page of a reply tree;GET /v1/trends?region=…returns trends.
| Store and key | Holds |
|---|---|
| Posts, by post id | author, text, media links, conversation id, reply-to id, audience and a deleted flag; ids are ordered by time |
| Inboxes, by reader | the newest post ids pushed to the reader |
| Search segments, by hour and partition | an in-memory inverted index of that hour’s posts |
| Reply edges, by conversation and parent | the parent’s replies, in rank order |
| Trend counts, by region, term and minute | posts and distinct accounts in each window |
Every post carries the id of the post that started its thread. X’s API exposes the same idea: all replies in a
thread share a conversation_id, which equals the first post’s own id
(X API documentation). Post ids are time-ordered, so a slice
of time is a range of ids.
One log feeds four consumers, each at its own offset
One log of posts feeds four consumers; the timeline service and the search broker read what they build
Text description of the diagram
An app sends a new post (1) to the post service, which stores it in the post store, where ids are ordered by time (2), and appends an event to the post log (3).
Four consumers read the log (4), each at its own offset, and each writes its own store: fan-out workers push the post's id into followers' inboxes when the author is below the pull threshold; indexers append the post to the newest search segment, cut by hour and refreshed every second; the replies service adds a reply edge under its parent; and trend counters keep counts per minute.
The read paths (5) read what the consumers built: the timeline service reads a reader's inbox and merges the recent posts of the pulled accounts the reader follows, and the search broker asks every partition of the segments it needs and merges their top results.
The write path. The post service stores the post and appends an event to a partitioned log. Four consumers read that log, each at its own position: a consumer’s position in a partition is a single offset: The position of a record in a log or a partition: 0 for the first record, then 1, 2 and so on. A reader group's committed offset is the next record it has not processed. More in “Queues, logs and pub-sub: three different tools”, which it can also move back to read again (Kafka documentation). Fan-out workers push the id into active followers’ inboxes unless the author is above the pull threshold. Indexers add the post to the newest search segment. The conversation service files a reply under its parent, and trend counters count its terms. Follow one reply through the log:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Stored with its thread. A reader replies to a post. The post service stores the reply with its own id and its conversation id, the id of the post that started the thread.
- One event on the log. The service appends one event to the post log. Every feature downstream starts from this event; none of them writes to the post store.
- Fan-out reads it. Fan-out workers read the event and push the reply's id into the inboxes of the author's active followers, unless the author is above the pull threshold.
- An indexer reads it. An indexer appends the reply to the newest search segment. It is not searchable yet; the next refresh makes it visible, by default about a second later.
- Filed under its parent. The conversation service adds a reply edge under the post it answers, keyed by the conversation id and the parent's id.
- Counted for trends. Trend counters add the reply's terms and hashtags to the current one-minute window of its region.
- Each at its own offset. Each consumer keeps its own position in every partition of the log. A slow indexer delays search alone, and it catches up from its committed offset without disturbing the others.
The read paths. A home-timeline read takes the reader’s inbox, merges the recent posts of the pulled accounts they follow, fetches the posts in one batch, drops what the reader may not see, and ranks the page. A search goes to every index partition of the newest hours, and a broker merges their best matches. A conversation read takes one page of replies under a parent, a few levels deep. Twitter’s engineers described this search shape for their Earlybird servers: posts arrive from an ingestion pipeline, only the newest of a handful of segments is written, and posts were typically searchable 10 seconds after creation, with queries answered in about 50 ms (Earlybird paper).
The model below sends 12,135 posts through a log into a segmented index and measures how long each waited to become searchable.
# A real-time search index in miniature. Posts reach the indexer through a log, are appended to the active time
# segment, and become searchable at the next refresh. Part 1 measures the visibility delay, from posting to
# searchable, for three refresh intervals. Part 2 runs a newest-first search over the time segments.
# Seeded, so every run prints the same.
import random
RATE = 300 # posts a second
SECONDS = 40
SEGMENT_POSTS = 3_000 # the active segment closes after this many posts
WORDS = ["the", "match", "goal", "rain", "train", "late", "today", "city", "power", "cut", "news", "live",
"score", "final", "metro", "traffic", "budget", "vote", "film", "song"]
WEIGHTS = [1 / (rank + 1) for rank in range(len(WORDS))] # a few words are common, most are rare
TAGS = ["#final", "#rain", "#budget", "#metro"]
def make_posts(seed=7):
"""(post id, created at, reaches the indexer at, words), in the order the indexer receives them."""
rng = random.Random(seed)
posts, t, post_id = [], 0.0, 100_000
while True:
t += rng.expovariate(RATE)
if t >= SECONDS:
break
# Most posts spend 50-400 ms in the log; 1 in 100 waits behind a slow batch for 1.5-3 s.
lag = rng.uniform(0.05, 0.4) if rng.random() >= 0.01 else rng.uniform(1.5, 3.0)
words = rng.choices(WORDS, weights=WEIGHTS, k=rng.randint(5, 12))
if rng.random() < 0.08:
words.append(rng.choice(TAGS))
if rng.random() < 0.002:
words.append("eclipse") # a rare word
posts.append((post_id, t, t + lag, words))
post_id += 1
return sorted(posts, key=lambda p: p[2])
class Segment:
"""One time partition: append-only postings, and a count of the documents that readers may see."""
def __init__(self):
self.docs, self.postings, self.visible = [], {}, 0
def add(self, post_id, words):
doc = len(self.docs)
self.docs.append(post_id)
for w in set(words):
self.postings.setdefault(w, []).append(doc)
def refresh(self):
self.visible = len(self.docs) # one number moves: everything appended so far becomes searchable
def build(posts, refresh_every):
"""Index the posts in arrival order; return the segments and when each post became searchable."""
segments, visible_at = [Segment()], {}
pending, i, tick = [], 0, refresh_every
while i < len(posts) or pending:
while i < len(posts) and posts[i][2] <= tick:
post_id, _, _, words = posts[i]
if len(segments[-1].docs) == SEGMENT_POSTS:
segments[-1].refresh()
segments.append(Segment()) # roll over: the old segment is read-only from now on
segments[-1].add(post_id, words)
pending.append(post_id)
i += 1
segments[-1].refresh()
for post_id in pending:
visible_at[post_id] = tick
pending, tick = [], tick + refresh_every
return segments, visible_at
def pct(sorted_values, p):
return sorted_values[min(len(sorted_values) - 1, int(p / 100 * len(sorted_values)))]
def newest(segments, term, k):
"""The k newest searchable posts with the term, newest first; and how much of the index was read."""
hits, touched, visited = [], 0, 0
for seg in reversed(segments):
visited += 1
for doc in reversed(seg.postings.get(term, [])):
if doc >= seg.visible:
continue # appended after the last refresh: not searchable yet
touched += 1
hits.append(seg.docs[doc])
if len(hits) == k:
return hits, touched, visited
return hits, touched, visited
posts = make_posts()
created = {p[0]: p[1] for p in posts}
print(f"posts: {len(posts):,} in {SECONDS} s ({RATE} a second), {SEGMENT_POSTS:,} to a segment")
print()
print("refresh every delay p50 p99 max refreshes a minute")
for every in (0.1, 1.0, 5.0):
segments, visible_at = build(posts, every)
delays = sorted(visible_at[pid] - created[pid] for pid in visible_at)
print(f"{every:>8.1f} s {pct(delays, 50):>6.2f} s {pct(delays, 99):>5.2f} s {delays[-1]:>5.2f} s"
f" {60 / every:>8,.0f}")
segments, _ = build(posts, 1.0)
print()
print(f"segments after {SECONDS} s: {len(segments)}, the newest still being written")
for term, k in (("#final", 20), ("eclipse", 20)):
hits, touched, visited = newest(segments, term, k)
total = sum(len(s.postings.get(term, [])) for s in segments)
print(f"newest {k} with {term}: {len(hits)} found, {touched} of {total} postings read, "
f"{visited} of {len(segments)} segments visited")
print(f" first ids: {hits[:4]}") Output
posts: 12,135 in 40 s (300 a second), 3,000 to a segment
refresh every delay p50 p99 max refreshes a minute
0.1 s 0.28 s 1.66 s 3.04 s 600
1.0 s 0.74 s 2.03 s 3.87 s 60
5.0 s 2.79 s 5.24 s 7.87 s 12
segments after 40 s: 5, the newest still being written
newest 20 with #final: 20 found, 20 of 246 postings read, 2 of 5 segments visited
first ids: [112093, 112027, 112077, 112040]
newest 20 with eclipse: 19 found, 19 of 19 postings read, 5 of 5 segments visited
first ids: [111250, 111147, 110724, 109876]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 realtime_index.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
With a refresh every second, half the posts were searchable within 0.74 s and 99 % within 2.03 s. Refreshing ten times as often cut those to 0.28 s and 1.66 s, yet the slowest post still waited 3.04 s: posts held up in the log set the tail. A refresh every 5 seconds pushed the median to 2.79 s. The search shows why the index is cut by time: the 20 newest posts with #final came from the two newest segments after reading 20 of 246 postings, while a rare word was looked for in all five. Ids a fraction of a second apart can reach the indexer out of order, so the service sorts each page by id.
Checkpoint
Check: One log and its four consumers
Practise: the newest posts in segments
The exercise asks for that Latest search; its sample tests run in the page:
Exercise · Medium · Python
Find the newest posts with a term across time segments
Write newest(segments, term, k, deleted) in newest.py. It answers a Latest search: the ids of the k newest searchable posts that contain term, newest first.
The index is a list of time segments, oldest first. Each segment is a dictionary with three keys:
"ids": the post ids in the order they were indexed."postings": for each term, the positions in"ids"of the posts that contain it, in the order they were indexed."visible": how many of the segment's posts are searchable; positions from"visible"on wait for the next refresh.
A later post was indexed later, so the newest posts are at the end of the last segment. deleted is a set of post ids that must never be returned.
Read as little of the index as you can: walk the segments from the newest, read each postings list from its end, and stop as soon as you have k posts. A test checks that an older segment is never read when the newer ones already hold enough posts. Return an empty list when k is 0 or nothing matches.
Starter code · newest.py
def newest(segments, term, k, deleted=frozenset()):
"""The ids of the k newest searchable posts that contain term, newest first."""
# Replace this line with your code.
return [] The sample tests · test_newest.py
from newest import newest
def segment(ids, postings, visible=None):
return {"ids": ids, "postings": postings, "visible": len(ids) if visible is None else visible}
class NeverRead(dict):
"""A postings map that fails the test when it is read."""
def get(self, *args):
raise AssertionError("an older segment was read although newer segments held enough posts")
def __getitem__(self, key):
raise AssertionError("an older segment was read although newer segments held enough posts")
def test_newest_first_across_segments():
"""the newest segment comes first, and each list is read from its end"""
old = segment([101, 102, 103], {"#final": [0, 2]})
new = segment([104, 105, 106], {"#final": [1, 2]})
assert newest([old, new], "#final", 3) == [106, 105, 103]
def test_stops_when_it_has_k():
"""an older segment is not read when the newest one holds k posts"""
old = segment([101, 102], NeverRead())
new = segment([103, 104, 105], {"rain": [0, 1, 2]})
assert newest([old, new], "rain", 2) == [105, 104]
def test_posts_after_the_refresh_are_not_searchable():
"""positions from "visible" on wait for the next refresh"""
new = segment([201, 202, 203, 204], {"metro": [1, 2, 3]}, visible=2)
assert newest([new], "metro", 5) == [202]
def test_deleted_posts_never_appear():
"""a deleted post is skipped, and the search keeps looking"""
old = segment([301, 302], {"vote": [0, 1]})
new = segment([303, 304], {"vote": [0, 1]})
assert newest([old, new], "vote", 2, deleted={304, 303}) == [302, 301]
def test_fewer_than_k_and_no_match():
"""all matches when there are fewer than k; nothing for an unknown term or k = 0"""
old = segment([401, 402], {"film": [1]})
new = segment([403], {"film": [0]})
assert newest([old, new], "film", 10) == [403, 402]
assert newest([old, new], "eclipse", 10) == []
assert newest([old, new], "film", 0) == [] A hint
for segment in reversed(segments): visits the newest segment first, and for position in reversed(segment["postings"].get(term, [])): reads one postings list from its newest end. Skip a position that is not below segment["visible"], and an id that is in deleted; return as soon as the list of results is k long.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Practice that adapts to you
Practise this lesson
8 questions about 12 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
The deep dives this design must answer
- The pull threshold: pushing costs writes, pulling costs a merge on every read.
- Merge latency: every pulled timeline fetched in parallel is one more chance of a slow fetch.
- The index: one writer appends to the newest segment while many readers search it.
- Latest and Top: a newest-first search stops after k results; a ranked one needs bounds to skip segments.
- Deletes and protected posts: both must vanish from search and timelines at once.
- Partitions and tiers: recent hours sit in memory across partitions; older ones move to cheaper storage.
- Conversations: a tree of thousands of replies is read a page at a time.
- Trends: counted in windows, with coordinated campaigns filtered out.
Choosing the pull threshold from the long tail, with numbers Pro: opens with a Pro, Premium or Ultimate pass
The threshold decides where the cost of a large following lands: on the writes when a post is published, or on every read by every follower. This part prices six thresholds in a model of 400 million accounts and picks one against a write budget and a read budget.
In this part:
- Pricing six thresholds on a power-law follower curve
- Why parallel fetches set the read tail
- Choosing against a write budget and a read budget, worked
- Hysteresis for the accounts near the line, with numbers
1 diagram · 1 runnable example (Python) · about 11 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Real-time search: segments, ranking, partitions and tiers Pro: opens with a Pro, Premium or Ultimate pass
Search has to show a post seconds after it is written and still answer in tens of milliseconds. This part follows a post through the index, measures the two kinds of query, and sizes the partitions and storage tiers behind them.
In this part:
- One writer, many readers: the append-only segment
- Latest and Top, measured: stopping early and skipping segments
- Deletes, edits and protected accounts
- Partitions and replicas, sized with numbers
- Tiers: merging old slices and moving them to cheaper storage
1 diagram · 2 runnable examples (Python) · about 14 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Conversations, trends, failure drills and the level rubric Pro: opens with a Pro, Premium or Ultimate pass
The last part pages a reply tree of 50,000 posts, connects trends to search, stress-tests the design at 10 × and 100 ×, and turns it into an answer for a design round.
In this part:
- Paging a reply tree of 50,000 replies, measured
- Trends next to timelines and search
- Failure drills, from indexer lag to a lost partition
- Bottlenecks at 10× and 100×
- What each level is expected to cover
- A sample answer and a 45-minute self-mock
1 runnable example (Python) · about 7 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher to mid · Warm-up
Why is a new post not searchable the instant it is written, and what sets the delay?
Because search reads an index, not the post store, and three waits stand between the two. The post travels through a log to the indexers, which takes milliseconds normally and seconds when they fall behind. The indexer adds it to the newest in-memory segment. Readers see it only after the next refresh, which makes everything appended so far searchable; Elasticsearch refreshes every second by default for indices that were searched recently. So the delay is the time in the log plus indexing plus up to one refresh interval. A shorter refresh lowers the median, but a slow log still sets the tail. When people must see their own new post at once, show it from the post store rather than from search.
-
Senior to staff
An account with 80 million followers posts during a cup final. Its followers open their home timelines within seconds, and its reply thread takes 40,000 replies a minute. Walk through the write and read paths and keep both under budget.
-
Mid to senior
During a final, search for the match hashtag must show posts within 10 seconds, but the indexers have fallen four minutes behind. Find the likely cause and restore freshness without losing posts.
-
Senior to staff
A post has 2 million replies in 30,000 branches, and readers report repeated and missing replies while they scroll its conversation. Explain why, and design storage and pagination for the conversation view.
Key takeaways
- One log of posts feeds four consumers: fan-out, search indexing, conversations and trends.
- Search freshness is a budget: time in the log, indexing and the refresh interval add up.
- Cut the search index by time, so that a newest-first search reads only the newest segments.
References
- Earlybird: Real-Time Search at Twitter (IEEE ICDE (Busch and others, authors' copy))
- Conversation ID (X API documentation) (X Developer Platform)
- Kafka design (Apache Kafka documentation) (Apache Software Foundation)
- Near real-time search (Elasticsearch documentation) (Elastic)
- Clusters, nodes and shards (Elasticsearch documentation) (Elastic)
- Data tiers (Elasticsearch documentation) (Elastic)
- Text analysis (Elasticsearch documentation) (Elastic)
- Faster retrieval of top hits in Elasticsearch with block-max WAND (Elastic)
- random.binomialvariate (generate pseudo-random numbers) (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress