Design Post Search

Design keyword search over a social network's posts, sorted by recency or likes: fast indexing, like-count tiers, time-sharded indexes and query caching.

Case study30 minLesson 2 of 10
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

Something happens in your city and you type "ferry strike" into the search box of a social app. You expect posts from the last few minutes, newest first, or the most-liked posts about it if you flip the sort. The kind of product this question means is Twitter search: keyword search over a huge, fast-moving stream of short posts.

The hard part is that the index is written far more often than it is searched, and one of the two sort orders depends on a number (likes) that changes 116,000 times a second. Search Systems already covers the inverted index, analysers and feeding an index from the database; this lesson builds on it. The steps follow A Framework for Any Design.

Clarify the problem

The first five minutes
Interviewer

Design search for posts on a social network.

You

Users type keywords and get matching public posts. Two sort orders, newest first and most liked? Do we need phrase search, typo tolerance or personalised ranking?

Interviewer

Both sorts. All the words must match; no phrases, no typos, no personalisation.

You

Scale: posts and likes per day, and how many searches?

Interviewer

500 million posts a day, 10 billion likes a day, 200 million searches a day. Search covers all posts ever made.

You

Then the index takes far more writes than queries. I'll aim for a new post being searchable within 10 seconds, results in under 500 ms, and like counts that may lag by a minute in the sort order. A deleted post must vanish from results quickly. Private posts are out of scope.

  • Functional: search public posts by keywords (all must match); sort by newest or by most liked, optionally within a time window; page through results.
  • Non-functional: new posts searchable within 10 seconds; 500 ms or better at the 99th percentile; like-based order may lag about a minute; deleted posts disappear from results within seconds; search stays up if parts of the index are down, possibly with fewer old results.
  • Out of scope: typo tolerance, phrase search, personalised ranking, private posts, autocomplete (see Design Typeahead Search).

Estimate the scale

A day is 86,400 seconds; assume peaks of 3x.

  • New posts: 500 million ÷ 86,400 ≈ 5,800 a second, about 17,000 at peak.
  • Likes: 10 billion ÷ 86,400 ≈ 116,000 a second, 20 per post on average.
  • Searches: 200 million ÷ 86,400 ≈ 2,300 a second, about 7,000 at peak.
  • Writes against reads: (5,800 + 116,000) ÷ 2,300 ≈ 53, so about 50 writes for every search.
Estimate: Index growth per day

How much does the inverted index grow each day?

You know
  • 500 million posts a day
  • About 30 tokens per post after analysis
  • About 5 bytes per posting, compressed, with overhead

Show the working
  1. Postings: 500 × 106 × 30 = 1.5 × 1010 a day
  2. 1.5 × 1010 × 5 B = 7.5 × 1010 B = 75 GB a day
  3. Last 7 days: 7 × 75 = 525 GB. One year: 75 GB × 365 ≈ 27 TB

Estimate 75 GB (anything from 25 GB to 250 GB is a good answer)

A week fits in memory: 7 days × 20 shards of about 3.75 GB, each with 3 replicas, about 1.6 TB of RAM. A year does not. That split is the hot and cold tiers.

Estimate: Rewriting the index on every like

If each like rewrote its post's document in the index, how many document rewrites per second would that be on average?

You know
  • 10 billion likes a day
  • A day is 86,400 seconds

Show the working
  1. 10 × 109 ÷ 86,400 ≈ 116,000 a second
  2. That is 20 times the rate of new posts

Estimate 116 thousand/s (anything from 40 thousand/s to 400 thousand/s is a good answer)

An index update is a delete plus a re-insert of the whole document. 20 times more index churn than new content is the problem the second deep dive solves.

Your turn: Define the API

Write the search endpoint. How does the client ask for the next page, for each sort order?

Compare with a model answer

One GET with the query, the sort and an optional time window. For newest first, the cursor is "older than post id X", which stays correct as new posts arrive. For most liked, counts move under you, so the cursor points to a short-lived snapshot of the ranked list.

Define the API

HTTP API
GET /v1/search?q=ferry+strike&sort=recent&limit=20&cursor=...
GET /v1/search?q=ferry+strike&sort=likes&since=2026-10-15&limit=20
  200 { "results": [ { "post_id": "1849...", "author": "...", "text": "...",
                       "likes": 1532, "created_at": "..." } ],
        "next_cursor": "...", "partial": false }
  400 Bad Request     empty query, or more than 10 words
  410 Gone            cursor snapshot expired; start again

