Social Post Search System Design Interview Guide
Web search asks "which documents match this query?" Search over social posts asks a harder question: "which documents match this query and is this particular person allowed to see them?" The second half is where the design lives.
What the interviewer is testing
Search over user-generated posts looks, at first, like search over web pages with a smaller corpus. Tokenise the text, build an inverted index mapping each term to the posts containing it, shard it, rank the matches. That machinery is real and you will need it. But it is not what the question is about, and a candidate who spends the whole hour on it has answered a different question than the one asked.
The difference is that a web page is visible to everyone. A social post is not. A post may be public, or visible to the author's friends, or to friends except a named list, or to members of one group, or to nobody but the author. Two people running the identical query get different results, and the difference is not a matter of personalisation or ranking taste — it is a correctness boundary. Returning a post to someone entitled to see it is a feature. Returning it to someone who is not is an incident.
That single fact propagates through the entire design. It decides where the visibility check runs, and therefore how many candidates retrieval must produce to fill a page. It decides what can be cached, because a filtered result set belongs to one viewer and nobody else. It decides what "the index is stale" costs you, because everywhere else in this series stale data means a slightly wrong answer, and here it can mean a leak. The interviewer is watching for whether you notice that, and how early.
The second thing under test is whether you can reason about an index as a data structure rather than as a product you install. Some interviewers make this explicit by ruling out off-the-shelf search engines, precisely to see whether "we'd use Elasticsearch" is a design or a way of skipping one. The underlying question is the same either way: what is actually stored, what does a query do to it, and what does that cost at the corpus size you just estimated.
What the question becomes at each level
| Level | The core question | What moves you up |
|---|---|---|
| L4 | Can you build something that returns matching posts quickly — an inverted index, an ingestion path, a ranked read? | Noticing that visibility filtering exists at all, rather than designing a public web-search engine and stopping. |
| L5 | Where does the visibility check run, and what does that choice cost in candidates scanned per query? | Quantifying the over-fetch instead of asserting it, and knowing why the index is partitioned by document rather than by term. |
| L6 | What breaks at a trillion posts — fan-out tail latency, scoring drift across shards, freshness versus merge cost? | Treating a stale permission as a different class of bug from a stale score, and designing the recovery path for each separately. |
| L7/L8 | How do you evolve ranking, re-index a corpus this size, and prove the system does not leak through side channels? | Catching what the result count reveals, owning the migration story for a ranking change, and naming what you would monitor to detect a systemic leak. |
Where this post sits in the series
Several posts here border this one, and the boundaries are worth stating so you know which to read for what.
- Search autocomplete serves a precomputed top-k per prefix, built offline from query logs. Nothing is ranked at request time and every user sees the same suggestions. This post ranks at query time over a live corpus, and no two viewers necessarily see the same thing.
- Proximity search also runs a query engine over an index, but its corpus of businesses is uniformly visible to everyone, so its hard problem is spatial indexing. Strip the per-viewer filter out of this post and you are largely reading that one.
- Web crawler builds a corpus by discovering and fetching content the platform does not own. Here the content arrives on our own write path the instant it is created, so freshness is an indexing-lag problem rather than a re-crawl-scheduling problem.
- Twitter/X feed answers "what should this user see next" by fanning out along the follow graph. Search answers "find posts matching this query" across the whole corpus, with no follow edge required and no per-user timeline to fan into.
- Top-K leaderboard shares the phrase "top k" and almost nothing else: there, one numeric score dimension over a small key space; here, a multi-signal relevance score over a sharded index, computed per query.
The one hard problem. Every result must be checked against one viewer's permission to see it. Put that check in the index and it goes stale, and stale means a leak. Put it after retrieval and you no longer know how many candidates a page of ten results requires. The design is what you do about that.
Requirements: what "find my posts" actually has to do
Settle this early, because it changes the shape of the answer: we are building search over posts, not a full social-graph search engine. A query is text. Results are posts. Searching for people, pages, groups and events is a related problem with a different index and different ranking, and folding it in would double the surface without deepening anything.
Functional requirements
- Search posts by free-text query, with multiple terms treated conjunctively by default.
- Return only posts the requesting user is permitted to see, under the post's audience setting and the relationship between the two users.
- Rank by relevance, with recency as a strong signal and engagement as a weaker one; offer a "most recent" ordering as an explicit alternative.
- Paginate deterministically — a user scrolling to page three does not see page one's results again, and does not silently skip results.
- Reflect edits and deletions: a deleted post stops being findable, and an edited post is findable by its new text and not its old.
Explicitly out of scope, so the hour goes somewhere useful: media understanding (searching the contents of images and video), cross-language retrieval, data residency, and the ranking model itself. Residency is a genuine constraint at this scale — it makes a home region a partitioning key alongside time, and narrows which shards a query may legally touch — but it multiplies every section below without changing any of the decisions this post exists to make. We will design where ranking happens and how it is evaluated and rolled out, but not the feature set of the model — that is a machine learning system design question wearing this question's clothes.
Non-functional requirements
| Requirement | Target | Why this number |
|---|---|---|
| Search latency | p99 < 300 ms server-side | Search is an interactive, deliberate action — slower than a feed load can be, faster than a page navigation. |
| Visibility correctness | No result returned that the viewer may not see, including during failures | The only requirement here that is binary rather than statistical. |
| Freshness | A new post is findable within ~10 s | Users search for what they just saw. Beyond roughly ten seconds it reads as broken. |
| Availability | 99.9% for the search path | Search failing is a degraded product, not a lost write. The write path that feeds it needs more. |
| Durability of the index | Rebuildable, not durable | The index is derived state. Losing it costs a rebuild, not data. |
The last row deserves more weight than it usually gets. The index is not the system of record — the posts live in a primary datastore, and the index is a derived read structure built from them. That is what makes aggressive in-memory designs, lossy compression and shard-level partial results acceptable here in ways they would not be for the store itself.
p99 < 300 ms — what it means and what it buys ›
One request in a hundred is allowed to exceed 300 ms measured at the search service, excluding client network time. That budget has to cover a fan-out to every shard, the visibility check on the surviving candidates, and hydration of the page being returned — three sequential stages, not one lookup.
It sits at 300 ms rather than the 200 ms used for proximity search because this query path has an extra dependency that one does not: a call into an authoritative permission service that cannot be skipped or cached per viewer. The budget decomposition in §6 spends it explicitly.
Visibility correctness — the one requirement with no error budget ›
Every other target here is statistical: a percentage of requests, an amount of downtime per year. This one is not, and that asymmetry drives the design. A ranking bug shows a mediocre result. A visibility bug shows someone a post about their own health, or their location, to a person they blocked.
Stating it as an absolute is easy and slightly dishonest, so state the exception instead: the system must not return a post whose audience excludes the viewer according to the authoritative permission state at query time. What it cannot promise is that a permission changed one millisecond ago has already propagated to the service being consulted. The design goal is that the window is the permission service's replication lag and nothing more — in particular, not the index's refresh interval, which is orders of magnitude longer.
Freshness in ~10 s — and why not one second ›
Ten seconds from "post is committed" to "post is findable". The number is a product judgement, not a physical limit: someone searching for a post they saw a minute ago should find it, and someone searching for a post made while they were typing need not.
Real systems have gone tighter. Twitter's Earlybird reported roughly ten seconds of ingest-to-searchable latency in its 2012 paper, and Twitter later published work bringing indexing latency to about a second. Each step tighter costs merge pressure and write amplification, which is why the target here is a decision rather than an aspiration — §9b shows what buying a lower number costs.
99.9% availability, and why the index is rebuildable rather than durable ›
99.9% is about 43 minutes of downtime a month. That is a deliberately weaker target than the primary post store would carry, and it reflects what failure means: a user cannot search for a few minutes, which is annoying, versus a user's post is gone, which is unacceptable.
Treating the index as derived also changes the failure playbook. A corrupted shard is not restored from a backup of the shard; it is rebuilt from the post store, which is authoritative. That makes shard loss a capacity and time problem rather than a data-loss problem, and it is why §10 can treat a dead shard as degraded recall rather than an outage.
Capacity: what actually binds is candidates per query
The usual estimate for a storage-shaped system multiplies writes per day by row size by retention and calls the result the capacity problem. Doing that here produces a number that is large, correct, and beside the point. Index storage is a budgeting exercise. What constrains the design is how much work one query does, and that is governed by two quantities the storage math never mentions: how many shards the query touches, and how many candidates retrieval must produce before enough of them survive the visibility check to fill a page.
Start with the corpus, because it sets the shard count. Facebook's engineering team reported in 2013 that its posts index held more than one trillion posts, growing by about a billion a day. Those figures are old — treat them as the right order of magnitude for a platform of that size rather than as today's numbers — but they anchor the estimate well, and the defaults below reproduce them: a billion posts a day retained for three years is roughly 1.1 trillion posts.
Post text is small, so raw volume is modest by the standards of this series. The index is a fraction of it again: Lucene's own feature list puts index size at “roughly 20-30% the size of text indexed”, and the estimator takes the conservative end of that range. That 30% is the one constant here taken on external authority, and it comes with a caveat — it is a rule of thumb from the library's documentation rather than a measurement of any particular corpus, and the real ratio moves with your analyzer, your stored fields and your doc-values, so treat it as a planning figure, not a guarantee. §7b checks it against a published measurement on an actual corpus of short posts, decomposes what the bytes are, and reconciles the result with the much larger figure Facebook published for the same index. Everything else in the estimator is an input you can move, because the honest position is that platforms do not publish their average post length, their term counts after tokenisation, or their ratio of searches to posts.
Post search capacity estimator
The last two cards are the ones to sit with. A page of ten results does not cost ten candidates. It costs ten divided by the share of matches this viewer is allowed to see — and because the coordinator cannot know in advance which shard holds the survivors, every shard has to return that depth. The rows the coordinator merges is therefore shard count multiplied by retrieval depth, and both factors grow for reasons that have nothing to do with each other.
Drag the pass rate down to 2%, which is what a query matching mostly friends-only posts from strangers looks like, and the merged row count goes up by an order of magnitude while the page the user sees stays exactly ten results long. That is the whole problem in one number.
What this implies architecturally. The two levers on merged rows are the two factors that produce it. Cutting retrieval depth means capping it — accepting a truncated candidate set and the recall loss that comes with it, which §5 quantifies. Cutting fan-out means routing a query to a subset of shards rather than all of them, which only works if the index is partitioned along something the query knows in advance. Buying larger machines moves neither factor, which is why this system is not solved with hardware.
High-level architecture: a wide fan-out and a narrow filter
Two paths cross in this system and they have almost nothing in common. The write path is a stream: posts are committed to a primary store, and something downstream turns them into postings in an index. The read path is a fan-out: a query goes to every shard, comes back with candidates, and those candidates have to survive a permission check before anyone sees them. The only thing the two paths share is the index itself, and they contend on it — which is most of what makes §9b and §10 interesting.
The components
Search coordinator. Receives the query, broadcasts it to every shard, merges what comes back, applies the visibility filter to the merged candidates, ranks the survivors and hydrates the page. It is the only place with a global view, which makes it the only place that can rank across shards correctly — and the place where the tail latency of 1,314 parallel requests becomes one user-visible number.
Index shards. Each holds a disjoint subset of posts and the complete inverted index over that subset, so it can score its own documents without talking to anyone. The non-obvious constraint is what a shard does not know: it has no idea who is searching, and no idea how many of its matches that person may see. It returns its best candidates blind.
Visibility service. Answers one question — may this viewer see this post — against live permission state. It is a dependency of the read path, not a copy of anything inside the index, and §5 is entirely about why it is drawn this way. Its latency lands directly in the query budget, and it cannot be cached per viewer without reintroducing the staleness it exists to prevent.
Post store. The system of record for posts and their audience settings. Everything in the index is derived from it, which is what lets us treat shard loss as a rebuild rather than data loss. It also serves the final hydration: the index returns post IDs and scores, and the bodies come from here.
Change stream and indexer. Committed writes in the post store are published as a change stream, and the indexer turns each change into postings and applies them to the shard that owns the document. Facebook's posts-search team described exactly this shape in 2013, subscribing to MySQL changes through a system called Wormhole so that creates, edits, deletes and connection changes all schedule an index update. Doing this asynchronously is what decouples "the post is saved" from "the post is findable" — the gap between them is the freshness NFR.
Architectural rationale
Why the index is split by document, not by term ›
There are two ways to cut an inverted index across machines. Split by document, and each shard holds the full index over its own slice of the corpus — every query must ask every shard. Split by term, and each shard owns the complete posting list for some terms — a query goes only to the shards holding its terms, but a multi-term query has to ship enormous posting lists across the network to intersect them, and a single viral term makes one shard a hotspot.
Document partitioning wins, and the reason given by the team that built Facebook's graph search is worth quoting because it is not the reason most people give: "We chose to partition by result-id because we wanted the system to remain available in the event of a dead machine or network partition." Their second argument was locality — "most set operations can be done at the index server (leaf) level instead of being performed higher in the execution stack" — which spreads compute across machines and cuts inter-machine bandwidth.
The availability argument is the one that transfers. Under document partitioning, a dead shard costs you the fraction of the corpus it held; returning most of the friends of Jon Jones beats returning none. Under term partitioning, losing the shard that owns a common term in the query costs you the entire query.
Why the visibility service is a separate dependency rather than index metadata ›
This is the design's load-bearing decision and §5 argues it in full. In outline: permission state changes at a different rate and with different urgency than post content, and an index built for throughput cannot offer the freshness that permissions require. Keeping the check outside means the index never has to be authoritative about anything that matters for correctness.
It also keeps one copy of the permission logic. Audience rules already exist in the product; reimplementing them inside the indexer means two implementations that must agree forever, and the failure mode when they diverge is a leak rather than a test failure.
Why indexing is asynchronous, and what that costs ›
Indexing synchronously on the write path would make every post creation wait for 1,314 shards' worth of coordination, and would couple the availability of posting to the availability of search. Since §2 puts search at 99.9% and posting well above it, that coupling is backwards.
The cost is that "searchable" lags "saved" by whatever the pipeline takes, and that every failure in the pipeline is silent from the user's point of view — the post exists, it simply cannot be found. §10 treats indexer lag as a first-class failure mode for exactly this reason.
Why ranking sits in two places ›
Each shard scores its own matches, because scoring where the postings live avoids shipping candidates that will never make the page. The coordinator then re-ranks the survivors, because only it can compare across shards and only it knows which candidates passed the visibility check.
Not every system does this. Twitter's Earlybird returned results in strict reverse-chronological order from the search cluster and left relevance ranking to a separate downstream service. That is a legitimate split — it keeps the index simple and makes ranking independently deployable — but it means the index cannot prune by relevance, so it must return more to get the same quality.
How real systems differ here
These are not stylistic differences. Each row is a place where teams building the same kind of system made opposite choices, and each choice is traceable to something about their corpus or their permission model.
| Decision | This design | Meta — Unicorn | Twitter — Earlybird | GitHub — Blackbird |
|---|---|---|---|---|
| Where the visibility check runs | Query time, against an authoritative service | Query time; each result carries a lineage trail the frontend checks | Index time; protected accounts live in a physically separate cluster | Query time, twice — an access clause injected into the query, then a re-check after merging |
| Index partitioning | By document | By result id, chosen for availability under failure | By document, plus a split by content class (realtime, protected, archive) | By content hash, which also deduplicates identical files |
| Where relevance is computed | Leaf scores, coordinator re-ranks | Inside the index, with a second pass across results | Outside the index; the cluster returns reverse-chronological matches | Baked into document ordering at index build time |
| Index residency | Tiered — recent in memory, older on SSD | Started in RAM, moved the bulk to flash as the corpus passed 700 TB | Fully in memory, at 2012 corpus size | Tiered, with the index about a quarter of the raw content size |
| Semantic retrieval | Out of the baseline; a level-gated addition | Embeddings in first-stage retrieval, as an operator inside the boolean query language | Not in the search index in the published design | Deliberately absent — code is matched structurally |
Read the table as consequences, not rankings. Twitter could put visibility in the index because "protected" is a single boolean on an account — a binary flag partitions cleanly. Facebook could not, because its audience rules are arbitrary relationships in a graph, and there is no clean split of a corpus by "who may see this". GitHub's permissions sit in between: numerous and dynamic, but enumerable per user, so they can be compiled into the query itself. The mechanism follows the shape of the rule, which is the subject of §5.
Where the visibility check runs
Everything so far has been the part of this system that resembles other search engines. This section is the part that does not, and it is the decision an interviewer is most likely to push on, because every instinct trained by the rest of the design points at the wrong answer.
That instinct says: push work into the index. It is why we score at the leaf instead of shipping candidates, why we denormalise, why we precompute. Applied here it says to bake visibility into the postings — index each post with the set of people who may see it, and let the query match on viewer as well as term. Retrieval then returns only permitted results, over-fetch disappears, and the visibility service leaves the read path entirely.
It is the wrong answer, and understanding why is the whole section.
Start with the naive version, because it fails in an instructive way
The simplest design does no filtering in the index at all. Ask each shard for its top ten, merge to a global top ten, check those ten against the visibility service, return what survives.
The user asks for ten results and gets four. Not because only four posts match — thousands do — but because six of the ten highest-scoring ones happened to be invisible to this viewer. Page two will be similarly holed, and the holes are in different places for different viewers.
The fix is to ask for more than you need, and the amount more is the pass rate. If a fraction p of matching posts are visible to this viewer, filling a page of K needs roughly K/p candidates. That expression is the over-fetch factor, and §3 makes it a slider because it is the number that drives everything else: at a 20% pass rate a page of ten costs fifty candidates per shard, and the coordinator merges shard count times that.
K/p is an expectation, not a guarantee. Each candidate either passes or does not, so the number needed to collect K survivors follows a negative binomial distribution — the mean is K/p, but the tail is long, and a query whose visible results cluster at the bottom of the ranking can exhaust any fixed depth. This framing is straightforward probability rather than a published result; the practical consequence is that retrieval depth is a bet, and the system needs an answer for when the bet loses. §10 handles the short-page case explicitly.
So why not put the check in the index?
Because the index cannot be fresh enough, and here staleness is not a quality problem.
Consider what an index-time design has to guarantee. When someone removes a friend, that person must immediately stop seeing the first user's friends-only posts. Every one of them, in every shard, at once. The index would have to apply a permission change across the entire corpus with the urgency of a database transaction — and an index built to absorb hundreds of thousands of postings per second in batches, with a refresh interval and a merge policy, is precisely the wrong machine for that.
The team that built Facebook's graph search reached this conclusion and documented it plainly. Their system, they wrote, "does not have privacy information incorporated into its index. Instead, our approach is to give callers all the relevant data concerning how a particular result was generated … so that the caller — typically our PHP frontend — can make a proper privacy check on the result." The justification names the exact scenario above:
"If a user 'un-friends' another user, the first user's friends-only content immediately and irrevocably must become invisible to the second user. Even a 30-second lag-time is unacceptable. … In CAP-speak, we chose availability and partition tolerance. Unicorn is not an authoritative database system."
Read that last sentence as a design principle rather than a disclaimer. The index is allowed to be stale, lossy, partially available and eventually consistent — that is what makes it fast and cheap. The moment it becomes the authority on who may see what, it inherits requirements it was never built to meet. Keeping the check outside is what lets the index stay the kind of system it is good at being.
There is a second argument, quieter but just as strong: the audience rules already exist somewhere in the product. Reimplementing them in the indexer produces two implementations that must agree forever, across every future feature that touches visibility. When they diverge — and they will — the symptom is a leak, not a failed test.
What materialising would actually cost, measured ›
The instinct deserves a price rather than only an argument, and the same paper provides a clean measurement of the general shape. They compared answering friend-of-friend queries by traversing the friend list at query time against materialising every friend-of-friend pair directly into the index. With 130 friends per user on average and roughly 48,000 friends-of-friends, at 4 bytes per posting across a billion users:
| Approach | Index size | Average latency |
|---|---|---|
| Store friend edges, traverse at query time | 484 GB | 20 ms |
| Materialise friend-of-friend into postings | 178 TB | 7 ms |
Materialising is genuinely faster — about a third of the latency. It also costs 368 times the storage. That is the trade the "push it into the index" instinct is proposing, and it is being proposed for a relation that changes far more often than friendship does, and whose staleness is a privacy incident rather than a slightly worse result.
The transferable lesson. Precomputing a derived relation into an index is a storage-for-latency trade, and it is a good one when the relation is stable and its staleness is cheap. Visibility is the opposite on both axes: it changes constantly and stale answers are unacceptable. The instinct is sound; this is one of the places it does not apply.
The three real answers, and what selects between them
Having argued that permissions do not belong in the index, the complication is that real systems do not agree — and they are not wrong. They have different permission models, and the mechanism follows the shape of the rule.
| Shape of the permission rule | Mechanism that fits | Seen in | Why it works there |
|---|---|---|---|
| A single global boolean per author | Split the corpus at index time; route the query to the clusters the viewer may read | Twitter — protected accounts in a separate cluster | A binary flag partitions cleanly, and changing it moves one author's posts rather than rewriting a relation |
| Numerous but enumerable per viewer | Compile the viewer's accessible set into the query itself, then re-check after merging | GitHub — an access clause in the query, plus a second check post-merge | The set is large but knowable at query time, so the index can prune with it without storing it |
| Arbitrary relationships in a graph | Return provenance with each result and check it against an authoritative service | Meta — lineage checked by the frontend | There is no clean partition of a corpus by "who may see this", and the rule changes faster than an index can |
Our choice for this system is the third row, because the requirements in §2 describe the third row: audience settings that depend on the relationship between two users, changeable at any moment, with correctness that is binary rather than statistical. The first row does not fit because visibility here is not a property of the author alone. The second row is closer than it looks — and worth saying out loud in an interview — but a viewer's accessible set on a social platform is their friend graph, which is large, changes continuously, and would have to be compiled into every query.
Note what the third row is not. It is not "fetch everything and filter in the application", which is the strawman version. The index still prunes hard: it returns ranked candidates, and the check runs only on those. What the index does not do is claim to know the answer.
Capping the damage: truncation
Over-fetch with no ceiling is a denial-of-service waiting for the right query. The mechanism that bounds it is a truncation limit — a maximum number of candidates retrieval will consider, after which it stops and returns what it has.
The same paper measured this directly: as the truncation limit rises, latency and CPU both climb, and latency crosses 100 ms at a limit around 5,000. Their conclusion reads as an admission rather than a solution — barring architectural changes, they write, there will always be queries whose candidate sets need truncating to keep latency reasonable.
Truncation trades recall for a latency bound. A query whose visible results sit below the cut returns fewer results than exist, and the user cannot tell the difference between "nothing else matches" and "we stopped looking". That is a real defect, not a rounding error, and the only honest mitigation is to make the ranking good enough that visible results tend to sort high — which is why ranking quality and privacy filtering are coupled problems rather than independent ones.
The filter loop, with the batching that makes it affordable ›
The check is per candidate, but it must not be per round trip — a thousand sequential calls at even a millisecond each would blow the entire budget. The loop fetches in depth-bounded rounds and checks each round in one batched call.
def search(query, viewer, k=10, max_candidates=5000):
results, scanned, cursor = [], 0, None
while len(results) < k and scanned < max_candidates:
# one fan-out round: every shard returns its next slice
batch = index.retrieve(query, after=cursor, limit=k * OVERFETCH)
if not batch:
break # corpus exhausted, not truncated
scanned += len(batch)
cursor = batch[-1].sort_key
# one call for the whole batch, not one per candidate
visible = visibility.filter_visible(viewer, [c.post_id for c in batch])
results.extend(c for c in batch if c.post_id in visible)
truncated = len(results) < k and scanned >= max_candidates
return results[:k], truncated
Two details matter more than they look. The loop reports truncated rather than silently returning a short page, because §5b's pagination contract and §10's failure handling both need to distinguish "no more results" from "we stopped looking". And OVERFETCH is a tunable seeded from the measured pass rate rather than a constant — the right value differs enormously between a query over public posts and one over a private-heavy corner of the graph.
If your NFRs were different: when index-time filtering becomes right ›
The argument above is conditional, and inverting the conditions inverts the answer. Index-time visibility becomes the better choice when the permission rule is stable (changes are rare enough that reindexing on change is affordable), low-cardinality (it partitions the corpus rather than cross-cutting it), and tolerant of a propagation window measured in seconds.
Enterprise document search often fits: documents belong to teams, team membership changes on a human timescale, and a short lag after a membership change is acceptable. Public content search fits trivially, because the pass rate is 1 and the whole problem disappears.
What does not change under any of these conditions is the need for a post-merge re-check if the stakes are high — which is why GitHub runs one even after injecting the access clause. Defence in depth here costs one extra check on K results, not on candidates.
The follow-up to expect. "Your visibility service is now on the critical path of every search. What happens when it is slow, or down?" The answer that reads as senior is fail-closed with a stated consequence: the search returns fewer results or an error, never unfiltered ones. Then the interesting half — you cannot fail open, and you cannot cache per viewer without reintroducing staleness, so the remaining levers are batching, a short-TTL cache keyed on the post's audience rather than the viewer, and shedding load by lowering the truncation limit. §10 works through the degradation ladder.
The query contract: pagination when results can vanish
Search APIs are usually boring, and this one would be too if results were stable. They are not. Between page one and page two the corpus grows by several million posts, and — the part unique to this system — a post that was visible when page one was served may not be visible when page two is requested. The contract has to say what happens then, because the alternative is that each client invents its own answer.
The read surface is one endpoint:
GET /v1/search?q=...&limit=10&cursor=...&author=...&since=...&until=...
200 OK
{
"results": [ { "post_id": "...", "author_id": "...", "created_at": "...",
"snippet": "...", "score": 8.41 } ],
"cursor": "eyJzIjo4LjQxLCJpZCI6..." | null,
"truncated": false
}
Three decisions in that shape are worth defending.
The cursor is opaque and encodes a sort position, not an offset. Offset pagination re-runs the query and skips N rows, which means the cost of page ten is ten pages of work, and any insertion above the cut shifts every subsequent row. Elasticsearch caps this behaviour outright — index.max_result_window defaults to 10,000 — and its documented alternative, search_after, carries the sort values of the last row seen so the next page resumes from that position at constant cost. The same docs are explicit that the sort must include a tiebreaker, or "results could miss or duplicate hits": score alone is not unique, so the cursor carries (score, post_id) and the comparison is lexicographic on the pair.
There is no total count. Producing one means evaluating every match and visibility-checking every match, which is exactly the unbounded work §5 spent a section avoiding — and the result would still be wrong the moment it was computed. §10b gives the second, better reason to omit it: a per-viewer result count is a measurement of content the viewer cannot see, which makes it a privacy leak in the shape of a UI affordance.
truncated is part of the response, not a log line. When retrieval hits the candidate ceiling before filling the page, the client is told. A UI that knows the difference between "that's everything" and "we stopped looking" can offer to search deeper or narrow the query; a UI that cannot tell shows the user an empty state that is a lie.
Why the cursor is opaque, and what it must contain ›
Opaque means base64 of a signed internal struct, not a readable offset. The reasons are practical rather than aesthetic. It lets the sort key change — adding a freshness component, moving from one scoring function to another — without breaking clients holding old cursors, since the server can version the struct and reject or migrate. And signing it prevents a client from hand-crafting a cursor that asks the index to resume from an arbitrary position, which is a cheap way to make expensive queries.
The cursor carries the sort position, the query fingerprint, and the retrieval depth already consumed. It does not carry the viewer: visibility is re-evaluated on every page, so a cursor minted before an unfriend yields fewer results afterwards rather than replaying stale permissions. That is the correct behaviour and it falls out of not caching the decision.
Filters belong in the query, not in post-processing ›
author, since and until look like conveniences and are in fact the most effective lever in the API, because they are the only thing that can cut the fan-out factor from §3. A query scoped to one author touches the shards holding that author's posts; a query scoped to last week can skip index segments whose time range does not overlap. Applying the same filters after retrieval gives identical results at full cost.
This is also why the filter set is closed rather than a general expression language. Every filter that reaches the index must be one the index can use to prune. An arbitrary predicate cannot be, and would have to run as a post-filter — which is the over-fetch problem again, this time self-inflicted.
A query end to end, and where the 300 ms goes
The dominant path is the read. Tracing one query through the components in §4 shows where the time actually goes, and — more usefully — shows that the answer is not what the diagram suggests.
1. Parse and plan. The coordinator tokenises the query the same way the indexer tokenised documents — an asymmetry here is a silent recall bug, not an error — applies the filters from §5b to decide which shards and which time-ranged segments can be skipped, and sets a retrieval depth from the last observed pass rate for this viewer class.
2. Fan out and score. Every surviving shard walks its postings lists, intersects them, scores the matches and returns its own top slice. Scoring at the leaf rather than shipping candidates upward is what keeps the merge affordable, and it is the same reasoning §4 used for partitioning by post rather than by term.
3. Merge. The coordinator takes shard-count × depth rows — 65.7k at the estimator's defaults — and reduces them to a ranked candidate list. This is a heap merge over already-sorted runs, which is cheap per row but not free at that volume.
4. Filter. The top candidates go to the visibility service in one batched call. Survivors fill the page; if the page is short and the candidate budget is not exhausted, the coordinator takes the next slice from the merged list and checks again.
5. Hydrate. The ten survivors — and only the ten — are fetched from the post store for text and snippet generation. Everything before this step moved identifiers.
Where the 300 ms p99 goes. Parse and plan, ~2 ms. Shard execution, ~20 ms for the typical shard — but the coordinator waits for the slowest of roughly 1,314, not the typical one, so budget ~120 ms. Merge of 65.7k rows, ~15 ms. Visibility check, ~20 ms for one batched round trip. Hydration of ten posts, ~10 ms. Snippet generation and serialisation, ~10 ms. Total ≈ 177 ms against a 300 ms budget.
The remaining ~120 ms is not spare capacity — it is the second round. When the first round's candidates do not fill the page, the coordinator runs another full fan-out, and the same slowest-of-1,314 argument prices it at another ~120 ms plus its re-merge. One round fits comfortably; two consume the entire budget and land at the p99 ceiling. That is why retrieval depth is sized to make a second round rare rather than budgeted as routine — the control problem the probe below describes.
That budget contains the section's real point. The single largest line is not the visibility check that §5 spent so long on; it is the gap between the typical shard and the slowest one. Dean and Barroso quantified exactly this in The Tail at Scale: if one request in a hundred is slow, fanning out to a hundred servers means 63% of user requests are slow. Their measured example is sharper still — a service whose individual leaves have a 10 ms p99 shows 70 ms latency when 95% of leaves have responded, and 140 ms when all of them have. The last 5% of shards double the query time.
At 1,314 shards, "the slowest of N" is not a tail effect to be tuned away. It is the dominant term, and it is why §9 treats shard count as a quantity to be actively reduced rather than a number that falls out of the storage math — and why §10's degradation ladder starts with returning results from the shards that answered.
The follow-up to expect. "Your budget assumes one visibility round trip usually suffices. What sets the initial retrieval depth?" The strong answer treats it as a control problem rather than a constant: measure the realised pass rate per query class, set depth to fill a page at that rate with margin, and let the second round handle the misses. The weak answer picks a fixed multiplier, which is simultaneously wasteful for public queries and insufficient for private-heavy ones — the two cases the slider in §3 exists to separate.
Data model: three stores, because there are three keys
There are four things worth modelling here — the post, the inverted index over posts, the audience rule attached to a post, and the relationship graph the rule is evaluated against — and the temptation is to put as many of them as possible in one place. The access patterns say otherwise.
| Operation | Frequency | Query shape |
|---|---|---|
| Index a new post | ~11.6k/s (1B/day) | Append ~20 postings, keyed by term |
| Look up a term at a shard | Once per term per shard per query | Sequential read of a postings list |
| Check visibility of candidates | Once per query, batched | Reachability between viewer and a set of audience rules |
| Hydrate results | 10 per query | Point read by post_id |
| Delete or edit a post | Rare vs creates, unbounded latency budget | Point write by post_id, plus index invalidation |
| Change an audience rule | Rare per user, immediate effect required | Point write — and, by design, no index write at all |
Two observations force the shape. The first is that every row in that table is keyed by something different: the index by term, hydration by post id, the visibility check by a pair of users. No single store is good at all three, and combining any two of them means one access pattern runs as a scan. The second is the last row. An audience change must take effect immediately and must not require touching the index — which is not a storage optimisation, it is §5's decision expressed as a schema constraint. The moment the index holds a copy of the audience rule, changing the rule means rewriting the index.
The inverted index, and why it is built out of segments
The post store answers one question well: what does post 7 say? Search asks the opposite question — which posts say "coffee"? — and answering that from the post store means reading every post. The inverted index is that table turned around: instead of post → words, it stores word → posts. That inversion is the whole trick, and everything else in this section is a consequence of it.
A postings list holds three things per matching post, and §7b prices each of them. The document id, stored as a gap from the previous id rather than an absolute — which is why the list has to be walked in order rather than indexed into. The term frequency, for scoring. And the positions the term occupies in the text, which is what makes a phrase query possible and what a proximity match reads. Document ids are the index; the other two are features you can decline to pay for.
So far this describes a structure you build once. The harder problem is keeping it current while a billion posts a day arrive, and the answer every production engine converges on is counter-intuitive: never modify the index at all. A term's postings list is a tightly packed run of bytes, so inserting one document in the middle of it means rewriting the run — and a single post touches twenty such lists. Instead, the index is cut into segments. A segment is a complete, self-contained miniature index over a slice of the documents: its own dictionary, its own postings, its own sidecar. Once written, it is never changed.
Immutability buys three things at once. Writes stop being edits and become appends to the one small mutable segment, which is flushed and frozen on the freshness interval §9b sets. Readers never need a lock, because a segment being merged is not the segment they are reading. And caching becomes trivial, since a file that cannot change cannot go stale.
It is paid for in three matching ways, and each one surfaces later in this article. A query has to search every segment and merge the results, so segment count is a latency term — which is why the system merges continuously, and why §9b treats merge throughput as a budget rather than a background detail. A delete cannot remove anything, so it flips a bit in the segment's live-documents bitmap and the document is skipped as the postings are walked; the space comes back only when that segment is merged. And an edit is a delete plus an insert, which is why the edit-version check below matters and why edited text lingers until a merge rewrites it.
Concretely, a segment on disk is a handful of files, each answering a different lookup:
segment/
terms.dict term -> offset into postings
postings.bin term -> [doc_id delta, term_freq, positions...]
docvalues.bin doc_id -> created_at, author_id, audience_kind
livedocs.bits doc_id -> 1 live | 0 deleted
ids.map doc_id -> post_id # segment-local, ephemeral
The fields in docvalues are exactly the ones §5b's filters need to prune with and the merge needs to sort by — nothing else. Post text is not among them: it lives in the post store, and hydration fetches it for the ten results that survive.
Note what audience_kind is and is not. It records the category of the rule — public, friends, custom list, group-only — not who may see the post. That is enough for a fast path: a query filtered to public posts can skip the visibility service entirely, because the pass rate for public content is 1 and there is nothing to check. It is not enough to decide anything about a non-public post, which is the point.
The post store and the graph
The post store is a partitioned key-value store keyed by post_id, holding author, timestamp, text and the full audience descriptor. It is the system of record for post content; the index is derived from it, which is what makes §2's "rebuildable, not durable" requirement true. The relationship graph is not part of this system at all — the visibility service owns it, and this design deliberately holds no replica of it, because a replica is a staleness window with extra steps.
The correctness problem nobody mentions: deletes and edits
A post's lifecycle is where a search index quietly goes wrong, and the ordering of two writes decides whether the failure is visible or dangerous.
Deleting a post means removing it from the post store and clearing its bit in the live-documents bitmap of whichever segment holds it. Those are separate writes to separate systems, so one lands first. Clear the index bit first and a crash leaves a post that exists and is unfindable — bad, and self-healing on the next reindex. Delete from the post store first and a crash leaves an index entry pointing at nothing, so hydration fails and the coordinator drops the result. Dropping it is the correct behaviour: a result that cannot be hydrated is not returned. So the post store is deleted first, and hydration failure is treated as a filter rather than an error.
Editing is subtler, because an edit produces a window in which the index describes text that no longer exists. A post edited to remove a word still matches that word until its segment is rewritten, and the snippet generated from the current text will not contain the term the user searched for. That is a quality bug, not a privacy one — and it is worth saying which it is, because the reflex is to treat all index staleness as equally serious.
There is a third case that neither ordering covers, because it arrives through the retry path rather than the crash path. The change stream delivers at least once, so an indexer that dies mid-batch sees some events again when it restarts. Applying a posting therefore has to be idempotent rather than additive: postings carry the post's edit version, a redelivered event overwrites the posting it already wrote instead of appending a second one, and an event carrying a version older than the segment already holds is dropped rather than applied. Without that version a redelivered edit can reinstate text the author has already replaced — the same defect as the delete-ordering bug, reached by a different route.
The privacy-relevant edit is a change to the audience rule, and this design has already solved it. Because the rule lives in the post store and is evaluated at query time, tightening a post's audience takes effect on the next query with no index write at all. That is the dividend from §5 that shows up nowhere in the query path and everywhere in the failure analysis.
Why doc_id and post_id are different things
›
Internal document ids are small integers, segment-local, and assigned in insertion order — which is what makes delta-encoded postings compress well and skip lists work. They are also unstable. Lucene's own documentation states it plainly: "document numbers are ephemeral and may change" — merging segments renumbers everything.
The practical rule: a doc_id must never escape the segment that minted it. Anything persisted outside the index — a cursor, a cache key, a click log, a foreign key — carries post_id. Violating this produces a bug that passes every test, because renumbering only happens on merge, and merges only happen under sustained write load.
Why tombstones instead of deleting from the postings list ›
Postings lists are delta-encoded and compressed in blocks. Removing one entry means decoding, splicing and re-encoding the block, and the document being deleted appears in a posting for every one of its terms — twenty rewrites for one delete, at random offsets in an immutable file.
A bit flip in a side bitmap costs one write, and the deleted document is filtered out as postings are walked. The space is reclaimed when the segment is merged, which was going to happen anyway. The cost is that deleted documents occupy index space until then, and that queries pay a small per-candidate check — both cheap next to the alternative.
Index size and storage: what the bytes are, and what holds them
§3 sized the index at 65.7 TB and moved on, because what binds this system is query cost rather than capacity. It is worth returning to now that §7 has named the files. Index size sets shard count, and §6 established shard count as the dominant latency term, so this is a latency input wearing a storage costume — and the one published figure for a real system of this shape is more than ten times larger.
Where the bytes actually go
A posting is one (term, document) pair. At the estimator's defaults — 200 bytes of text, 20 indexed terms — 65.7 TB across 1.1 trillion posts is 60 bytes per post, or 24 bits per posting. That rests on a documentation rule of thumb, so it is worth breaking apart and checking against something measured on real short text.
| Component | Bits per posting | Share | What it buys |
|---|---|---|---|
| Document ids, delta-encoded | 10.13 | 63% | The matching itself — unavoidable |
| Positions | 4.96 | 31% | Phrase and proximity queries only |
| Term frequencies | 1.06 | 6.5% | Relevance scoring; nearly free on short text |
| Total, best-in-class encoding | 16.4 | — | 302 MB for that corpus, including skip structures |
| Same corpus, Lucene 3.6 | 23.0 | — | 423 MB — the engine most readers will actually use |
Three things fall out of that split. Document ids are nearly two thirds of the index and are not a lever — they are the matching. Positions are the only large component tied to a single feature, which makes them the one real choice on the table. And term frequencies are nearly free here, because a 200-byte post rarely repeats a term, so the counts compress to almost nothing.
The estimator's implied 24 bits per posting lands essentially on the 23 measured for Lucene on that corpus, which is reassurance rather than luck: the 20–30% rule is Lucene's documentation describing Lucene's own output. Newer codecs beat that figure, so the estimate errs high — the direction an estimate should err.
Short documents cost more per posting, not less. The figure candidates reach for is the classic ~8 bits per compressed pointer, which describes long news and web documents. On tweets the document ids alone measure 10.13 bits. A term occupying a fixed share of the text appears in far fewer documents when documents are short, so posting lists are sparser against a larger document count and the gaps between ids grow. Budget roughly twice the web-corpus figure for a corpus of posts.
Why the published figure is ten times bigger
§12 cites Facebook's 2013 report of more than a trillion posts in more than 700 TB — roughly 640 bytes per post, against the estimator's 60 for the same corpus size. Same platform, same scale, an order of magnitude apart. What differs is not the compression. It is what is being counted.
65.7 TB is the inverted index over post text: the structure a query walks. The 700 TB is an entire serving footprint, and Meta's own write-up says what fills it — dozens of distinct kinds of data sorted and indexed on, well over a hundred ranking features, and the document data held separately from the inverted index. Per-document ranking data at that richness dwarfs the text postings, and none of it is the text index.
This design made the opposite choice explicitly, in §7: docvalues holds created_at, author_id and audience_kind and nothing else, because those are what §5b's filters prune with and what the merge sorts by. Text and the full audience descriptor sit in the post store, reached by point read for the ten survivors. The two numbers measure different objects, and §3's shard count stands.
The transferable move. "How big is the index?" is underspecified until you say which structure. Postings over text, the per-document sidecar the filters need, and the document store holding the source are three quantities with three growth rates, and only the first two are fanned out to on every query. A single all-in number hides the one that drives fan-out — which is the one that decides the latency.
What medium holds it
§6 gives each shard about 20 ms to walk its postings, and a multi-term query touches on the order of twenty posting lists. That budget, divided that way, picks the medium before cost is even considered.
| Medium | Random read | 20 lookups | Cost vs object storage | Verdict against a 20 ms shard |
|---|---|---|---|---|
| RAM / page cache | ~100 ns | negligible | ~100× | Fits, and costs more than the rest combined |
| Local NVMe | 60–90 µs at queue depth 1 | ~1.5 ms | ~3.6× | Fits comfortably |
| Network block storage | single-digit ms (general purpose) | ~40 ms | ~3.5× | Blows the shard budget on its own |
| Object storage | tens of ms per request | hundreds of ms | 1× | Not viable for a tier every query touches |
Two rows are disqualified on latency before cost is considered at all, and the two that survive are separated by roughly thirty times in price. That is the whole decision, and both Facebook and Twitter documented making it on indexes of exactly this shape — bulk postings onto flash, the hottest structures held back in RAM.
What drove Facebook's move is the part worth carrying, because it is not the obvious thing: not the price of memory, but the coordination overhead of spreading an index across enough machines to hold it — the same quantity §6 prices as fan-out. Twitter was blunter about what it cost them, taking a major hit to per-machine query capacity to move its archive off RAM, and taking it anyway.
That is what §9's age tiering buys. The recent tier is touched by every query and earns page cache over local flash; the archive is touched only when a date filter overlaps it, which makes storage two orders of magnitude cheaper and three orders slower an acceptable trade. Seconds-scale p99 for a sixteenth of the cost is a bad number for interactive search and a fine one for "find the thing I posted in 2019".
Tiering pays a second time, and this is where §2's classification of the index as derived state cashes out. Replicas of a search index buy read throughput and recovery time, not durability — the post store is the system of record, and §9b describes rebuilding from it. So replica count is a performance decision that can differ per tier: the recent tier carries replicas because it absorbs every query, while an object-backed archive can carry none, since a lost node's cache refills from a store that is already redundant. Elastic puts that saving at roughly half the disk of an equivalent replicated tier. Sizing every tier at 3× because "that is what replication means" is the common way to get this wrong.
Working the tradeoff in an interview
When an interviewer pushes on size, the weak answer is a smaller estimate. The strong one is a named lever with a number on it, and an order to reach for them in. These are the levers that exist.
| Lever | Saves | Costs | Reach for it when |
|---|---|---|---|
| Modern postings codec | ~7–30% | Nothing but an engine version | Always — it is the one free win |
| Drop positions | ~31% | Phrase queries cost a post-store read per candidate | Posts are short and quoted search is minority traffic |
| Drop term frequencies and norms | ~6% | Relevance scoring gets coarser | Rarely — you give up ranking for very little |
| Age-tier the storage | Most of the storage bill | Archive queries go from milliseconds to seconds | Traffic is recency-skewed — which, for post search, it is |
| Object storage with a hotcache | ~100× on the archive | Seconds-scale p99, plus per-request fees | Archive only, never a tier every query touches |
| Add shards | Bytes per shard | Fan-out — §6's dominant latency term | Last resort: it buys capacity by spending latency |
The ordering carries as much signal as the levers. Take the codec win first because it costs nothing. Then decide about positions, which is the only structural choice in the list and the one that turns on a product question rather than an engineering one — is quoted search a headline feature or a long-tail convenience? Tier by age next, because recency skew is real here and the archive is where the bytes are. Reach for shard count last, and say why: §6 already showed that fan-out, not bytes, is what the latency budget cannot absorb.
Common probe: "Your index does not fit in memory. What do you do?" The answer that stalls is "add machines" — it is the lever with the worst latency consequence and the candidate reached for it first. The answer that lands names the split instead: the term dictionary and skip data stay resident because every posting lookup goes through them, the postings themselves move to local flash, and the age tiers get different media because they see different traffic. Then price it: ~30× between RAM and flash, ~100× between RAM and object storage.
Reading the published numbers honestly. Two caveats turn a quoted figure into a wrong one. Vintage: the Facebook and Twitter figures are 2013–2014 — the right order of magnitude for a platform of that size, not today's numbers, and codecs have improved since. Scope: any "index size" is underspecified until you say whether it includes the per-document sidecar and the document store, and that single ambiguity is the whole ten-times gap above. How each number was derived, and which are soft, is in the sources note below.
What has to stay in memory even when the postings do not ›
"The index lives on flash" is shorthand, and the part it omits is the part that fails first. To read a posting list you must first find it, which means a term dictionary lookup. Serving that from the same device doubles the I/O per term and puts a random read in front of every one of §6's twenty lookups.
The Unicorn authors hit the ceiling from this direction. Rejecting a denormalised index, they noted that "to merely store pointers to the posting lists for these terms—not term hashes or the terms themselves—it would require hundreds of GB per shard, which is more than the amount of RAM in a single machine". The dictionary, not the postings, was the binding constraint. Vespa states the resulting rule as a guarantee: for fields configured for fast search, the dictionary and index structures are never paged out to disk.
This is also why Facebook's phrasing is precise. They did not say the index moved to flash. They said the majority of it did — "storing the majority of the index on solid-state flash memory" while "carefully separating out the most frequently accessed data structures and placing those in RAM". The split is dictionary and skip data resident, postings and positions on the device.
Dropping positions: 31% of the index, and posts are short enough to get away with it ›
Positions are the second-largest component in the table above and they serve exactly one feature: phrase and proximity queries. For a corpus of long documents that is a fair trade. For 200-byte posts there is a cheaper way to buy the same feature.
Elasticsearch ships it as match_only_text, which drops positions, frequencies and norms, then answers phrase queries by loading the field from the stored document "to check whether terms actually occur at consecutive positions" — and only for documents that already matched every term. Phrase queries get slower; they do not stop working.
That fits this design unusually well, because the architecture already has the two things the technique needs. The post store holds the text (§7), and the candidate set reaching any verification step is small by construction — §3's retrieval depth is 50 per shard, and §5's whole argument is that candidates are filtered down to a page. Verifying a phrase against a few hundred 200-byte posts is not a scan of the corpus.
Why the whole index cannot simply live in object storage, and what the engines that try actually do ›
Object storage is ~100× cheaper than RAM and durable without replication, so the obvious question is why the recent tier does not use it. The latency row above is one answer; the request count is the other, and it is the less obvious one.
Walking a posting list is a sequence of small random reads, and against an object store each one is a billed HTTP request rather than a page fault. Quickwit, which is built for this, measures roughly 2,000 object reads for a single simple query before caching. At list pricing of $0.0004 per thousand requests that is $0.0008 per query in fees alone, before bytes and before compute — a rounding error per query and a serious line item at search traffic volumes.
The engines that make this work do so by not doing the obvious thing. Quickwit keeps a per-split "hotcache" — the dictionary and offsets, under 0.1% of split size — resident, so "opening a split on Amazon S3 only takes 60ms", and the object store is touched only for the postings themselves. Elasticsearch's searchable snapshots do the structurally identical thing with a local disk cache in front of the repository, and benchmark p99.9 between roughly one and fourteen seconds over 105 TB depending on cache state. Both are the same split this section already described — structure in memory, bulk on the slow device — applied one tier further down.
Sources behind the numbers in this section ›
The component breakdown comes from Vigna's quasi-succinct indices paper, the rare published measurement taken on actual short social text: 13 million tweets, 147 million postings, 156 million term occurrences, indexed several ways. It is the source for the 10.13 / 4.96 / 1.06 split and for both totals.
The ~8 bits per pointer that the warning above pushes back on is the long-standing web-corpus figure from Zobel and Moffat's inverted files survey. The 20–30% planning ratio is Lucene's own feature list, and the further ~7% saving from block-packed postings in Lucene 4.1 is McCandless's measurement.
The RAM-to-flash move and the 700 TB figure are Facebook's 2013 post, which is also the source for the "70 different kinds of data we sort and index on" and "well over a hundred distinct ranking features" that explain the scope gap. Twitter's complete tweet index post is the corroboration: the real-time index stayed "fully stored in RAM", the archive went to SSD because RAM "would have been prohibitively expensive", and "switching from RAM to SSD, our Earlybird QPS capacity took a major hit".
Device latencies are Micron's 9550 datasheet for NVMe at queue depth 1 and AWS's gp3 documentation for general-purpose network block storage. The replica-count saving on a cold tier is Elastic's data-tier documentation.
Two methodology notes. The component measurements are reported per stored element rather than per posting, so the positions row in the first table is scaled by occurrences per posting to put every row on one footing. And the cost ratios are the softest figures here: there is no list price for RAM, so the ~100× comes from differencing instance families that vary only in memory, and it lands anywhere between 75× and 145× depending on the pair. The ordering is what carries the argument, and the ordering is not close.
Caching: everything except the decision
The read path in §6 spends 177 ms doing work, most of it repeated across queries, and a cache is the obvious response. It is also the place where this design is most likely to be quietly broken by someone optimising in good faith, because the most valuable-looking thing to cache is the one thing that must not be.
The test to apply to each candidate is not "how often does this change?" but "what is the consequence of serving it stale?" — and in this system those two questions have different answers for different data.
| Candidate | Changes when | Stale consequence | Cache? |
|---|---|---|---|
| Postings blocks, term dictionary | Never — segments are immutable | None possible | Yes, aggressively (page cache) |
| Post text for hydration | On edit, rare | A stale snippet | Yes, invalidated on edit |
| Results of a public-only query | As the corpus grows | A slightly old result set | Yes, short TTL |
| A post's audience descriptor | When the author changes it | Wrong rule evaluated | Yes, short TTL, invalidated on write |
| "Can viewer V see post P?" | On any graph change, at any time | A privacy leak | No |
The last row is §5's argument arriving in a different costume. A cache of visibility decisions with a sixty-second TTL is functionally an index with a sixty-second staleness window, and the reason to reject it is the one the Unicorn authors gave for rejecting the index: after an unfriend, "even a 30-second lag-time is unacceptable". A cache does not become acceptable by being called a cache.
The fourth row is the interesting one, because it looks like the fifth and is not. Caching a post's audience descriptor memoises a property of the post — "this post is visible to the author's friends" — which changes only when the author edits it, and can therefore be invalidated by the write that changes it. Caching the decision memoises a property of the pair, which changes when either side's relationships change, with no write to this system to hang an invalidation on. Same latency saving, completely different correctness story.
That distinction is what makes the visibility check affordable without weakening it. The expensive part is the reachability evaluation, and it is the authoritative service's job to make that fast — a friend-set membership test against data it owns and can invalidate. This system caches the inputs it is allowed to cache and asks for the answer every time.
Where the cache actually pays. The two highest-value caches here are unglamorous. Postings blocks in the operating system's page cache are what make the ~20 ms shard execution in §6 possible at all — §7b prices the alternatives against that budget and finds that only memory and local flash fit it. And a public-only result cache is effective precisely because public content has a pass rate of 1: the query needs no per-viewer filtering, so the entire result set is shareable across every viewer. Logged-out and public search traffic is the part of this workload that behaves like a normal search engine, and it should be served like one.
Why there is no per-viewer result cache, even for repeated queries ›
A user who searches the same thing twice in a minute is common, and caching their result set by (viewer, query) would serve the second one for free. The reason not to is that the cached entry contains post ids that passed a visibility check at write time, and nothing invalidates it when the check's answer changes.
The exposure is smaller than it first appears, and worth stating precisely: the viewer already saw those results, so the leak is a continuation rather than a disclosure. That makes it tempting. It is still a case where the product tells a user "you may see this" after the platform has decided they may not, and the whole architecture exists to make that impossible rather than merely unlikely.
If the latency saving were needed, the safe version caches the candidate list — the merged, pre-filter ranking, which contains no visibility decision — and re-runs the check on replay. That keeps the expensive fan-out and skips none of the correctness.
Scaling the read path: cutting fan-out, keeping scores comparable
§6 identified the dominant latency term, and it was not the part of the system this article has spent the most words on. It was the fan-out: waiting for the slowest of roughly 1,314 shards. Scaling this system means attacking that number, and then dealing with the two problems that attacking it creates.
Reducing the number of shards a query touches
There are only two ways to touch fewer shards: make each shard hold more, or route the query to a subset.
Bigger shards have a ceiling that is not about storage. Elastic's guidance puts the working range at 10–50 GB per shard, and the constraints behind it are recovery time, merge cost and heap pressure rather than capacity — a shard is the unit of replication and rebalancing, so a very large one makes every failure slower to recover from. Pushing from 50 GB to 200 GB would cut the fan-out to about 330, which is a real improvement, at the price of quadrupling how long it takes to rebuild a lost shard.
Routing is the better lever, and it works here because of something true of post search specifically: queries are overwhelmingly recency-biased. People search for the thing they saw last week far more often than for something from 2019. That makes time a partitioning dimension the query knows in advance, which is exactly the property §3's callout said routing requires.
So the index is tiered by age. A recent tier — the last few weeks — is touched by every query and is therefore held on the media §7b showed can serve a 20 ms shard: page cache over local flash. An archive tier holds everything older, partitioned by time range, and a query touches only the ranges its since/until filters overlap. An unfiltered query still has to consider the archive, but it can consult the recent tier first and skip the archive entirely once it has enough high-scoring recent results.
Routing and storage cost are the same argument reaching the same conclusion from two directions. Routing makes the archive rarely touched; being rarely touched is what makes it affordable to put on storage two orders of magnitude cheaper and three orders of magnitude slower. Neither half works alone — a cheap archive that every query reads is just a slow index.
Twitter's Earlybird shows the shape at the segment level: only the newest segment per server is actively written, and the rest are optimised into a compact read-only form — which is what makes holding many of them per server affordable.
The problem tiering creates: scores stop being comparable
This is the part most write-ups skip, and it is a genuine correctness issue rather than a tuning detail.
Relevance scoring weights a term by how rare it is — a match on "photosynthesis" means more than a match on "the". Rarity is measured as inverse document frequency, and document frequency is counted per shard, because counting it globally would require a round trip before the query could start. Elasticsearch's default search type, query_then_fetch, does exactly this: each shard scores using its own local statistics, and the coordinator merges scores that were computed against different denominators.
With documents distributed randomly across many shards this is close to harmless, because every shard's local frequency approximates the global one. Two things break that assumption, and this design has both. Rare terms are the first: a term appearing in three documents platform-wide may appear in one shard and not its neighbour, so the same document scores differently depending on where it landed. Non-random partitioning is the second, and it is the one tiering introduces — once shards are partitioned by time, terms that were common last year and rare this month have wildly different local frequencies, and a document's score depends on which tier it is in.
The available fix is dfs_query_then_fetch, which adds a preliminary round trip to gather global term statistics before scoring. Elastic's documentation describes it as slower but more accurate, and the cost is precisely the thing §6 is trying to protect: an extra fan-out to 1,314 shards before the real one begins.
Also worth getting right in an interview: Elasticsearch's default similarity has been BM25 since version 5, not classic TF-IDF. Several widely-shared guides still describe the scoring as TF-IDF. BM25 differs in ways that matter for exactly this workload — it saturates term frequency, so a post repeating a word twenty times does not score twenty times higher, and it normalises by document length against the corpus average, which matters when posts range from four words to four hundred.
The proportionate answer is not to enable global statistics for every query. It is to keep the distribution random within a tier, so local statistics stay representative for the common case, and accept score drift across tiers as the price of the routing that makes the system fast. Where it matters — a small number of head queries where ranking quality is worth the latency — the global-statistics path can be turned on selectively. Turning it on everywhere buys accuracy the users cannot perceive at a latency cost they can.
Scaling the write path: freshness, merges, and rebuilding the index
Staying fresh: one writable segment, many frozen ones
§2 asks for a new post to be findable in about ten seconds, and §3 says 231.5k postings per second are arriving. Immutable segments and a ten-second freshness target are in obvious tension — an immutable structure cannot absorb a write.
The resolution is that exactly one segment per shard is mutable. New postings append to an in-memory segment that is optimised for write throughput rather than compactness; queries search it alongside the frozen ones and merge the results. Periodically it is sealed, optimised and becomes read-only, and a fresh one takes over. Elasticsearch exposes this as the refresh interval, defaulting to one second — which is the knob that decides how quickly a write becomes visible, and the single most useful thing to name when an interviewer asks how near-real-time search works.
Earlybird is the worked example: its measured end-to-end ingest latency was around ten seconds, with queries around fifty milliseconds — which is where §2's freshness target comes from.
What merging actually costs, and why it shows up as query latency ›
Sealed segments accumulate, and more segments means more lists to walk per query, so the system continuously merges small segments into larger ones. Merging rewrites data that was already written: a measurement on a Wikipedia index put the write amplification at 6.19× — every byte indexed was written to disk more than six times over the index's life.
At 231.5k postings per second that is not a background detail. Merge I/O competes with query I/O on the same devices, which is why a merge storm appears to users as a latency spike with no change in traffic. The mitigations are throttling merge throughput, scheduling large merges off-peak, and separating the I/O budget so queries are not starved — all of which trade index compactness for predictable reads.
Changing the ranking, and rebuilding the index, without a maintenance window ›
Both of these happen regularly, and neither can take the system down. They are the same problem twice: run the new thing alongside the old one, compare, then move traffic.
A ranking change starts offline. Production queries are logged with the ranked list they returned and what the user clicked, which gives a replay corpus — a candidate scoring function is run against those logged candidate sets and its ordering compared to the observed clicks. That catches gross regressions cheaply, and it is all that can be done offline, because a ranking that surfaces documents the old one never retrieved has no click data to be judged against. So the next stage is shadow scoring: the new function runs on live traffic, its results are recorded and not served, and the two orderings are compared on queries nobody had to see. Only then does traffic move, a percentage at a time, behind the same kill switch §10 uses for the visibility service.
A full reindex — a tokenisation change, a new field, a shard-count change — is the same shape with different plumbing. A second index is built from the post store, which §2 designates the system of record precisely so this is possible, while the change stream writes to both. When the new index catches up, queries are shadowed against it to compare result sets rather than latency, and the cutover is per-shard rather than global, so a bad build is rolled back by pointing one shard's traffic back at its old copy.
The detail that separates a real plan from a diagram: the comparison is on result sets, not on aggregate metrics. Latency and error rate will look identical between a correct index and one missing 3% of its documents. Only diffing the returned post ids finds that.
If your NFRs were different: when semantic retrieval earns its place ›
Everything above is lexical: a query matches posts containing its terms. That fails the user who searches "that restaurant my friend posted about" and matches nothing, because none of those words appear in the post.
Embedding-based retrieval fixes it by matching on meaning, and Meta published how they deployed it. The design decision worth taking from that paper is not that they used approximate nearest neighbour search — it is how they integrated it. They folded ANN into the existing boolean query language as an operator, and explicitly rejected running a separate vector index and fusing the two result sets, citing the performance cost of maintaining a dual index.
That rejection is the transferable lesson, and it is why this is an accordion and not baseline design: adding semantic retrieval is a second retrieval system, with its own freshness pipeline, its own sharding, and — critically for this article — its own encounter with the visibility filter, since nearest-neighbour results need the same per-viewer check and the ANN index cannot enforce it either. None of §2's requirements ask for it.
Failure modes: the degradation ladder
Search is allowed to fail. §2 sets availability at 99.9% and calls a failure degraded rather than lost, which gives this system a luxury the write path does not have: it can return less rather than returning wrong. The design question is what "less" means at each step down, and the ordering matters, because one of these failures has a consequence the others do not.
| Scenario | What goes wrong | Response | Level |
|---|---|---|---|
| A few shards are slow or unreachable | The coordinator waits for the slowest of 1,314; §6 shows the last 5% can double latency | Return results from the shards that answered, past a deadline, and mark the response partial. Replicas cover unreachable shards; the deadline covers slow ones. | L4 |
| Retrieval exhausts its candidate budget | Fewer than K visible results found; the user cannot distinguish this from "nothing matches" | Return what was found with truncated: true (§5b), and let the client offer a deeper or narrower search. |
L5 |
| The visibility service is slow | It sits on the critical path of every non-public query | Shed load by lowering retrieval depth — fewer candidates to check — before shedding queries. Never by skipping the check. | L5 |
| The indexer falls behind | Freshness silently breaches the ~10 s NFR; nothing errors | Track an ingest watermark per shard and alert on it. Surfacing "results may be incomplete" beats pretending the index is current. | L5L6 |
| A merge storm saturates disk I/O | Query latency spikes with no change in query traffic — the confusing incident | Throttle merge throughput and cap its I/O share (§9b). Diagnosis comes from correlating latency against merge activity, not request rate. | L6 |
| The visibility service is down entirely | Failing closed means non-public search is unavailable — the correct behaviour is also a full outage | Degrade to public-only search, which needs no check at all, and tell the user their results are limited. This is the only lever that preserves both correctness and a working product. | L7 |
| The visibility service is wrong | A bug that answers "visible" too often leaks content while every dashboard stays green | Continuous verification: canary posts with known audiences, queried by known non-viewers, alerting on any result. Plus a kill switch to public-only. | L7L8 |
| An index shard is lost | Its slice of the corpus is unsearchable | Serve from a replica; rebuild from the post store, which §2 designates the system of record. Recovery time is why §9 caps shard size. | L6 |
The last two rows are where the levels separate. Every row above them is a capacity or availability failure, and the response is some version of doing less work. The visibility rows are correctness failures, and they behave differently in one specific way: they do not announce themselves. A slow shard shows up in a latency graph. A visibility service that has started answering "yes" too often shows up nowhere — latency is fine, error rate is zero, the product works, and the only signal is content reaching people it should not.
That asymmetry is the argument for continuous verification rather than monitoring. Canary posts with known audiences, queried by accounts known not to be in those audiences, turn a silent correctness failure into an alert. It is a cheap mechanism and it exists because nothing else in the stack can detect the failure at all.
Fail-closed has a cost, and the honest answer names it. "Fail closed" sounds like the end of the discussion, and it is not — if the visibility service is unavailable and the system refuses to return unchecked results, search is down. The public-only fallback is what makes fail-closed survivable rather than merely correct: public posts need no check, so a degraded search over public content is available when the check is not. Saying "fail closed" without naming that consequence is the answer that sounds senior; naming it is the one that is.
Abuse: search as a privacy oracle
The abuse model for most systems in this series is borrowed from the general user-generated-content checklist — spam, scraping, rate limits. This system has one that is specific to it, and it survives every mechanism §5 put in place.
A search engine that filters results per viewer is, structurally, an oracle. The attacker cannot read the hidden posts, but they can ask questions whose answers depend on them, and the system answers honestly because it was built to. Filtering the results is not the same as filtering the information the response carries about the results.
Three channels that leak
Result counts. A total-matches number computed before filtering tells the viewer how many posts exist that they cannot see. Computed after filtering it is expensive and still differential — the count changes when a hidden post is created. §5b omits the count entirely, and this is the second reason why.
Timing. This one is harder, and it is worth admitting rather than solving badly. A query matching nothing at all terminates immediately: the postings lists are empty and there is nothing to check. A query matching two hundred posts, all invisible to this viewer, walks those postings, scores them, merges them, batches them to the visibility service, and gets zero survivors — running the entire pipeline to return the same empty page. The responses look identical and take measurably different amounts of time, so an attacker who can search a distinctive phrase can distinguish "no such post exists" from "a post exists that you may not see."
The truncated flag. §5b introduced it as an honesty mechanism, and against an attacker it is the cleanest leak of the three: truncated: true with an empty result list says directly that the system found candidates and none of them were visible. The flag that makes the product honest makes the oracle precise.
What to actually do about it
The last one has a clean fix, and it is the kind of detail an interviewer remembers: report truncation only when the page is non-empty. A response with zero results is always a plain empty result set, whatever happened internally. The user who got zero results has nothing to act on anyway — "search deeper" is not a useful offer when nothing surfaced — so the signal is worth nothing to them and everything to an attacker.
Timing has no clean fix. Padding every response to a fixed duration means every query pays the worst case, which contradicts §2's latency target outright. The proportionate mitigations are to rate-limit per account so probing costs more than it yields, to add jitter that makes single-query inference unreliable without making the median slower, and to detect the access pattern — a sequence of narrowly varying phrase queries returning zero results is not what search traffic looks like.
State the residual risk rather than claiming it is closed. A per-viewer filtered search cannot be made fully non-differential without destroying its latency profile. The defensible position is that the leak is reduced to a low-bandwidth timing channel that requires sustained, detectable probing to exploit — not that it is eliminated. An answer claiming the channel is closed is wrong in a way an interviewer working on this problem will recognise immediately.
The other two, briefly
Expensive queries as a denial of service. §3 made the cost of a query a function of shards touched and retrieval depth, and both are attacker-influenceable: an unfiltered query over rare terms with a low pass rate is the most expensive thing this system can do, and it costs the attacker one HTTP request. Rate limiting by request count prices every query the same and therefore prices none of them correctly. The mechanism that fits is cost-based admission — estimate shards × depth before executing, charge it against a per-account budget, and reject or degrade when the budget is spent. Deep pagination is the same problem with a cursor attached, which is why §5b's cursor is signed.
Index poisoning. Stuffing a post with unrelated popular terms to appear in searches for them is the oldest attack on any index. Part of the defence is already present for unrelated reasons: BM25's term-frequency saturation (§9) means repeating a word twenty times does not score twenty times higher, so the crudest version of the attack fails against the default scoring function. The rest is ordinary — spam signals in ranking, per-author indexing rate limits, and treating a sudden vocabulary shift in one author's posts as a signal.
Deletion that is actually deletion. §7's tombstone hides a document from queries; it does not remove its text from the segment file, which keeps it until a merge rewrites the segment. For an ordinary delete that is fine. For a legal erasure request it is not — the obligation is that the content is gone, not unfindable. The mechanism is to force a merge of segments containing erasure-marked documents inside the compliance window, and to remember that the index is not the only copy: caches (§8), snapshots, and any log that captured post text are all in scope. The post store delete is the authoritative act; everything else is cleanup with a deadline.
How to answer the post search question at your level
Almost every candidate draws the same three boxes: a coordinator, a set of index shards, a document store. The levels do not separate on the diagram. They separate on when the candidate notices that a result set has to be filtered per viewer, and what they do once they have noticed — because that single observation reshapes the capacity model, the API, the caching strategy and the failure analysis.
L4 Builds a correct inverted index and knows the query has to be filtered ›
- Explains an inverted index concretely — tokenise, normalise, term to postings list — and that query tokenisation must match indexing
- Shards the index and fans a query out to all shards, merging the results
- Notices unprompted that results must be filtered against what this viewer may see
- Separates the index from a document store, and hydrates only the results being returned
- Uses cursor pagination rather than offsets
- Filters the top ten after retrieval and does not see that the page comes back short
- "We'll put permissions in the index" with no account of what happens when permissions change
- Sizes the system by storage, so the fan-out never appears as a cost
L5 Quantifies over-fetch and defends where the check runs ›
- Derives the over-fetch factor — K/p candidates to fill a page of K at pass rate p — and multiplies it by the shard count to get the real merge cost
- Argues the placement of the visibility check from staleness: an index cannot apply a permission change with the urgency a permission change needs
- Caps candidates with a truncation limit, and states the recall it costs
- Batches the visibility check rather than calling per candidate
- Handles near-real-time indexing with one writable segment and many immutable ones
- Treats the over-fetch factor as a constant instead of a measured, per-query-class quantity
- Fans out to every shard with no plan to reduce the number, so the tail is never addressed
- Says "fail closed" without naming that it makes search unavailable
L6 Attacks the fan-out tail and sees the correctness traps ›
- Identifies the slowest-of-N fan-out as the dominant latency term, not the visibility check, and reduces N by routing on time rather than by buying bigger machines
- Raises per-shard scoring statistics unprompted, and knows that non-random partitioning makes the drift worse
- Distinguishes caching a post's audience rule from caching a visibility decision, and rejects the second
- Gets the delete ordering right — post store first — so a crash drops results rather than serving dangling ones
- Knows internal document ids are ephemeral and never lets one escape the index
- Treats a correctness failure in the visibility path as monitorable the way a latency failure is
- No degradation path between "fully correct" and "down"
- Optimises the system without asking what the product actually needs from it
L7/L8 Treats silent correctness failure and the oracle as first-class ›
- Observes that a visibility bug produces no signal — latency normal, errors zero — and proposes continuous verification with canary posts rather than monitoring
- Builds the degradation ladder explicitly, with public-only search as the rung that makes fail-closed survivable
- Raises search as a privacy oracle without being asked, names the timing channel, and declines to claim it is closed
- Prices queries by estimated cost rather than counting requests, because the expensive query is the cheap attack
- Scopes erasure across the index, the caches, the snapshots and the logs, and knows a tombstone is not a deletion
- Reasons about the organisation as well as the system: who owns the visibility service, and what it means that search depends on a team it does not control
- Says which requirement they would renegotiate — and a pass rate floor, or public-only search for logged-out users, is usually cheaper than any engineering
Classic probes
| Probe | L4 answer | L6+ answer |
|---|---|---|
| "How do you make sure users only see posts they're allowed to see?" | Filter the results against a permissions check before returning them. | Names where the check runs and why, derives the over-fetch cost that placement creates, and explains that the mechanism follows the shape of the permission rule — a global flag can partition the index, a graph relationship cannot. |
| "A user searches and gets four results instead of ten. What happened?" | Only four posts matched. | Distinguishes the three causes — genuinely four matches, six filtered out with no deeper retrieval attempted, or truncation at the candidate ceiling — and points out that the API has to tell them apart, then notes that saying so on an empty page leaks. |
| "Your p99 is 800 ms and every shard reports a 20 ms p99. Where is the time?" | Looks for a slow component. | Recognises it immediately as fan-out amplification, cites that waiting for all leaves rather than 95% of them can double latency, and reaches for hedged requests, deadlines with partial results, and reducing the shard count — not for faster shards. |
| "Can I use search to find out whether someone posted about me?" | No, the results are filtered. | Treats it as the oracle question: filtered results still leak through counts, timing and truncation signals; fixes what is fixable, rate-limits and detects the rest, and states plainly that the timing channel is reduced rather than removed. |
Numbers to know
Each of these is either published by the team that measured it or derivable from the estimator in §3. The dates matter — several are a decade old, and they are included as the right order of magnitude for a platform of that size rather than as current figures.
Scale and sizing
| Number | Value | What it settles |
|---|---|---|
| Posts created per day, Facebook (2013) | ~1 billion | The corpus grows by a billion documents a day, which is why freshness is an ingest-rate problem and not a batch job (§3, §9b) |
| Posts index, Facebook (2013) | >1 trillion posts, >700 TB, moved from RAM to flash | That no single machine holds the index, so every text query fans out — the fact that produces the dominant latency term (§6). The same article records the storage decision, and the reason for it: spreading that much RAM across racks cost more in coordination than flash cost in latency (§7b) |
| Inverted index vs source data (Lucene feature list) | ~30% | Index sizing from corpus size; the one externally sourced constant in the estimator, and pre-Lucene-4.1, so a planning figure (§3) |
| Positional index on short social text (measured, 13M tweets) | 16.4 bits/posting best-in-class, 23 for Lucene — 63% document ids, 31% positions | That the estimator's implied 24 bits/posting is corroborated rather than assumed, and that positions are the one component worth dropping (§7b) |
| Short documents vs web documents (Zobel & Moffat, 2006) | ~8 bits/pointer for long documents vs 10.1 measured on tweets | That the standard compression figure understates a posts corpus, because short documents make posting lists sparser and gaps larger (§7b) |
| Storage cost and latency ladder (list price, 2026) | RAM ≈ 100× object storage; local NVMe 60–90 µs vs object storage tens of ms | Which media can serve §6's 20 ms shard budget, and therefore which tier can live on which — the decision behind §9's tiering (§7b) |
| Working shard size (Elastic, current) | 10–50 GB | Shard count, and therefore fan-out width — ~1,314 shards at the estimator's defaults (§3, §9) |
| Merge write amplification (measured, Wikipedia index) | 6.19× | That indexing I/O is several times the postings rate, so merges compete with queries for the same devices (§9b) |
Latency and the fan-out tail
| Number | Value | What it settles |
|---|---|---|
| Fan-out amplification (Dean & Barroso, 2013) | 1-in-100 slow, 100 servers → 63% of requests slow | That a rare slow shard is a common slow query — the reason shard count is a latency quantity, not a storage one (§6) |
| Waiting for the last leaves (same, measured) | 70 ms at 95% of leaves, 140 ms at 100%, with a 10 ms per-leaf p99 | That deadlines with partial results are worth more than faster shards; it is the top of the degradation ladder (§6, §10) |
| Truncation limit vs latency (Unicorn, 2013) | Latency crosses 100 ms at a limit around 5,000 | Where the candidate ceiling sits, and that some queries will always need truncating (§5) |
| Earlybird serving (Twitter, 2012) | ~10 s ingest latency, ~50 ms query latency | That ~10 s freshness is achievable with one writable segment per shard — the source of §2's freshness NFR (§9b) |
| Elasticsearch refresh interval (default) | 1 s | The knob that turns a write into a searchable document; the concrete answer to "how does near-real-time indexing work" (§9b) |
| Cache hit / intra-AZ round trip | 0.1–0.5 ms / ~0.3 ms | That one batched visibility call costs milliseconds and a per-candidate call would not — the reason the check is batched (§6) |
The two that decide the design
| Number | Value | What it settles |
|---|---|---|
| Over-fetch factor | K/p candidates per shard for a page of K at pass rate p | The whole capacity model: 50 candidates per shard for a page of 10 at a 20% pass rate, and 65.7k rows merged across 1,314 shards (§3, §5) |
| Materialise vs traverse (Unicorn, 2013) | 484 GB at 20 ms vs 178 TB at 7 ms — 368× the storage for a 3× latency win | What precomputing a derived relation into the index actually costs, and why visibility — which changes constantly and cannot be stale — is the wrong candidate for it (§5) |
| Deep paging ceiling (Elasticsearch default) | index.max_result_window = 10,000 | That offset pagination is capped by the engine itself, and cursors with a tiebreaker are the supported path (§5b) |
- Rate Limiter System Design, atomic Redis operations, distributed race conditions, and multi-tier quota enforcement
- URL Shortener System Design, hash encoding tradeoffs, database sharding strategies, and viral key mitigation
- Web Crawler System Design, Bloom filter deduplication, politeness throttling, and distributed frontier design
- Twitter/X Feed System Design, fan-out write amplification, hybrid push/pull strategy, and celebrity threshold design
- Notification Service System Design, multi-channel delivery, idempotency keys, and priority queues at scale
- Search Autocomplete System Design, Trie data structures, prefix caching, and read-heavy scale strategies
- Key-Value Store System Design — quorum consensus, LSM trees and SSTables, and anti-entropy repair
- Distributed Cache System Design — consistent hashing, eviction and TTL policy, hot keys, and cache-aside vs write-through
- Chat System (WhatsApp) System Design, WebSocket management, transient vs persistent storage, and read receipts
- Video Streaming (YouTube) System Design, ABR streaming, CDN distribution, and metadata management
- Distributed Message Queue System Design, Kafka partition tuning, exactly-once delivery, and geo-replication
- File Storage (Dropbox / Google Drive) System Design, chunking, delta sync, conflict resolution, and global deduplication
- Ride-Sharing System Design (Uber / Lyft) — geohashing, WebSocket-driven location tracking, and ETA prediction
- Payment Processing System Design — idempotency keys, exactly-once semantics, and append-only ledger models
- Top-K Leaderboard System Design — Redis sorted sets, approximate counting, and stream aggregation
- Airbnb Booking & Reservation System — inventory locks, double-booking prevention, and async elasticsearch sync
- Photo-Sharing Feed System Design — image pipelines, CDN delivery, and social graph scaling
- Proximity Search System Design (Yelp / Google Places) — geohash indexing, quadtree partitioning, and Bayesian review ranking
- Online Judge System Design — secure sandboxing, execution queues, and worker scaling
- Collaborative Document Editing (Google Docs) System Design — operational transformation vs CRDTs, ownership leases, and offline merge
- Ticket Booking & Flash-Sale (Ticketmaster) System Design — seat-hold contention, optimistic vs pessimistic locking, and virtual waiting rooms
- Google Maps System Design — road-graph routing, contraction hierarchies vs customisable preprocessing, vector tiles, and live-traffic ETAs
- Object Storage (Amazon S3) System Design — erasure coding across zones, durability math, and a range-partitioned metadata index