Design a search suggestion service
Open in the interactive app: practice, quiz, mock interview →In one minute
- Precompute, never search, on a keystroke. Each prefix's top k completions are computed ahead of time and stored with the prefix, so the request path is a lookup, not a ranking job.
- Rank by decayed frequency, not raw counts. Each past search loses half its weight every half-life. Storing a score plus its last-update time keeps updates O(1) while old fads fade on their own.
- Two layers of freshness. Hourly immutable snapshots plus a small real-time trending layer, merged at query time.
- Shard by prefix range, not by hash. Almost every prefix then lives on one shard; the short prefixes that span several are scatter-gathered and mostly served from cache anyway.
- Most of the latency is the network and most of the risk is content. Debounce and cache at the client and edge, and design filtering (abuse, manipulation, takedowns) in from the start.
Requirements
Functional
- As the user types, return up to 10 query completions for the current prefix, in ranked order.
- Rank by how often a phrase was searched, weighted towards recent searches, per locale.
- New trending phrases appear within about 15 minutes; the long tail can lag by a day.
- Filter unsafe, spammy or legally removed phrases, and remove a phrase everywhere within minutes of a takedown. Google's published policy is a useful model: it drops predictions that are sexually explicit, hateful, violent or dangerous, plus spam, piracy-linked terms and valid legal removals (Google, 2018).
- Blend in a user's own recent searches when they are signed in.
Non-functional
- Server time under 10 ms at p99, end to end under 100 ms for most users.
- 99.99% availability, failing open: an empty list is degraded, not an outage, because the search box still works.
- 2x headroom over peak. Suggestions may be minutes stale and need not agree across replicas.
Out of scope
- Spelling correction and fuzzy matching, ranking full search results, ads in the suggestion list, and mid-word matching ("phone" matching "iphone").
Back-of-envelope
- Traffic. 300M daily active users, 4 searches each: 1.2B searches per day. With client debounce, about 4 suggestion requests per search reach us: 4.8B requests per day. 4.8 × 10^9 / 86,400 ≈ 56k QPS average. Peak is roughly 2.5x the mean, so about 140k QPS, and the design point with headroom is 300k QPS.
- Cache offload. Prefixes of one to three characters are a few hundred thousand keys per locale and account for perhaps 40% of requests. With a 5-minute CDN TTL, origin peak drops to around 85k QPS.
- Write events. 1.2B completed searches per day ≈ 14k events per second, around 150 bytes each: 180 GB per day raw, 1.8 × 10^11 × 1,825 ≈ 330 TB over 5 years. Keep 30 days raw (5.4 TB), then only daily aggregates.
- Corpus. After dropping phrases seen by fewer than 20 distinct users in 30 days, keep about 100M phrases across locales, averaging 22 bytes: 2.2 GB of text. A naive pointer trie over them has on the order of 1B nodes; at 64 bytes of children plus a 10-entry top-k list of 4-byte phrase IDs, that is over 100 GB. A compact layout (below) brings it to roughly 10 to 15 GB, which we still shard.
- Bandwidth. A response is 10 phrases at about 30 bytes plus framing, around 600 bytes compressed to 300. 140k × 300 B ≈ 42 MB/s at peak, trivial; TLS and connection CPU sizes the fleet.
API
GET /v1/suggest?q=how+to+ma&locale=en-US&k=10
-> 200 {
"prefix": "how to ma",
"suggestions": [ {"text": "how to make pancakes"}, {"text": "how to make money"} ],
"snapshot": "en-US/2026-10-16T09:00Z"
}
Cache-Control: public, max-age=300 (only for prefixes of 3 characters or fewer)
POST /v1/search-events
{ "query": "how to make pancakes", "locale": "en-US",
"session_id": "s-81f2", "typed_prefix": "how to ma", "picked_rank": 1, "ts": 1760605200 }
-> 202
Internal RPC (suggest frontend to shard):
TopK(locale, prefix, k, snapshot) -> [ {phrase_id, score} ]
Admin:
POST /v1/blocklist { "pattern": "exact phrase or rule", "reason": "legal", "ticket": "T-1234" } -> 201Scores stay internal because they change with every build. picked_rank in the search event lets the pipeline learn which suggestions people actually click.
Data model
- search-events (Kafka topic): keyed by normalised phrase, so one phrase's events share a partition and can be counted without a shuffle. Retained 7 days and archived hourly to object storage as Parquet.
- phrase_stats (batch output in columnar files):
(locale, phrase)key,decayed_score,score_updated_at,distinct_users_30d,first_seen. Rewritten by each batch run; never read by serving traffic. - snapshot files (object storage): one immutable file per
(locale, shard, version): the compact trie plus aphrase_idto text table. A per-locale manifest lists the version, split points and each file's SHA-256. All shards of one version therefore agree on split points. - trending counters (in memory on each shard):
phrase -> (score, last_update)for phrases seen in the last hour, owned by the shard whose range contains the phrase. - blocklist (Postgres):
rule_idPK,pattern,match_type(exact, prefix, regex),reason,created_by. Small, transactional and audited; pushed to every server as a versioned set and also applied at build time. - recent searches (per-user key-value store):
user_id -> last 20 queries, partitioned byuser_id. Read once per session, not per keystroke.
The partition key for serving is (locale, prefix range). Locale comes first because rankings differ completely between en-US and pt-BR, and a locale is the natural unit to build and roll back.
High-level design
flowchart LR U["Client: debounce, cache"] --> CDN["CDN edge cache"] CDN --> FE["Suggest frontend"] FE --> RT["Prefix-range router"] RT --> S1["Shard a..f"] RT --> S2["Shard f..p"] RT --> S3["Shard p..z"] U -->|"completed search"| EV["Event collector"] EV --> K["Kafka: search-events"] K --> TR["Trending counters on shards"] K --> ARC["Object storage archive"] ARC --> B["Hourly batch: score, filter, build"] BL["Blocklist"] --> B BL --> FE B --> SN["Snapshot files and manifest"] SN --> S1 SN --> S2 SN --> S3
Read path. The client debounces about 50 ms, cancels any in-flight request and checks its own cache; on a miss the CDN answers short prefixes. The frontend normalises the prefix (lowercase, Unicode NFC, collapse spaces), finds the shards owning that prefix in the current manifest (usually one), and calls them in parallel. Each shard walks its compact trie to the prefix node, reads the stored top-k phrase IDs, merges in its trending counters, drops anything on the blocklist, and returns its best k. The frontend merges the lists, deduplicates, takes the top k and returns text.
Write path. A completed search becomes an event, validated, normalised and written to Kafka. Each shard's trending consumer updates decayed counters for phrases in its range; an archiver writes hourly files. Every hour, a batch job reads the last 30 days of aggregates plus the new hour, recomputes decayed scores, applies thresholds and the blocklist, chooses split points from traffic, builds one snapshot file per shard, and publishes a new manifest. Shards download the new files, build the in-memory structure off the request path, and swap a pointer.
Deep dives
1. The serving structure
Options: edge n-grams in an existing search cluster, a pointer trie with a top-k list per node (covered in Search and Typeahead), or a compact trie or finite state transducer built offline. Edge n-grams make every prefix an indexed term and every keystroke a scored search, costing milliseconds, not microseconds. Facebook's people typeahead worked this way at scale, with a posting list for every name prefix up to a fixed length, but it ranked by social graph, not by a global top k (Unicorn, VLDB 2013). A pointer trie wastes memory on a heap object per node and a pointer per child.
I would build a compact trie offline. Nodes are laid out breadth-first in flat arrays: a first_child index, a label byte per edge, and a topk_offset into a shared array of phrase IDs. Single-child chains are collapsed into one edge with a multi-byte label (a radix tree), which removes most nodes in natural language because phrases share long middles. Top-k lists are stored only where they help: at a node whose subtree contains more than k phrases. Below that, the subtree is small enough to enumerate at query time in a few hundred nanoseconds. Lucene's completion suggester makes a similar choice with a weighted FST, and Elasticsearch exposes it as a completion field that is costly to build and held in memory (Elasticsearch suggesters). An FST beats a trie on memory because it shares suffixes as well as prefixes: one public build packed 1.6 billion Common Crawl URLs into 27 GB, about a fifth of the sorted input (Andrew Gallant). Either way, building offline into an immutable result is what allows the compact layout.
One catch with filtering at query time: if a node stores exactly k phrases and the blocklist removes three of them, the shard can only return seven. Store a margin, say 2k, at each node so filtering still fills the list. Lucene's suggester documents the same hazard, warning that many deletions or a restrictive filter make its pruned top-N search miss results (Lucene NRTSuggester).
2. Freshness and ranking with decayed frequency
Raw 30-day counts rank "world cup schedule" above "election results" on election night. Exponential decay needs two numbers per phrase: score and updated_at. Reading at time now gives score × 2^(-(now - updated_at) / half_life). Recording a search applies the decay and adds 1. Since all scores decay by the same factor, a stored ranking stays valid until new events arrive.
I use two half-lives. The batch layer uses 7 days for durable popularity; the trending layer uses 30 minutes for spikes. The shard blends them, for example batch + 3 × trending tuned against click-through, so a new phrase can appear within 15 minutes without a rebuild.
Counting is also the attack surface. Ranking by distinct users rather than raw events, requiring a minimum number of distinct users before a phrase is eligible, and capping one session's contribution stop a script from pushing a phrase into everyone's suggestion list. Recent personal searches are merged on the client, so personalisation never pollutes shared caches.
3. Partitioning, routing and merging
Hashing the full phrase spreads load evenly but sends every prefix query to every shard, since completions of "how" are scattered. Hashing the first two characters keeps prefixes together but cannot be rebalanced: "th" and "s" are enormous, "qz" is empty. Prefix ranges, as in Bigtable's tablets, which are contiguous row ranges split as they grow (Bigtable, OSDI 2006), keep neighbours together and can be rebalanced by moving split points. Azure's sharding guidance names the price: range sharding does not balance load on its own, and rebalancing may not help when activity piles up on adjacent keys (Azure Sharding pattern). That is why the split points below follow traffic, not phrase counts.
The batch job picks split points so each shard carries a similar share of expected request traffic, not of phrases. That pushes split points deep in busy areas, such as [ca, cat) then [cat, d), so "c" spans several shards. The routing rule is simple because strings with a common prefix are contiguous in sorted order: start at the shard owning the prefix itself, then include one more shard for every split point that begins with the prefix. Multi-shard prefixes are short, and short prefixes are what the caches absorb.
Merging must deduplicate, because during a rebalance a phrase can briefly exist on both the old and the new owner, and each shard must return its own top k (not fewer) so the global top k is guaranteed to be in the union. All shards of one request use the same pinned manifest version.
Down the stack
Layer 1The contract
For a normalised prefix, return up to k phrases starting with it, ordered by blended score from one snapshot version plus trending state, excluding anything blocklisted when the request arrived. The long tail may be one build interval stale, trending a few minutes. If a shard misses its 15 ms budget, the frontend returns a partial answer, marked uncacheable, rather than an error.
Layer 2The mechanism
The compact trie is three arrays per shard: edges sorted by label within each node so a child lookup is a binary search over a few bytes, a node array of (first_edge, edge_count, topk_offset, topk_len) packed in 16 bytes, and a phrase-ID array. Lucene's equivalent, CompletionPostingsFormat (versioned per codec, Completion101PostingsFormat in Lucene 10), stores an FST per segment whose outputs carry a weight, surface form and document ID, and NRTSuggester runs a top-N search over it (Lucene NRTSuggester). The trending layer is a hash map plus a min-heap per hot prefix, updated by the Kafka consumer thread and read by request threads through a read-copy-update swap every second, so readers never take a lock. Snapshot loading verifies the SHA-256 from the manifest before swapping.
Layer 3The machine
An 8-character prefix touches about 8 nodes. In a pointer trie each is a separate heap object and likely a cache miss at 80 to 100 ns, close to 1 µs per lookup. In the flat layout, root-level nodes stay hot in L1 and L2 and breadth-first order packs siblings into one 64-byte line, so a lookup is well under 1 µs. A 12 GB snapshot is loaded with mmap, outside the garbage-collected heap; 12 GB of small heap objects could cost GC pauses of tens of milliseconds, the whole budget. The network still dominates: a mobile round trip is 50 to 150 ms and a fresh TLS 1.3 handshake adds one more (RFC 8446), so clients keep one HTTP/2 connection open while the box is focused and multiplex keystroke requests over it (RFC 9113).
Why it's built this way
- Users type faster than a search engine can rank, so answers per prefix must be precomputed and looked up.
- Precomputed answers for a billion prefixes are large, so they are built offline into a compact, immutable layout that the CPU cache likes.
- Immutable snapshots are stale between builds, so a small, mutable trending layer with fast decay covers the gap.
- The snapshot no longer fits one machine, so it is split by prefix range, because range splitting keeps one prefix's answer on one shard.
- Even a microsecond lookup sits behind a 100 ms mobile round trip, so caching at the client and edge saves more than any server optimisation.
Failure modes
- 10x load. The CDN absorbs short prefixes; frontends shed by serving a cached shorter prefix's answer (filtered client-side), and clients lengthen debounce on a back-off hint.
- Region outage. Serving is stateless apart from snapshots, so other regions absorb the traffic. Trending counters are regional, so trending is briefly less fresh after failover.
- Hot prefix. A celebrity's name saturates one shard. Add replicas for that range, edge-cache the prefix regardless of length, and let the next build move split points.
- Bad snapshot. A build bug ranks spam first or produces an empty trie. Mitigation: validate against golden prefixes, canary the manifest on one replica group, and roll back by reverting the manifest pointer.
- Pipeline stall. Kafka lag or a failed batch means no new snapshot. Shards keep serving the last good version, and an alert fires when snapshot age exceeds two build intervals.
- Harmful suggestion goes live. A takedown is added to the blocklist, pushed to frontends within a minute and enforced at query time, so it does not wait for the next build. Build-time classifiers are never complete; Google says its own systems "are not perfect or precise" and that it enforces policy when predictions slip through (Google, 2020).
In the wild
- Facebook, 2012: typeahead on a social-graph index. Facebook replaced its first typeahead backend, which was already serving hundreds of millions of queries a day but was hard to scale past half a billion users, with Unicorn, an in-memory index spread over thousands of servers. Index servers hold a posting list for every name prefix up to a fixed length, so "J", "Jo", "Jon" each map straight to candidate IDs. A plain prefix lookup ignored who your friends are, so Unicorn added a
WeakAndoperator that reserves part of the result list for friends without requiring every result to be one. By the end of 2012 it served over a billion typeahead queries a day (Curtiss et al., VLDB 2013). - Google, 2020: a policy change enforced in the suggestion layer. Ahead of the US election, Google said it would remove predictions that could be read as claims for or against a candidate or party, or about the legitimacy of the electoral process: both "you can vote by phone" and "you can't vote by phone" were to disappear, as was "donate to" any party. It also said it had tuned its systems to show no prediction when the query may not lead to reliable content, and that because those systems are imperfect it removes predictions that slip through. Suggestions are an editorial surface, so the takedown path is a core feature (Google, September 2020).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Compact trie or FST, offline build | Sub-microsecond lookup, small memory | Stale between builds, build pipeline to run | Large query-log autocomplete |
| Edge n-grams in a search cluster | Reuses existing cluster, flexible filters | Larger index, millisecond queries | Product or entity search at moderate scale |
| Prefix-range sharding | One shard per typical prefix, rebalanceable | Short prefixes scatter, split points to manage | Corpus too big for one node |
| Hash sharding | Even load, no split management | Every query scatters to all shards | Rarely, for autocomplete |
| Decayed score | O(1) update, two numbers per phrase | One fixed half-life per score | Popularity with recency |
| Sliding window counts | Exact "last N hours" semantics | Memory per bucket, slow expiry | Compliance or reporting views |
Leadership lens
- Build first. The offline pipeline with a single, unsharded snapshot per locale and a golden-prefix check. Shard only when the snapshot outgrows one machine.
- Rollout. Launch to 1% of traffic, compare suggestion click-through against the old system, then ramp. Ship trending later behind a flag.
- Ownership. A search platform team owns serving and the pipeline. A trust and safety team owns the blocklist and its review queue, with a takedown path that needs no platform deploy.
- Cost drivers. Memory for snapshots times replicas times regions, CDN requests, and the event pipeline's storage. Raising the distinct-user threshold is the biggest single cost lever, and it improves quality too.
- Design review questions. How long from a takedown request to the phrase disappearing everywhere? What do users see when the build has been failing for six hours?
Check yourself
Why shard by prefix range instead of hashing the full phrase or its first two characters?
Show answer
Hashing the full phrase sends every prefix query to every shard, and hashing the first two characters keeps prefixes together but cannot be rebalanced because "th" is enormous and "qz" is empty. Prefix ranges keep neighbours together and rebalance by moving split points, so most prefixes hit one shard.
Why rank by decayed frequency rather than raw 30-day counts, and what does it cost to store?
Show answer
Raw counts let old fads outrank what people are searching for now, such as on election night. Exponential decay needs only
scoreandupdated_atper phrase with an O(1) update, and since all scores decay by the same factor a stored ranking stays valid until new events arrive.A build bug produces a snapshot that ranks spam first. How is the damage limited?
Show answer
Snapshots are validated against golden prefixes and the manifest is canaried on one replica group before rollout. If it still gets through, rollback is reverting the manifest pointer to the last good version.
How does about 56k QPS average become a much smaller origin load, and why does that matter for sharding?
Show answer
Prefixes of one to three characters are perhaps 40% of requests and are cached at the CDN with a 5-minute TTL, cutting origin peak from about 140k to around 85k QPS. Those short prefixes are also the ones that span several shards, so caching absorbs most scatter-gather queries.
Sources and further study
Primary
- Curtiss et al., "Unicorn: A System for Searching the Social Graph" (VLDB 2013): prefix posting lists, document-sharded index servers and the
WeakAndoperator behind Facebook typeahead. - Bigtable: A Distributed Storage System for Structured Data (OSDI 2006): row-range tablets that split as they grow, the model for prefix-range shards.
Systems docs
- Lucene NRTSuggester: the weighted-FST top-N search, with its warning about deletions and filters over-pruning results.
- Google: our latest investments in information quality (2020): first-party description of autocomplete removal rules and enforcement.
Books
- System Design Interview, Volume 1, Alex Xu, chapter 13 "Design A Search Autocomplete System": the interview-depth version of this design, a trie that caches the top k at each node and is rebuilt offline from query logs.
- Introduction to Information Retrieval, Manning, Raghavan and Schütze, chapter 3 "Dictionaries and tolerant retrieval": search trees and tries for prefix lookups, wildcard queries and k-gram indexes (free online).
- Designing Data-Intensive Applications (1st edition), Martin Kleppmann, chapter 6 "Partitioning": key-range versus hash partitioning and rebalancing, the trade-off behind prefix-range shards.
Other curricula
- Azure Architecture Center: Sharding pattern: compares lookup, range, hash and geographic sharding and warns that range shards drift out of balance, the reason split points here follow traffic.
- donnemartin/system-design-primer: query cache: frames the read side as an LRU memory cache in front of a reverse index, the caching half of this design without the precomputed per-prefix answers.
Watch
- System design: Design Autocomplete or Typeahead Suggestions for Google search (Tushar Roy): the trie with top-k per node and how to build it offline.
Free resources
- Using finite state transducers in Lucene (Mike McCandless): the compact structure behind Lucene's term index and its suggesters.
- Index 1,600,000,000 keys with automata and Rust (Andrew Gallant): a deep, readable explanation of how FSTs compress sorted keys and answer prefix queries.
- Completion suggester (Elasticsearch docs): how a production engine exposes in-memory prefix completion with weights.
- How Google autocomplete works in Search (Google): a first-party description of predictions, freshness and removal policies.
- Bigtable: A Distributed Storage System for Structured Data (Google Research): the range-partitioned tablet design that prefix-range sharding borrows.
- Trie (Wikipedia): background on tries, radix trees and their compressed variants.