The decision worth arguing is pagination. For sort=recent, post ids are time-ordered (see Unique IDs), so the cursor is just "ids smaller than the last one shown": new posts never shift a page. For sort=likes, a post can gain 500 likes between page 1 and page 2, so the server ranks once, keeps the ordered ids for 10 minutes and the cursor names that snapshot. The partial flag tells the app that some shards timed out and the list may miss a few old posts: a degraded answer beats an error.

Your turn: Model the data

What does the index store for each post, and where do like counts live so that the likes sort works without rewriting the index?

Compare with a model answer

The index stores the post id, its tokens and a coarse like tier. Exact counts live in a separate key-value store keyed by post id, read at query time. The posts database stays the source of truth for text and deletion.

Model the data

Records
posts DB (source of truth, partitioned by post_id)
  post_id     64-bit, time-ordered     text        up to 1 KB
  author_id   64-bit                   status      live | deleted
 
index document (one per post, never stores the text)
  post_id     64-bit                   tokens      ["ferry", "strike", ...]
  like_tier   0..6                     -- 0: 0-9 likes, 1: 10-99, 2: 100-999 ...
 
index layout
  one index per day  ->  split into N shards by hash(post_id)
 
like_counts (key-value, partitioned by post_id)
  post_id -> count, last_offset

Three stores, three access patterns. The posts database is read by key to fetch the text of 20 results; a wide-column or key-value store fits. The search index answers "which ids contain all these words", and needs a search engine such as Elasticsearch or a purpose-built inverted index. The like count store answers "how many likes do these 2,000 ids have" in one batch; an in-memory key-value store such as Redis, backed by a durable table, fits.

Your turn: High-level design

Sketch how a new post and a like reach the index and count store, and how a search request is answered.

Compare with a model answer

Posts and likes flow as events from a stream. Indexers add posts to today's index; an aggregator batches likes into the count store and tells the indexers only when a post changes tier. A search service checks a query cache, fans out to the index shards, reads counts if sorting by likes, and fetches text from the posts database.

High-level design

Writes arrive as events and never block search. A query goes to the cache, then to the recent index shards, and only reaches the cold index when the recent ones cannot fill a page.App to Search service (GET /search); Search service to Query cache (get / set); Search service to Recent index (7 days) (top 20 per shard); Search service to Older index (if page not full); Search service to Posts DB (text, deleted?); Search service to Like counts (counts); Post + like events to Like counts (likes, batched), asynchronous; Post + like events to Indexers, asynchronous; Indexers to Recent index (7 days) (posts, tier moves); Indexers to Older index (roll-over, tier moves).AppQuery cacheSearch servicePosts DBLike countsRecent index (7days)Older indexPost + likeeventsIndexersGET /searchget / settop 20 per shardif page not fulltext, deleted?countslikes, batchedposts, tier movesroll-over, tier moves

request and replyasynchronous message

