The prompt
"Design search autocomplete" (Google's search box, a shop's search bar) is mostly a data pipeline question. The serving path is a lookup; the real design is how suggestions are counted, ranked, filtered and shipped to the servers that answer.
Snip Bio's search box suggests searches as people type: after su, show the five most popular searches starting
with "su". There are 10 million daily users, each searching about 5 times a day. Suggestions must appear within
about 100 ms of a keystroke, and a search that spikes (a creator going viral) should start appearing within minutes.
1. Requirements
Functional: return the top 5 suggestions for a prefix, ranked by popularity; reflect trending searches within minutes; never suggest offensive or private queries.
Non-functional: under 100 ms end to end, so a few milliseconds at the server; very high availability (a broken autocomplete is noticed immediately, though search still works without it).
2. Estimates
- Requests: 10 million × 5 searches × about 6 requests per search after debouncing ≈ 300 million a day, about 3,500 a second and 15,000 at peak. Several times the search traffic itself.
- Table size: suppose 10 million distinct queries clear the popularity threshold. Prefixes up to 10 characters, heavily shared, come to perhaps 50 million; × 5 suggestions × about 30 bytes ≈ 7.5 GB. That fits in memory on each serving node.
- Logs: 50 million searches a day is a few GB a day: an ordinary batch job.
3. Entities and API
SearchLog { query, user_id_hash, at, country } raw, kept 30 days (14.11)
QueryScore { query, score } rebuilt weekly with decay
PrefixTable { prefix → [(query, score)] × 5 } versioned snapshot
GET /suggest?q=su&locale=en-GB → 200 ["summer sale", "summer shoes", ...] Cache-Control: max-age=3004. High-level design
6 steps. Use Next and Back to follow the request.
Text version
- The browser debounces keystrokes and asks for /suggest?q=su. Short, popular prefixes are answered by the CDN, cached for a few minutes.
- Misses reach a suggest server, which holds the whole prefix table in memory: one hash lookup.
- It merges in anything from the trending layer for that prefix, so a search that spiked this hour can appear.
- Every search is logged. A weekly batch job counts queries, blends them with last week's decayed scores, filters them, and builds a new prefix table.
- Suggest servers load the new snapshot alongside the old one and switch over at once; a bad snapshot is rolled back by loading the previous version.
- A streaming job keeps approximate counts for the last hour with a count-min sketch and publishes the heavy hitters per prefix (11.04).
- Browser → CDN
- CDN → Suggest servers: miss
- Suggest servers → Trending layer: merge
- Search logs → Weekly build
- Weekly build → Table snapshots
- Table snapshots → Suggest servers: load
- Search logs → Stream job
- Stream job → Trending layer
5. Deep dives
Deep dive
Answering a prefix in a few milliseconds
WHERE query LIKE 'su%' GROUP BY query ORDER BY count(*) DESC scans millions of rows per keystroke.
The lookup walks the prefix and returns the stored list (10.06 Autocomplete). Fast, but updating weights in place is awkward, and the structure lives in one process's memory.
The build job writes prefix → top 5 directly, so serving is a hash lookup; every server holds a full copy (7.5 GB) and any can answer. If the table outgrows memory, shard it by hash of prefix, not by first letter, since "s" holds far more than "x".
export function buildPrefixTable(counts: Map<string, number>, k = 5, maxPrefix = 10) {
const table = new Map<string, [string, number][]>();
for (const [query, n] of counts) {
for (let len = 1; len <= Math.min(query.length, maxPrefix); len++) {
const prefix = query.slice(0, len);
const top = table.get(prefix) ?? [];
top.push([query, n]);
top.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
if (top.length > k) top.pop();
table.set(prefix, top);
}
}
return table; // written to a snapshot: prefix -> top k
}
/** Blend this week into the running score so old spikes fade: halve the past every week. */
export function decay(previous: Map<string, number>, thisWeek: Map<string, number>, keep = 0.5) {
const out = new Map<string, number>();
for (const [q, n] of previous) out.set(q, n * keep);
for (const [q, n] of thisWeek) out.set(q, (out.get(q) ?? 0) + n);
return out;
}Deep dive
Keeping suggestions fresh
Last February's spike for "super bowl" stays at the top of su all year.
Each week's counts are added to half of the previous score, so a spike fades within weeks.
The weekly table gives stable ranking; a streaming top-k over the last hour (11.04 Count-min sketch and top-k), in event-time windows (11.02 Time-window aggregation), adds what's spiking now. The suggest server merges the two per prefix.
Tested with six queries over three weeks, k = 3. In week 1, su suggested "super bowl" (2,000), "summer sale" and
"summer shoes". In week 2, "super bowl" fell to 30 searches; its decayed score of 1,030 dropped it below "summer sale"
(1,400). By week 3, "sun cream", rising from 120 to 500 searches a week, had overtaken it as well: su showed
"summer sale", "sun cream", "summer shoes". The six queries produced 42 prefix entries.
Filtering. Before a query enters the table: drop anything on a blocklist (offensive, defamatory, or names a creator has asked to remove), and drop queries searched by fewer than, say, 50 distinct users, so nothing someone typed privately (a phone number, a name) is ever suggested to others. These are rules in the build, run before anything is served.
The client. Debounce about 100 ms, cancel requests for stale prefixes, cache by prefix in the browser, and filter already-fetched suggestions locally as the user keeps typing (10.06 Autocomplete). With a few minutes of caching at the CDN, most one- and two-letter prefixes never reach the servers.
6. Wrap-up
What breaks first: build time and memory as distinct queries grow, handled by the popularity threshold and by limiting prefixes to 10 characters; then regional differences, handled by building one table per locale.
What's expected at each level
Junior
gets a working design
- Uses a trie or a prefix index
- Debounces on the client
Mid-level
explains the trade-offs
- Precomputes top-k per prefix from logs, offline
- Caches short prefixes at the CDN
- Decays old popularity
Senior
drives it and goes deep
- Ships versioned snapshots with atomic switch and rollback
- Adds a streaming trending layer and merges it
- Filters offensive and privacy-revealing queries with thresholds
Seen in production
Elastic
DocsCompletion suggester
The completion suggester provides autocomplete and search-as-you-type. It uses data structures that allow fast lookups but are costly to build and are stored in memory, so it's optimised for speed; the completion index is entirely held in the heap. New suggestions become visible after a refresh, and the documentation recommends indexing completions into a single-shard index.
What we foundAutocomplete from in-memory structures built for prefix lookups: optimised for speed, costly to build.
Suggester examples: completion suggester (Elasticsearch documentation)elastic.co ↗Snip Bio's suggestions come from all-time search counts. Months after a one-day spike, a joke query is still the first suggestion for its prefix. What's the best fix?
Recap
Interview questions
Design search autocomplete for a large website.