Design Post Search
Keyword search over a billion users’ posts, searchable seconds after posting, sorted by recency or likes, hiding every post the searcher may not see.
Last updated 2026-10-26. Difficulty: hard. Patterns: search, real-time, privacy, counting, time-partitioning. Reported at Meta, X, LinkedIn, Reddit, Pinterest, TikTok.
The interviewer asks, the candidate answers and draws, and you press Next. Skip to any stage or key moment, and ask the AI Mentor anything along the way.
Or sit it in the style of Meta · LinkedIn · Pinterest · TikTok, run as that company’s round is reported to go.
The short answer
How to design Post Search in a system design interview, in five steps:
- Scope it. Search posts by keywords, sort by Recent or by Top, new posts are searchable within seconds, likes and comments move posts in Top, results only include posts the searcher may see, deletes, edits and audience changes apply quickly, page through results.
- Set the targets. Never show a post the viewer cannot see: 0 leaks; Freshness: searchable p99 < 10 s; Query latency: p99 < 500 ms; Scale: 50 k searches/s · 1 M likes/s.
- Do the numbers. Post writes: ~6 k/s avg · ~15 k/s peak; Like events: ~290 k/s avg · ~1 M/s peak; Like-bucket changes: ~15 k/s avg · ~50 k/s peak.
- Draw the design. API gateway, Post service, Post store, Change stream, Indexer, Hot index tier, Archive tiers, Segment builder, Search service.
- Go deep where it is hard.
- Searchable within seconds → Change stream into an append-only in-memory segment, sealed and merged later.
- Partition by time, then by post id → Time tiers (hot week in RAM, weekly and monthly slices on SSD), each hash-partitioned by post id.
- Likes at a million a second → Coarse log2 bucket in the index, updated on crossings; exact counts for finalists from a counter store.
- Only what this viewer may see → Filter in the index with audience fields and the viewer’s friend set, then an exact check on every result.
- Deletes, edits and blocks → Exact check at hydration, deleted-bit tombstones within seconds, delta segments for edits, purge on merge.
- Caching when results are personal → Cache only the public candidates per query; apply blocks and merge the viewer’s friends branch per request.
Functional requirements
- Search posts by keywords. Words, hashtags and quoted phrases, all terms required. The same analyzer runs on posts and on queries, so "Earthquakes" finds "earthquake" and a hashtag stays one term.
- Sort by Recent or by Top. Recent is newest first; Top weighs likes, comments, recency and closeness to the searcher. Two orderings with different access patterns, and one index has to serve both.
- New posts are searchable within seconds. People search what is happening right now: a goal, an outage, an earthquake. An index that is minutes behind misses the reason the search was typed.
- Likes and comments move posts in Top. A post that takes off rises within about a minute, and results show current counts. Likes arrive at a million a second, which is the constraint.
- Results only include posts the searcher may see. Public posts, friends-only posts by the searcher’s friends, custom audiences that include them, and their own posts. Nothing by anyone they blocked or who blocked them.
- Deletes, edits and audience changes apply quickly. A deleted post stops appearing at once. An edit must stop matching the words it removed, or search reveals what was taken out.
- Page through results. Infinite scroll with a cursor. Posts arriving in the meantime must not duplicate or skip results on the next page.
- Out of scope. People, page and group search; typeahead (its own question); spelling correction and semantic retrieval; ads in results; how posts are stored beyond what search needs.
Non-functional requirements
- Never show a post the viewer cannot see (0 leaks). The requirement behind the hardest tradeoff. A post missing for a few seconds is a shrug; a friends-only post shown to a stranger is a privacy incident. So results are personal, every one is re-checked against the source of truth, and caching is limited to what is the same for everyone.
- Freshness (searchable p99 < 10 s). Typically 1–2 s from commit. Rules out batch builds and periodic bulk refreshes: a post has to flow from the write path into an in-memory segment.
- Query latency (p99 < 500 ms). About 150 ms for the index fan-out, the rest for viewer context, counts and hydration. Latency follows the slowest partition, so partitions are replicated and requests hedged.
- Scale (50 k searches/s · 1 M likes/s). Peak rates, against 15 k posts/s. Likes outnumber post writes by about 65 to 1, so the engagement path, not the post path, is the write-heavy one.
- Corpus (~180 B posts/yr, kept for life). Tens of billions in any recent quarter, hundreds of billions overall. Most queries only need the last week, which is what makes tiering by time pay.
- Engagement in ranking (within ~1 min). Top reflects a surge in likes within a minute. Counts shown on results are read live at query time, so they are seconds fresh even where the index is not.
- Deletion (hidden at once · purged in days). Hidden from the moment the delete commits, out of the index within seconds, physically gone from every segment and snapshot within the purge deadline.
- Availability over completeness (99.95 %). Partial results beat an error: if a partition is slow, return what the others found and say so. This never extends to visibility, which is not negotiable.
Back-of-envelope estimates
- Post writes: ~6 k/s avg · ~15 k/s peak. 500 M posts/day ÷ 86 400 ≈ 5.8 k/s; evening peaks run ~2.5× that, ~15 k/s. Spread over 12 hot partitions that is ~1.25 k appends a second each. Writing new posts into the index is not the hard part.
- Like events: ~290 k/s avg · ~1 M/s peak. 1 B daily users × ~25 likes each = 25 B/day ÷ 86 400 ≈ 290 k/s, peaking near 1 M/s during live events. That is ~65× the peak post rate. This is the stream that must never rewrite an index document.
- Like-bucket changes: ~15 k/s avg · ~50 k/s peak. With power-of-two buckets, a post with n likes changes bucket only ~log2(n) times: 20 times on the way to a million. Most posts get a handful of likes and most likes land on a few popular posts, so assume ~2.5 changes per post over its life: 500 M × 2.5 ÷ 86 400 ≈ 15 k/s, about 20× fewer writes than likes.
- Index growth: ~100 GB/day · ~36 TB/yr. ~25 distinct terms per post with positions, compressed, ≈ 150 B, plus ~50 B of per-post fields (post id, author, time, audience, buckets) ≈ 200 B/post. 500 M × 200 B = 100 GB/day, ~36 TB/yr per copy before replicas.
- Hot tier size: ~700 GB · 12 partitions. Last 7 days = 3.5 B posts × 200 B ≈ 700 GB per copy. At ~60 GB of index per server that is 12 partitions. Small enough to hold entirely in RAM, which is what makes seconds-fresh appends and fast posting walks possible.
- Hot tier servers: ~240. Every query touches every hot partition. At ~2.5 k queries/s per replica (32 cores, ~10 ms of CPU per query), 50 k/s needs 20 replicas × 12 partitions = ~240 servers. Data size decides the partition count; query rate decides the replica count, separately for each tier.
- Viewer context per query: ~2.4 KB typical · 40 KB max. ~300 friends × 8 B ids = 2.4 KB; at the 5 000-friend cap, 40 KB, sent to each of 12 or more partitions. A Bloom filter at 1 % false positives needs ~10 bits per id, ~6 KB for 5 000 friends, and is safe only because the final check is exact.
- Exact-count reads: ~5 M keys/s peak. 50 k searches/s × ~100 finalists each = 5 M counter reads/s, batched into one multi-get per counter shard. Fetching counts for every match instead (a common term matches millions of posts) is what makes ranking on exact likes impossible at query time.
Components
- App / web: The search box and result list. Sends the query with a sort (Recent or Top) and an opaque cursor for the next page. The counts it shows come from hydration at query time, never from the index.
- API gateway: Authenticates the viewer and takes the viewer id from the session, never from the request, because every visibility decision downstream trusts it. Rate-limits searches per user and routes posts, likes and searches to their services.
- Post service: Creates, edits and deletes posts. Assigns a time-ordered post id (Snowflake-style, so the id carries the creation time), validates the audience, and commits the row. Search is not on this path, so a search outage cannot stop anyone posting.
- Post store (sharded by post_id): Source of truth for posts: text, author, audience, created_at, version, deleted flag, and a coarse like bucket. The index is derived from it and can always be rebuilt from it. The privacy check reads it for every result, so the index’s copy of visibility never has the final word.
- Change stream (Kafka · CDC, key = post_id): Committed post changes from CDC on the post store, keyed by post id so a post’s create, edits, bucket changes and delete stay in order. Indexers write analysed documents to one log per index partition, and every replica of that partition tails it: the log is the replication mechanism and the recovery path.
- Indexer (stateless consumers): Analyses text (tokenise, lowercase, hashtags and mentions as whole terms, positions for phrases) and builds the index document. Routes every event by the creation time in the post id and hash(post_id): a new post to the hot tier, an edit or delete to whichever tier owns the original. Idempotent on (post_id, version).
- Hot index tier (7 days in RAM · 12 × 20): The last 7 days, 12 partitions by hash(post_id), 20 replicas each. A single writer appends to a live in-memory segment while readers walk it lock-free up to a published watermark, so a post is searchable the moment it is appended, and postings are walked newest first. Like buckets, audience and deleted bits sit in per-document arrays that change in place.
- Archive tiers (weekly / monthly slices, SSD): Everything older than a week, in time slices (weekly for the last quarter, monthly before), each partitioned by hash(post_id). Posting lists on SSD, per-document arrays in RAM. Recent slices get more replicas because they are queried more, and each slice records its time range and per-term maximum engagement so queries can skip it.
- Segment builder (batch merges): Merges hot segments into the weekly slice once they age out of the hot window, and compacts weekly slices into monthly ones. Every merge writes only live documents and folds in edits, which is where deletion becomes physical. Writes slices to object storage for archive servers to load, and can rebuild any slice from a post store snapshot when the analyzer changes.
- Search service (plan · fan out · merge): Analyses the query with the indexer’s analyzer, loads the viewer context, and plans by sort: Recent walks tiers newest first and stops when the page is full; Top prunes slices by their engagement upper bounds. Merges partition results, fetches exact counts for finalists, re-ranks, and sends the page to the privacy check. Returns partial results with a flag rather than an error.
- Result cache (public slice · ~10 s): For hot queries only: (normalised query, sort, language) to the top ~500 public candidates with scores, kept about 10 s. It never holds friends-only posts or anything per viewer, so sharing it cannot leak; blocks and the friends branch are applied per request after the read.
- Social graph (friends · blocks, cached): Friend lists, blocks in both directions (who I blocked, who blocked me) and custom audience lists, in a cached graph store. Search reads the viewer’s context once per query, so an unfriend or a block applies to the very next search with nothing reindexed.
- Engagement counters (by post_id · in memory + log): Like and comment edges plus an exact counter per post, sharded by post id and held in memory with a durable log. The counter moves only when an edge is new, so a retried like counts once. A viral post’s counter is split into sub-counters summed on read; search reads ~5 M counts a second at peak for finalists.
- Like service (likes · comments): Takes likes and comments, writes the edge and increments the counter in the engagement store. Only when the new count crosses a power of two does it write the post’s like bucket to the post store, which turns ~1 M likes a second into ~50 k small row updates.
- Privacy check (canonical can-see rules): The one implementation of "can this viewer see this post", shared with feed and profile. Reads each result’s current row (deleted, audience, version, text) and checks it against the viewer context, blocks in both directions, and whether the current text still matches the query. The index may be stale in either direction; this check makes the dangerous direction harmless.
User flows
- A post becomes searchable in seconds. The core write. The post commits to its own store first; search hears about it from the change stream and makes it visible in an in-memory segment without ever blocking the writer.
- The client publishes a post; the post service assigns a time-ordered id and commits the row. The id is Snowflake-style, so its high bits are the creation time and anything downstream can route by time without a lookup. Search is not on this path: with every index server down, posting still works and search catches up from the stream.
- Change data capture publishes the committed row to the change stream, keyed by post id. Reading the post store’s commit log means an event exists if and only if the row committed, with no dual write in the post service. Keying by post id keeps one post’s create, edits, bucket changes and delete in order. Typical lag is a few hundred milliseconds.
- An indexer analyses the text and builds the index document. Tokenise, lowercase, keep hashtags and mentions as whole terms, detect the language, and record positions for phrase queries. The analyzer is versioned because the query side must run the same one; changing it means rebuilding slices. The document carries only the fields queries filter and rank on.
- The indexer writes the document to the log of the hot partition that owns it. One log per index partition, and all 20 replicas of the partition tail the same log, so they converge without talking to each other. The log is also how a new replica catches up: it copies sealed segments from a peer and replays from their offset.
- Each replica appends it to the live in-memory segment and advances the searchable watermark. One writer thread appends to the posting lists; query threads read lock-free up to the last published document count, so there is no refresh step and no reader ever sees half a post. Twitter’s Earlybird paper describes this single-writer, many-reader design. Commit to searchable is typically 1–2 s.
- A Recent search a moment later meets it first, because postings are walked newest first. Documents get increasing ids in arrival order, so walking a posting list backwards is walking back in time. The newest post is the first thing a query sees, which is why being seconds fresh costs nothing on the read side.
- When the live segment fills, it is sealed into a compact read-only segment. Roughly an hour or two of posts per partition at peak. Sealing rewrites the postings into a compressed, read-optimised layout and opens a fresh live segment; queries read both. After 7 days, sealed segments leave the hot tier and are merged into an archive slice.
- Search "earthquake", sorted by Recent. The core read. Visibility is applied inside the index walk using the viewer’s friend set, the walk stops as soon as a page is full, and every result is re-checked against the source of truth before it is shown.
- The client sends the query; the search service analyses it with the indexer’s analyzer. The same analyzer version as the index, or "Earthquakes" and "earthquake" stop meeting. The viewer id comes from the session at the gateway; a viewer id in the request would let anyone search as anyone.
- It loads the viewer context: friend ids, whom the viewer blocked, and who blocked the viewer. Read on every query from the graph cache, so an unfriend or a block applies to the next search with nothing reindexed. Both block directions matter: the viewer must not see posts by someone who blocked them. Typical size is ~300 friends, 2.4 KB; the cap is 5 000.
- The search service fans the query out to all 12 hot partitions with the visibility filter. Each partition walks postings newest first and admits a candidate only if its author is in neither block list and it is the viewer’s own, public, friends-only by a friend, or custom by a friend (flagged for the exact check). It stops after k admitted hits; most queries finish within a few thousand postings.
- If the hot tier cannot fill the page, the search continues into archive slices, newest first. A rare term may have three matches this week. The search service walks back one slice at a time and stops the moment it has a page, so a common term never touches the archive and a rare one touches only as many slices as it needs. That is early termination across tiers, and it only works because tiers are cut by time.
- The privacy check hydrates the page from the post store and re-checks every result. It reads each post’s current row: deleted, audience, version and text. A post whose audience narrowed a second ago, or whose author just blocked the viewer, is dropped here even though the index still has the old state. It also re-matches the query against the current text, so an edit that removed a word stops matching at once.
- Exact counts for the page come from the engagement counters, and the page returns with a cursor. Counts shown to the user are read live, never from the index. The cursor is (created_at, post_id) of the last result: the next page is a seek to "older than this" in time-ordered postings, so page 50 costs what page 1 costs and posts arriving meanwhile cannot shift it.
- A goal in the final: likes and searches spike. The scale-breaking case. A million likes a second must reach ranking without rewriting index documents, and thirty thousand people a second search the same word while each must see only what they may see.
- Likes pour in at a million a second; one post takes 50 k a second. Each like inserts a (user, post) edge and increments the counter on the same shard, and increments only if the edge was new, so a retry counts once. The viral post’s counter is split into 32 sub-counters summed on read, so one post does not saturate one shard.
- The like service writes the post’s bucket only when its count crosses a power of two. Bucket = floor(log2(likes + 1)), one byte. The viral post reaches bucket 17 at 131 k likes and bucket 18 at 262 k, 2.6 s later at 50 k/s, and every later gap is twice as long. Across all posts that is ~50 k bucket writes a second at peak instead of a million.
- The change reaches the post’s partition, which overwrites one byte in its bucket array. Buckets, audience and the deleted bit live in per-document arrays beside the postings, not inside them, so this is an in-place write, not a delete and reinsert. The segment’s per-term maximum bucket is raised too, which is what lets Top skip blocks that cannot win.
- Thirty thousand people a second search "goal"; the public candidates are computed once and cached. Key (normalised query, sort, language), value the top ~500 public posts with scores, kept ~10 s. Public posts are the only part of a result that is the same for everyone, so they are the only part shared. One request per key refreshes it under a single-flight lock while the rest read the stale copy.
- For each viewer, blocked authors are removed and the viewer’s friends-only matches are added. The friends branch is the same query restricted to authors in the viewer’s friend set, which partitions answer quickly because the author check runs before scoring. The two lists are merged by score. The expensive, shared part is cached; the cheap, personal part never is.
- Finalists get exact counts, are re-ranked, and pass the privacy check. Buckets are within a factor of two of the truth: good enough to choose the top 100, not good enough to order them, so exact counts decide the final order. Cached public posts go through the privacy check too, because one may have been switched to friends-only after it was cached.
- The indexer falls behind, then a replica dies. Failures should cost freshness or completeness, never correctness. Index lag means a new post appears late or a deleted one lingers in the index; the final check makes the second harmless.
- A bad indexer deploy leaves the hot partitions 90 seconds behind the change stream. Freshness is outside its target: new posts appear 90 s late. Alert on consumer lag in seconds per partition, and on an end-to-end probe that publishes a synthetic only-me post every few seconds per partition and searches for it until it appears.
- A post deleted 30 seconds ago is still in the index and matches a query. The tombstone has not been applied, so the partition returns the post. The privacy check reads the post store, sees it deleted, and drops it. Lag costs freshness and a result slot; it never shows something it should not, because the index never has the final word.
- A hot replica of partition 7 crashes; the search service routes around it. Nineteen replicas remain, each with ~5 % more load. Each partition request already goes to one replica and is hedged to a second after the 95th-percentile latency, so users notice nothing.
- A replacement copies sealed segments from a healthy peer and replays the partition log from their offset. About 60 GB over a 10 Gb/s link is under a minute, and replaying the live segment’s hour or two of posts takes another minute or two. The replica joins rotation only when its lag is under a second. Replaying the whole week from the log would take far longer, which is why sealed segments are copied, not rebuilt.
- If a whole partition is slow, the search returns what the others found, flagged as partial. Missing a twelfth of candidates for a few seconds is acceptable; an error page is not. The flag lets the client say results may be incomplete, and keeps the partial answer out of the result cache.
- Delete, edit and block. The privacy path. Removals are hidden at once by the final check, applied to the index as tombstones within seconds, and physically purged only when segments are merged.
- An author deletes a post from last year. The row is marked deleted in the source of truth. From this commit on, the privacy check drops the post from every result, before any index has heard of it. Everything after this keeps pages full and the index honest; hiding the post is already done.
- The indexer routes the delete to the archive slice that owns the post and sets its deleted bit. The post id carries its creation time, so routing needs no lookup: time picks the slice, hash(post_id) the partition. Slices are immutable on SSD, but their deleted-document bitmaps are in RAM and writable, so the post stops matching in seconds without rewriting anything.
- Another author edits a phone number out of a post; the old version is tombstoned and the new one indexed. If the old version kept matching, searching for the number would find the post and confirm what was removed. The new version goes into a small delta segment in the owning partition, which every query scans in full and merges by created_at, so the post keeps its place in time. The indexer ignores any event older than the version it has applied.
- Someone blocks a viewer; that viewer’s next search excludes all of the blocker’s posts. Nothing in the index changes, which is the point: blocks, unfriends and audience lists are read from the graph at query time, so they apply on the next request. Indexing per-viewer permissions would turn one block into a rewrite of every post the blocker ever made.
- Later, the builder merges the segments and drops tombstoned documents for good. Hot segments leaving the 7-day window are merged into their week’s slice, and older slices are compacted on a schedule; each merge writes only live documents and folds in the delta segments. Once a merged slice is loaded, its tombstones are released. A slice holding a deletion older than the purge deadline is rewritten even if nothing else would have triggered it.
Deep dives
Searchable within seconds
A post was published two seconds ago. How is it already in the results?
Indexing has to keep up with 15 k posts a second at peak on 20 replicas of every hot partition, and a new post must be searchable within 10 s at p99. A batch-built index is minutes to hours behind. A general-purpose near-real-time engine refreshes a write buffer into a small new segment every second and then spends its life merging those segments, and every change to a document is a delete plus a full reinsert.
Social posts have a shape that allows something simpler. They are written once, rarely edited, and almost always queried newest first. An index that only ever appends, in arrival order, can make each document visible the instant it is written, and walking it backwards is walking back in time. The things that do change often (likes, audience, deletion) can be kept out of the posting lists altogether.
- Change stream into an append-only in-memory segment, sealed and merged later chosen
- Index synchronously in the post write path rejected
- Off-the-shelf near-real-time engine (Elasticsearch / OpenSearch), 1 s refresh situational: up to a few billion documents and modest update rates, or a team that should not build a search engine; the shape (stream in, time-based indices) is the same
- Rebuild the index in batches every few minutes rejected
The answer: The post store is the source of truth, and CDC puts every committed change on a Kafka topic keyed by post id. Stateless indexers analyse the text and write documents to one log per index partition, and every replica of that partition tails it. Each replica appends to a live in-memory segment with a single writer thread; readers see documents up to a published count, so there is no refresh step and no locking. At a fixed size, roughly an hour or two of posts, the segment is sealed into a compact read-only layout and a new one opens; after 7 days sealed segments are merged into archive slices by a batch builder. Likes, audience and deleted bits live in per-document arrays beside the postings so they can change in place. Twitter’s Earlybird paper (ICDE 2012) describes this design for tweets: in memory, one writer with lock-free readers, postings traversed newest first.
An indexer crashes after writing half a batch. What happens?
It restarts from its last committed Kafka offset and writes those documents again. Every write carries (post_id, version) and a partition ignores a version it already holds, so the replay is harmless. Nothing is lost, because the offset is committed only after the partition log has the documents.
How do you know the freshness target is being met?
Measure it end to end, not per component. A prober publishes a synthetic only-me post routed to each partition every few seconds and searches for it until it appears; that gap is the freshness metric, per partition, with an alert above 10 s. Consumer lag per partition then explains where the time went.
Posts get ten times more frequent. What breaks first?
Not the append path: 150 k posts a second is ~12 k appends a second per partition, and adding partitions only affects the hot tier, which repartitions itself as it ages. RAM breaks first: the 7-day window becomes 7 TB per copy. So shrink the hot window to two or three days and give the first archive slice more replicas. Query cost per partition stays roughly flat, because early termination does not care how long a posting list is.
Why not let the post service write to the index directly and skip Kafka?
Because that is a dual write: the post commits and the index write times out, or the index has a post whose transaction rolled back, and nothing reconciles them. CDC from the commit log gives "the event exists if and only if the row committed" for free. Kafka also gives replay: a new replica, a new analyzer or a bug fix can read the stream again.
Events for one post arrive out of order: an edit before the create.
Keying by post id keeps one post’s events in order within a partition, so this mostly happens during replays and re-keys. Versions handle it anyway: a partition applies an event only if its version is newer than what it holds, and a tombstone is never overwritten by an older insert. An edit for an unknown post is parked briefly and retried, because the create is almost certainly right behind it.
Partition by time, then by post id
Hundreds of billions of posts. How do you split the index so "newest first" stays cheap?
The corpus grows by 500 M posts a day, ~180 B a year, but queries are lopsided: most people want the newest matches, and most queries can be answered from the last week. In one big hash-partitioned pool, every query fans out over every partition holding every year, and this hour’s posts share servers sized for years of history.
Time is the natural first cut because it is the sort key of the default ordering. If tiers are cut by creation time, a Recent query searches the newest tier first and stops when it has a page; older tiers are touched only when newer ones run out. Each tier can also be sized on its own: partitions follow data size, replicas follow query rate, so the newest week gets 20 replicas and a five-year-old slice gets two.
- Time tiers (hot week in RAM, weekly and monthly slices on SSD), each hash-partitioned by post id chosen
- One pool, hash-partitioned by post id rejected
- Partition by author situational: searching within one person’s or one page’s posts, where a query touches a single partition
- Term partitioning rejected
The answer: Three kinds of tier by creation time: the hot tier holds the last 7 days in RAM (12 partitions, 20 replicas); weekly slices cover the rest of the last quarter on SSD with four replicas; monthly slices cover everything older with two. Within every tier, documents are partitioned by hash(post_id), so a viral hour or a celebrity author spreads over all partitions. The time bits of the post id route any event to its owning slice without a lookup. The search service keeps a tier map (time range, partitions, replicas, per-term engagement maxima) and plans each query against it: Recent walks tiers newest to oldest and stops when the page is full; Top fans out wider but skips slices whose best possible score cannot make the page. It is the idea behind daily indices for logs, applied to a corpus that is almost always read newest first.
How many partitions does a Recent query actually touch?
For a common term, the 12 hot partitions and nothing else: each finds 25 visible matches in its first few thousand postings. A rare term walks back a slice at a time, the next slice requested only when the previous one comes back short, or the next two in parallel when latency matters more than load. Track fan-out per query: it tells you whether the hot window is the right length.
Why seven days for the hot tier?
It is a cost curve, not a rule. The window should be long enough that most queries finish inside it and short enough to fit in RAM at 20 replicas: 700 GB per copy, 14 TB across replicas. If 30 % of queries spill into the first weekly slice, either lengthen the window or give that slice more replicas, and pick whichever is cheaper.
A celebrity with 100 M followers posts. Does any partition get hot?
Not from the write: it is one document in one hot partition. The reads are a burst of searches for their name, and the matching posts are spread over all 12 partitions by hash(post_id), with the public result cache absorbing the repeats. Partitioning by author would have put the post and every one of those reads on one partition.
How do you change the number of partitions in a tier?
The hot tier repartitions by aging: write new posts with the new count, and within 7 days every hot partition is on the new layout. Archive slices are rebuilt by the builder into the new count one slice at a time, served from the old layout until the new one is loaded and checked. No live data moves between servers.
Old slices are rarely queried. Why keep them on servers at all?
Because people do search for old posts, and a slower query on SSD is acceptable where minutes from object storage are not. Old slices get two replicas on dense SSD servers, with posting lists memory-mapped and only the per-document arrays in RAM. If queries for a slice fall to almost nothing, it can be served from object storage behind a local cache, trading latency for cost.
Likes at a million a second
Top needs like counts. Why not just update the count on the index document?
Likes arrive at ~290 k a second on average and a million at peak, 65 times the post rate. In an inverted index a document’s fields are baked into its segment, and changing one means deleting the document and inserting it again, on all 20 replicas. A million reinserts a second is a million dead documents a second for merges to clean up, and merges would never catch up.
Ranking does not need an exact count for every document. It needs to know roughly how popular each candidate is, so that the right hundred posts reach the final round, and the exact count only for those hundred. Popularity is also logarithmic in practice: 3 likes against 4 does not matter, 300 against 300 thousand does. So the index can hold a coarse value that rarely changes, and exact counts can live in a store built for counting.
- Coarse log2 bucket in the index, updated on crossings; exact counts for finalists from a counter store chosen
- Update the index document on every like rejected
- Fetch exact counts at query time for every match situational: rare terms or narrow filters, where a query has a few hundred matches at most
- Refresh counts into the index in periodic batches situational: the oldest slices, if their per-document arrays ever have to leave RAM: likes on year-old posts barely change, and the next rebuild picks up the current value
The answer: The engagement store keeps the (user, post) like edges and an exact counter per post, sharded by post id, in memory with a durable log; the counter moves only when the edge is new, and a viral post’s counter is split into sub-counters summed on read. When a like moves a post’s count across a power of two, the like service writes bucket = floor(log2(likes + 1)) to the post row, CDC carries it to the owning partition, and the partition overwrites one byte in a per-document array; comments get their own bucket the same way. Each segment also keeps, per term and per block of 128 postings, the largest bucket it contains, raised on every increase and lowered only by a rebuild, so it is always a safe upper bound. Recent never needs any of this, because time order lets it stop early; Top cannot stop early, so it uses those upper bounds to skip blocks and slices that cannot reach the current top 100. The best ~100 across partitions get exact counts in one batched multi-get and are re-ranked.
The bucket can be off by 2×. Doesn’t that make Top wrong?
It makes the first stage approximate, and the first stage only has to get the right posts into the top 100, not order them. A post just under a boundary competes as if it had half its likes, which matters only at the edge of the 100; widen the finalist set if offline evaluation shows those misses. The final order uses exact counts.
A post goes from 10 likes to 100 k in a minute. How fast does it rise in Top?
Its bucket climbs from 3 to 16 within that minute, one step each time the count doubles, and each step reaches the index in a couple of seconds. So it sits near its correct rank within seconds of each doubling, with nothing waiting for a batch. The count shown on the result is exact, because it comes from the counter store at query time.
Someone likes and unlikes in a loop right at a boundary.
Increases apply at once; decreases apply lazily, only once the count falls below three quarters of the boundary, so flapping produces no writes. The per-term maxima are never lowered by an unlike anyway. The like service also rate-limits toggles per user, which is an abuse problem before it is an indexing one.
The counter store loses a shard.
The counts are derived: the like edges are the truth, and a counter is rebuilt by counting the edges for that shard’s posts. While it rebuilds, Top ranks those posts on buckets alone and the result shows an approximate count from the bucket. Ranking is a little worse for a few minutes; visibility is unaffected.
Why put the bucket on the post row instead of publishing it straight to the index?
So it travels on the same per-post ordered stream as edits and deletes, and the post store snapshot used to rebuild a slice already contains it. A separate topic would need its own ordering against tombstones, so a late bucket update cannot resurrect a deleted post, and its own rebuild path. The cost is ~50 k small row updates a second at peak, which a sharded post store absorbs.
Only what this viewer may see
Every searcher sees a different subset of posts. How do you never show one they cannot see, without a per-viewer index?
About half of posts are friends-only, a few percent have custom audiences, and anyone can block anyone. So the same query run by two people returns different results, and the difference depends on data that changes constantly: friendships, blocks, audiences. An index per viewer would mean writing every post into the index of each of the author’s ~300 friends, ~150 B index writes a day, and rewriting them on every unfriend.
The asymmetry that shapes the answer: showing a post late is harmless, showing it to the wrong person is not. So the index can use a fast and possibly stale filter to choose candidates, as long as an exact check against the source of truth runs on everything returned. The fast filter must be cheap per candidate and must not need reindexing when the graph changes, which means the viewer’s side of the check travels with the query.
- Filter in the index with audience fields and the viewer’s friend set, then an exact check on every result chosen
- Retrieve by relevance, then filter through a privacy service situational: as the final gate it is required; as the only filter it works only when most matches are public
- Per-viewer index, or audience lists copied into each document rejected
- Bloom filter of the friend set sent with the query situational: large sets (5 000 friends, or friends-of-friends audiences in the hundreds of thousands) where the exact set is too big to send to every partition
The answer: Every document carries author_id and an audience class: public, friends, custom or only_me. The search service reads the viewer context from the graph on each query: friend ids, plus blocked and blocked-by ids merged into one exclusion set. Partitions admit a candidate if its author is not excluded and it is the viewer’s own, public, friends-only by a friend, or custom by a friend, flagged because only the post knows its audience list. The friend set is sent exactly when it is under ~1 000 ids and as a Bloom filter above that, with the page margin covering the ~1 % the final check will drop. The privacy check then reads each result’s current row and runs the canonical can-see rules on current graph data; anything that fails is dropped and counted. Facebook’s Unicorn paper (VLDB 2013) describes the same division of labour for social-graph search: the in-memory index narrows candidates with graph terms, and the final privacy check runs after retrieval, in the front end.
I unfriended someone a second ago. Can I still find their friends-only posts?
No. The friend set is read from the graph on every query, so the next search no longer counts them as a friend and the partitions reject their friends-only posts. Even if a stale cache served the old set, the privacy check uses the current graph and drops them. Nothing about the post or the index has to change.
A query where almost every match is invisible: "happy birthday" from a viewer with 40 friends.
Most matches are friends-only posts by strangers, so the walk admits maybe one posting in fifty and scans more to fill the page. Each partition has a scan budget; when it runs out it returns what it has with a resume point, and the search service asks for more or returns a shorter page. For queries like this it is cheaper to flip the plan: take the viewer’s friends’ recent posts, a few thousand documents, and match the terms against those.
How would you know if the index ever let through something it should not?
Every drop at the final check is a near miss: the index admitted something the source of truth rejected. Track the drop rate by reason (deleted, audience, block, no longer matches); it should be small and explained by index lag. Separately, sample served results and re-verify them offline with an independent implementation of the rules; a disagreement there is a real leak and pages someone.
What about friends-of-friends audiences?
That set is the union of the viewer’s friends’ friend lists, often hundreds of thousands of ids, too big to send exactly. Either send a Bloom filter of it, about 1.2 MB for a million ids at 1 %, or skip the in-index check for that audience class and flag those candidates for the exact check, which asks whether viewer and author share a friend. Flagging is simpler and correct either way; the Bloom filter only pays off if the audience class is common.
Why not cache each viewer’s friend set on the partitions instead of sending it?
You can, keyed by (viewer, graph version), so a user paging through results sends it once. The risk is staleness: an unfriend must bump the version, or a partition keeps admitting posts it should not. The final check still catches that, so it is a performance optimisation with a correctness backstop, which is the only kind worth adding here.
Deletes, edits and blocks
Segments are immutable. A post is deleted, or edited to remove a phone number. How fast does it stop matching, and how?
Segments are immutable by design: the hot tier’s sealed segments and every archive slice on SSD. Rewriting a 60 GB partition to drop one post is out of the question, and deletes arrive continuously alongside edits and audience changes. There are three promises to keep: a deleted post is never shown, it stops matching quickly so pages stay full, and it is eventually gone from storage.
Edits are the subtle case. If the old version of an edited post keeps matching, a search for the removed text still finds the post, and anyone can confirm what used to be there. So for matching, an edit is a deletion of the old version plus an insert of the new one, and the insert must keep the post in its correct place in time.
- Exact check at hydration, deleted-bit tombstones within seconds, delta segments for edits, purge on merge chosen
- Rely on the hydration check alone rejected
- Rewrite the affected segment on each delete rejected
- Global exclusion list at the search service situational: account-level removals (deactivation, a mass takedown) where marking millions of documents one by one is slower than excluding the author everywhere at once
The answer: The privacy check reads the post store for every result, so a deleted or narrowed post is hidden from the moment its row commits. The delete then flows through CDC to the owning partition, chosen by the creation time in the post id and by hash(post_id), which sets the document’s bit in an in-memory deleted-documents bitmap: seconds, and nothing per query. An edit tombstones the old version and writes the new one into a small in-memory delta segment in the same partition, which every query scans in full and merges by created_at; versions make replays and reordering harmless. Account deactivation puts the author on an exclusion list at the search service until per-document tombstones land. The builder’s merges write only live documents and fold delta segments in, and a slice holding a deletion older than the purge deadline is rewritten regardless of schedule. Blocks never touch the index: they are per viewer, so they are applied from the graph at query time.
How long until a deleted post is gone from disk?
Hot segments are gone within 7 days, when they merge into the weekly slice without the deleted documents. Archive slices are compacted on a schedule, and any slice holding a deletion older than the purge deadline, say 30 days, is rebuilt regardless. Snapshots and old slice versions in object storage expire on the same deadline, which is the part people forget.
A user deactivates and their 40 000 posts must vanish from search.
Put their author id on the exclusion list the search service applies to every query, which hides everything in seconds with one write. Then emit per-post tombstones in the background and remove the author from the list once they have all landed. Reactivation is the reverse, so the list is also the fast path for undo.
How many deleted documents can a slice carry before it hurts?
Deleted documents are still in the posting lists, so they cost scan time. Track the deleted fraction per slice and compact when it passes ~10 %, sooner for slices that are queried a lot. Recent weeks have the most deletes, because people delete soon after posting, and are the most queried, so they are compacted most often.
An edit and a delete of the same post arrive out of order.
Each event carries the post’s version and a delete is a terminal version. A partition applies an event only if it is newer than what it holds, and a tombstone is never overwritten by an older insert. Because the stream is keyed by post id, this mostly matters during replays, which is exactly when it has to be right.
Why are blocks applied at query time rather than as tombstones?
A block is per viewer: the blocker’s posts are hidden from one person and visible to everyone else, so there is no document to tombstone. Read as an exclusion set from the graph on each query, it applies on the next search and costs one membership check per candidate. It is the same reason friendships are not in the index.
Caching when results are personal
Thirty thousand people a second search "goal". Can you cache the results?
During live events a handful of queries take a large share of the 50 k searches a second, and each fans out to at least 12 partitions and scans the busiest posting lists in the index. Caching the result page would remove most of that work. But the page is personal: it contains friends-only posts by the searcher’s friends, excludes authors they blocked, and was checked against their visibility.
Split the result into the part that is the same for everyone and the part that is not. Public posts are visible to everyone except people the author blocked, and blocks can be applied afterwards by removing authors. Friends-only and custom posts are personal, but for one viewer they come from a few hundred authors, which makes them a small, cheap query.
- Cache only the public candidates per query; apply blocks and merge the viewer’s friends branch per request chosen
- Cache full result pages per (query, sort) rejected
- Cache per (viewer, query) situational: pagination and repeated searches by the same person, kept for a minute or two
- No result cache, more replicas situational: traffic is flat and the head of the query distribution is thin
The answer: Key (normalised query, sort, language), value the top ~500 public candidates with their scores, kept ~10 s and refreshed by one request under a single-flight lock while the others read the stale copy. Only queries above a rate threshold are cached, which is what the trending detector is for. On each request the search service drops candidates whose authors are in the viewer’s exclusion set, runs the friends branch (the same query restricted to authors in the viewer’s friend set), merges the two by score, fetches exact counts for the finalists and sends the page through the privacy check. The cache can make public results 10 s old; it cannot make anything visible to the wrong person, because it holds only public posts and everything still passes the final check.
A public post in the cached list is switched to friends-only.
The privacy check reads the current audience at hydration and drops it for anyone who is not a friend, so the cache’s staleness costs a result slot for at most 10 s. You could also evict entries containing the post when its audience narrows, but with a 10 s TTL the bookkeeping is not worth it.
The key expires and 30 000 requests miss at once.
Single flight: the first miss takes a short lock and recomputes, and the rest read the stale value, which is kept a little past its TTL for exactly this. Without it, every expiry is a burst of identical 12-partition fan-outs at the worst possible moment.
Why is the friends branch cheap enough to run on every request?
For one viewer it covers about 300 authors and their recent posts, a few thousand documents, and the partition checks the author before scoring, so it skips most postings. The expensive part is the public scan, millions of postings for "goal", and that is what the cache saves. With 5 000 friends the branch is bigger, and it is still per viewer and never shared.
What hit rate do you expect, and how would you see the effect?
During a spike the top few hundred queries can be a third of traffic or more, so 30 % or better on the public list; on an ordinary day much less. Compare partition load and scanned postings for cached keys before and after, and watch the age of served cached results. If cache age approaches the freshness target, the TTL is too long for that query.
Could you cache at the CDN?
Not the response: it is personal and authenticated, so no shared proxy may cache it. The shareable artefact is the public candidate list, an internal object that never leaves the data centre. What a client can cache is its own last results, for back-navigation.
The theory behind it
- Search, indexing and autocomplete: Inverted indexes, analysis and ranking, prefix search with tries, filtering by permissions, index freshness, and when vector search actually belongs in the answer.
- Authorization and permission systems: Designing who can do what: authentication versus authorization, RBAC, ABAC and relationship-based access control, Google Zanzibar-style permission services, sharing and inheritance, checking permissions fast, filtering lists, and auditing.
- Counting at scale: How to count likes, views, clicks and unique visitors at high volume: exact versus approximate counts, sharded counters, batching through streams, idempotent counting, HyperLogLog for uniques, windowed counts and reconciliation.