Writes arrive as events and never block search. A query goes to the cache, then to the recent index shards, and only reaches the cold index when the recent ones cannot fill a page.
A post, its likes, a search, and two failuresUse the arrows or ← → keys
  1. A post, its likes, a search, and two failures, step 1App to Search service; Search service to Query cache; Search service to Recent index (7 days); Search service to Older index; Search service to Posts DB; Search service to Like counts; Post + like events to Like counts, asynchronous; Post + like events to Indexers, asynchronous; Indexers to Recent index (7 days); Indexers to Older index.AppQuery cacheSearch servicePosts DBLike countsRecent index (7days)Older indexPost + likeeventspost 9120Indexers

    1Ana posts "Ferry strike again today". The post service writes the posts database; change data capture publishes the post to the event stream.

  2. A post, its likes, a search, and two failures, step 2App to Search service; Search service to Query cache; Search service to Recent index (7 days); Search service to Older index; Search service to Posts DB; Search service to Like counts; Post + like events to Like counts, asynchronous; Post + like events to Indexers, asynchronous; Indexers to Recent index (7 days); Indexers to Older index.AppQuery cacheSearch servicePosts DBLike countsRecent index (7days)9120 in tier 0Older indexPost + likeeventsIndexers

    2An indexer analyses it into ferry, strike, again, today and adds it to the in-memory buffer of one of today's shards. The buffer becomes a searchable segment within a second.

  3. A post, its likes, a search, and two failures, step 3App to Search service; Search service to Query cache; Search service to Recent index (7 days); Search service to Older index; Search service to Posts DB; Search service to Like counts; Post + like events to Like counts, asynchronous; Post + like events to Indexers, asynchronous; Indexers to Recent index (7 days); Indexers to Older index.AppQuery cacheSearch servicePosts DBLike counts9120: 1,100Recent index (7days)Older indexPost + likeeventsIndexers

    3The post takes off: 300 likes in one second, from 800 to 1,100. The aggregator sums them and writes count += 300 once. The index is not touched.

  4. A post, its likes, a search, and two failures, step 4App to Search service; Search service to Query cache; Search service to Recent index (7 days); Search service to Older index; Search service to Posts DB; Search service to Like counts; Post + like events to Like counts, asynchronous; Post + like events to Indexers, asynchronous; Indexers to Recent index (7 days); Indexers to Older index.AppQuery cacheSearch servicePosts DBLike countsRecent index (7days)9120 in tier 3Older indexPost + likeeventsIndexers

    4Passing 1,000 likes moved it from tier 2 to tier 3, so the aggregator publishes one tier change and an indexer updates one document. So far that has happened at 10, 100 and 1,000 likes, not 1,100 times.

  5. A post, its likes, a search, and two failures, step 5App to Search service; Search service to Query cache; Search service to Recent index (7 days); Search service to Older index; Search service to Posts DB; Search service to Like counts; Post + like events to Like counts, asynchronous; Post + like events to Indexers, asynchronous; Indexers to Recent index (7 days); Indexers to Older index.AppQuery cachemissSearch servicePosts DBLike countsRecent index (7days)Older indexPost + likeeventsIndexers

    5Leo searches "ferry strike", newest first. The service normalises the query and checks the cache: a miss.

  6. A post, its likes, a search, and two failures, step 6App to Search service; Search service to Query cache; Search service to Recent index (7 days); Search service to Older index; Search service to Posts DB; Search service to Like counts; Post + like events to Like counts, asynchronous; Post + like events to Indexers, asynchronous; Indexers to Recent index (7 days); Indexers to Older index.AppQuery cacheSearch servicePosts DBLike countsRecent index (7days)20 per shardOlder indexPost + likeeventsIndexers

    6It asks every shard of today's index for its 20 newest matches. Each shard walks its lists from the newest id down and stops at 20, so the work does not grow with the number of old matches.

  7. A post, its likes, a search, and two failures, step 7App to Search service; Search service to Query cache; Search service to Recent index (7 days); Search service to Older index; Search service to Posts DB; Search service to Like counts; Post + like events to Like counts, asynchronous; Post + like events to Indexers, asynchronous; Indexers to Recent index (7 days); Indexers to Older index.AppQuery cacheSearch servicePosts DBLike countsRecent index (7days)hedgedOlder indexPost + likeeventsIndexers

    7One shard's replica is slow. After 50 ms with no answer, the service sends the same request to another replica and uses whichever answers first.

  8. A post, its likes, a search, and two failures, step 8App to Search service; Search service to Query cache; Search service to Recent index (7 days); Search service to Older index; Search service to Posts DB; Search service to Like counts; Post + like events to Like counts, asynchronous; Post + like events to Indexers, asynchronous; Indexers to Recent index (7 days); Indexers to Older index.AppQuery cacheSearch servicePosts DB1 deletedLike countsRecent index (7days)Older indexPost + likeeventsIndexers

    8It merges the shard lists into the newest 20 overall, fetches their text, and drops one post deleted a minute ago. The page never needed the older index.

  9. A post, its likes, a search, and two failures, step 9App to Search service; Search service to Query cache; Search service to Recent index (7 days); Search service to Older index; Search service to Posts DB; Search service to Like counts; Post + like events to Like counts, asynchronous; Post + like events to Indexers, asynchronous; Indexers to Recent index (7 days); Indexers to Older index.AppQuery cacheset, 15 sSearch servicePosts DBLike countsRecent index (7days)Older indexPost + likeeventsIndexers

    9The ranked ids go into the cache for 15 seconds. During a breaking story, the next thousands of identical searches are cache hits.

  10. A post, its likes, a search, and two failures, step 10App to Search service; Search service to Query cache; Search service to Recent index (7 days); Search service to Older index; Search service to Posts DB; Search service to Like counts; Post + like events to Like counts, asynchronous; Post + like events to Indexers, asynchronous; Indexers to Recent index (7 days); Indexers to Older index.AppQuery cacheSearch servicePosts DBLike countsRecent index (7days)Older indexPost + likeeventsbacklog 60 sIndexersdown

    10An indexer crashes. New posts wait in the stream and become searchable late; nothing is lost. On restart it resumes from its saved offset and skips any post version it already applied.

1 / 10

Deep dives

Interviewers push on four things: indexing at 5,800 posts a second, sorting by likes, sharding, and keeping popular queries cheap.

Indexing a firehose

An inverted index is cheap to read and expensive to change in place, because each postings list is sorted and compressed. So the indexer never edits lists. It buffers new posts in memory and writes them out as a small, immutable segment every second; a search reads all segments of a shard. A background job merges small segments into bigger ones, the same idea as the log-structured storage in a key-value store. Deletes are recorded as a list of dead ids and removed for real during merges.

Time-ordered post ids pay off twice here. New posts always have the largest ids, so they append to the end of every list. And a newest-first query can walk lists backwards and stop early. Step through it on one shard.

Newest 2 matches for "ferry strike"Use the arrows or ← → keys
  1. ferry
    0102
    1340
    2512
    3777
    4901
    5950
    strike
    088
    1340
    2615
    3777
    4950

    1Postings for both words, sorted by post id. Higher id means newer. We want the 2 newest posts that contain both.

  2. ferry
    0102
    1340
    2512
    3777
    4901
    5950i
    strike
    088
    1340
    2615
    3777
    4950j
    found
    950

    2Start at the end of both lists. 950 and 950: a match.

  3. ferry
    0102
    1340
    2512
    3777
    4901i
    5950
    strike
    088
    1340
    2615
    3777j
    4950
    found
    950

    3901 against 777. The larger one cannot be in the other list from here on, so step past 901.

  4. ferry
    0102
    1340
    2512
    3777i
    4901
    5950
    strike
    088
    1340
    2615
    3777j
    4950
    found
    950, 777
    entries read
    5

    4777 and 777: a second match. We have 2, so we stop. The six older entries were never read.

1 / 4

Within a second of a post arriving, it is in a segment; the 10-second freshness goal leaves room for the stream's own delay. At 17,000 posts a second at peak and 30 tokens each, the fleet adds about 510,000 postings a second, spread over the shards of today's index.

Sorting by likes without rewriting the index

Rewriting a document per like means 116,000 index updates a second, 20 times the post rate. The fix is to separate "roughly how popular" from "exactly how many".

Keeping like counts sortable
OptionGood atBad atPick it when
Update the document on every likeThe index always has the exact count; sorting is built in.116,000 delete-and-reinsert operations a second, mostly for posts nobody is searching for.Counts change rarely, like product ratings.
Counts outside the index onlyLikes never touch the index. Exact counts at query time."Most liked posts about weather" may match 10 million posts, and every one needs a count lookup before you can sort.Queries match few documents.
Periodic bulk refreshRewrite counts for changed posts every 10 minutes in one batch.Still rewrites every liked post, and a breaking post sits at its old count for up to 10 minutes.Freshness of popularity barely matters.
Tiers in the index, exact counts outside Usual defaultThe index holds a tier (powers of 10) that changes only a few times in a post's life; exact counts sort within a tier.Two stores to keep in step. A post that just crossed a tier is misplaced for a few seconds.High like rates and broad queries, as here.

With tiers, a post with 1 million likes has changed tier 6 times (at 10, 100, 1,000, 10,000, 100,000 and 1 million), not a million times. Most posts cross none or one boundary; even if every post crossed two, that is 2 × 5,800 = 11,600 updates a second, a tenth of the like rate.

A likes query searches the highest tier first, within the time window. Suppose "ferry strike" matches 4 posts in tier 4 and 150 in tier 3. Those 154 are the only possible top 20, since every lower-tier post has fewer likes. The service fetches their 154 exact counts in one batch, sorts, and returns 20. Lower tiers are never read. The order is exact except for a post whose tier update is a few seconds behind, which the requirements allow. A broad word over 30 days can put 50,000 matches in one tier; then each shard sends only the newest few hundred from that tier, which bounds the work and makes the order approximate. Finer tiers (half powers of ten) shrink that case for twice the tier updates.

The counts themselves are batched as in Design Photo Sharing: the aggregator sums likes per post each second and stores the last applied stream offset with the count, so a replay cannot add twice.

Sharding the index

Split by time first, then by document. Each day gets its own index, split across shards by hash of post id, as Search Systems recommends: every shard holds a fair slice, and a post touches one shard. Time first has three benefits. Writes all go to today's index, which can sit on more machines. Newest-first queries read only the latest days unless they need more. And an old day's index never receives new posts, so it can be merged into one large segment and moved to cheaper machines.

The cost is fan-out. A query that needs a year reads 365 day indexes times their shards. So older days are rolled into week and month indexes, and likes queries default to a window of 30 days; "all time" is allowed but slower.

With 20 shards per query, the slowest shard sets the latency. If one shard is slow 1% of the time, a query that waits for all 20 is slow 1 − 0.9920 ≈ 18% of the time. That is why the tracer sent a hedged request to another replica after 50 ms.

Hot and cold data, and caching

The recent 7 days, 525 GB of index, live in memory on fast machines with 3 replicas, because nearly every newest-first query ends there. Older data, about 27 TB a year, lives on SSDs with fewer replicas. If the cold tier is down, the service returns recent results with partial: true.

Queries are skewed: during a news event, thousands of people type the same words. Cache the ranked list of ids, keyed by normalised query, sort and window, for 15 seconds, short enough that new posts appear. Also cache per-shard results for days that are over: an old day gains no new posts, so its answer for "ferry strike" changes only through deletes and tier moves, and can be kept for an hour. A deep page then costs one live query on today's shards and cache reads for the rest. Deleted posts are filtered when the text is fetched, so a cached list can never show one.

Bottlenecks and failures

At 10x (5 billion posts a day), today's index takes 170,000 posts a second at peak, so it needs about 10 times the shards, and the recent 7 days grow to 5 TB of memory. The like stream grows to 1.2 million events a second; it is partitioned by post id so aggregators scale out. The first thing to hurt is usually fan-out latency, which you fix with fewer, larger rolled-up indexes for old data and narrower default windows.

When this diesWhat happensWhat you do
An indexerNew posts become searchable late.The stream keeps events; it resumes from its offset and skips versions already applied.
A recent-index replicaIts shard has fewer copies.Queries go to the other replicas; rebuild from the stream and the posts database.
A whole shardAbout 1 in 20 of that day's posts is missing.Return partial: true rather than an error, and page someone.
The like count storeLikes sort cannot order within a tier.Sort by tier and then recency; mark the result approximate.
The query cacheEvery search reaches the shards: 7,000 a second at peak.Size the shards for it; the cache is a saving, not a requirement.
The index as a wholeSearch is down.Rebuild from the posts database, newest days first, so useful results return within hours.

What a strong answer sounds like

LevelWhat the answer does
JuniorPuts posts into a search engine, sorts by date or likes in the query, and adds a cache.
MidFeeds the index from a change stream, shards by document, notices that likes change constantly and keeps counts outside the index.
SeniorLeads with the 50-to-1 write ratio, shards by time then document, uses early termination for newest-first, uses tiers for likes with the arithmetic, caches closed days, and explains tail latency from fan-out.
Sorting by likesWeak answer
Interviewer

How do you sort results by likes?

You

Store the like count in the index and update it when someone likes a post.

Sorting by likesStrong answer
Interviewer

How do you sort results by likes?

You

Likes come in at 116,000 a second, so they can't rewrite the index. I'd index a tier, powers of ten, which changes a few times per post, and keep exact counts in a key-value store. A query takes the top tiers until it has at least 20 candidates, looks up those counts and sorts. The cost: a post that just crossed a tier is misplaced for a few seconds.

Follow-up questions

How do you add relevance ranking?

Fetch a few hundred candidates per shard by a cheap score (text match plus tier plus recency), then re-rank the merged list with a richer model in the search service. The two-stage shape keeps expensive scoring to a few hundred posts.

A post is deleted. How fast does it disappear?

Immediately for users: the service filters ids whose status is deleted when it fetches text. The index entry is marked dead within seconds and physically removed at the next merge.

How would you search hashtags or a single user's posts?

A hashtag is just another token. "From this user" adds an author_id field to the document and a filter in the query.

How do you change the analyser, say to add stemming?

Build new indexes alongside the old ones, newest days first, by replaying from the posts database, then switch reads one day at a time. Old and new can run side by side because each day is a separate index.

At 75 GB of new index a day and 5,800 inserts a second, a single database's text index cannot keep up or be split by time cleanly. For a few million posts it would be the right first answer.

Self-review: post search0 of 13

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure