Anurag's Notes

Design a Search Autocomplete System

1. The problem

Type "tw" into a search box → before you finish, a dropdown shows 5 completions. Suggestions must appear while you type → a latency budget of tens of milliseconds, hit on nearly every keystroke by every user.

Autocomplete is two systems: a slow one that decides what the answers are, and a fast one that just looks them up.

  • Query service (fast, online) — returns a precomputed answer for a prefix. Never computes top-k at request time.
  • Data gathering service (slow, offline) — counts what people search, rebuilds the precomputed answers periodically.

Links to earlier notes: the news feed precomputed each user's feed; this precomputes each prefix's answer. The feed needed a celebrity fast path; this needs a trending fast path (§10).


2. Requirements (extract these)

QuestionBook's answer
Matchingbeginning of query only ("tw" → "twitter", not "new tweets")
How many suggestions?5
Rankingpopularity = historical query frequency
Spell check / autocorrect?no
LanguageEnglish, lowercase alphabetic
Scale10M DAU

Non-functional: fast (≤ ~100 ms per keystroke), relevant, sorted, scalable, highly available.

Senior questions to ask unprompted — each changes the design:

QuestionWhy it matters
How fresh?breaking news in minutes needs a streaming path (§10); weekly is far simpler
Personalized or global?global = perfectly cacheable (CDN, browser); keep the shared index global, personalize on top
Filter harmful suggestions?always — autocomplete puts words in front of people they didn't type
Languages / countries?one index vs one per locale
Typo tolerance?fuzzy matching multiplies paths explored; book scopes it out

3. Estimation

  10M DAU × 10 searches × 20 chars (4 words × 5) → a request per character
  = 2×10⁹ requests/day ÷ 86,400 s           ≈ 24,000 QPS     peak ×2 ≈ 48,000 QPS
                                             (÷10⁵ shortcut → 20,000; same decision)
  new data: 10M × 10 × 20 B × 20% new        ≈ 0.4 GB/day

Every number ends with a decision:

  • 0.4 GB/day is tiny → storage isn't the problem; don't lead with sharding.
  • 24k–48k QPS in 100 ms → every request must be a memory lookup; the cheapest request is the one never sent.
  • 20 requests per search is mostly waste → the client is part of the design (§7).

4. Naive version — and breaking it

SELECT query FROM frequency_table
WHERE query LIKE 'tw%' ORDER BY frequency DESC LIMIT 5;
  • Scans and sorts on every keystroke — LIKE 't%' matches millions, sorted to keep 5, 48k times/sec.
  • Short prefixes are the most common AND the most expensive — everyone types the first letter; it matches the most rows.
  • An index on query finds matches but not the top 5 by frequency — still sorts the whole match set.

Fix = a prefix data structure + precompute the answer so nothing sorts at request time.


5. The trie and the optimization that matters

Trie = prefix tree: root is empty string, each child adds one character, a path spells a prefix; every query starting with "tw" lives under node "tw".

Naive top-k on a trie:

  1. walk to prefix node — O(p)
  2. traverse whole subtree to collect completions — O(c)
  3. sort, take top k — O(c log c)

Steps 2–3 are still the problem (subtree under "t" = millions).

Book's two optimizations:

OptimizationEffectCost
Cap prefix length (people rarely type 50+ chars)step 1: O(p) → O(1)none worth mentioning
Cache top-k at every nodesteps 2–3 disappear: find node, return its listspace — every node stores k queries; a popular query is duplicated in all its ancestors. Accepted: speed ≫ storage

Trie for true, try, toy, tree, win, wish with each node's top 3 cached

5.1 Senior insight: at serving time the trie collapses to a hash map

Once every node stores its answer, serving never walks an edge — hash the prefix straight to its list:

  "t"  → [true, try, toy]      "tr" → [true, try, tree]     "to" → [toy]
  "w"  → [win, wish]           "wi" → [win, wish]
  • Book's two storage options: serialized trie snapshot in a document store, or KV store: prefix → node data ("prefix hash table"). KV's con: loses the parent–child relationship between levels.
  • That relationship matters at build time: compute each node's top-k bottom-up by merging its children's lists — far cheaper than scanning all queries per prefix.

The trie is the build-time algorithm; prefix → top-k map is the serving format.

5.2 The "buy" option — FSTs

Elasticsearch completion suggester: in-memory finite-state transducer (compressed trie relative), per-suggestion weight for ranking, fuzzy support for typos; costly to build, held in memory, rebuilt periodically. Handles distribution, replication, failover. Mention it, then design the build-it version — the interviewer wants the mechanics.


6. High-level design

Offline: logs → aggregators → aggregated data → workers → trie DB → trie cache. Online: client → LB → API servers → trie cache

Data gathering (offline)

ComponentRole
Analytics logsappend-only (query, timestamp); not indexed. Sample (log 1 in N) at high volume — popular stays popular in a sample
Aggregatorsroll logs into counts per period. Cadence = the freshness decision: weekly (book default for general search) vs near-real-time for social/fresh products
Aggregated data(query, week_start, frequency) — the build input
Workersasync jobs: build the trie, compute top-k bottom-up, write to trie DB
Trie DBdurable snapshot (document or KV store) — what the cache reloads from
Trie cachein-memory copy that serves traffic; loads each new snapshot (weekly)

Query service (online)

Client → regional LB → stateless API server → in-memory lookup of the prefix → filters (§9) → 5 strings. Miss → read trie DB, fill cache (rare: the map usually fits in RAM, §8).


7. Making the query path fast — mostly on the client

TechniqueDetail
AJAXfetch suggestions in the background, no page reload
Browser cachingGoogle: Cache-Control: private, max-age=3600 — private = only this user's browser, not shared proxies; 1 hour. Retyping "tw" never leaves the machine
Debouncing (senior)send only after a ~100–150 ms pause; "twitter" typed fast = 1–2 requests, not 7
Minimum prefix length1-char prefixes give generic suggestions and waste server work; start at 2
Discard stale responsesresponses arrive out of order — tag requests (prefix / sequence no.), drop any response not matching current input, cancel in-flight on new keystroke → dropdown never jumps backward
CDN / edge cachebase suggestions are global → "how to" identical for all; Zipf traffic → caching the top ~10k prefixes covers ~60% of traffic
Regional serving100 ms budget vs ~150 ms cross-continent RTT → serve near users; server lookup itself is microseconds

8. Scaling the storage — and when not to

8.1 Do the memory math first (the most valuable senior move)

  keep ~10M distinct queries (drop the long tail), ~20 chars avg
  distinct prefixes  ≤ 10M × 20, heavily shared   → ~50M prefixes
  value              5 × ~25 B + overhead          ≈ ~150 B
  key                                              ≈ ~20 B
  total              50M × ~170 B                  ≈ ~8.5 GB → fits in one server's RAM

→ No sharding. Full copy on every serving node; replicas for throughput + availability. Read-only between rebuilds → every replica loads the same snapshot → trivially consistent; no routing, no shard map, no hot shards.

"At this scale the index fits in memory, so I replicate it fully. I shard only when it stops fitting — many languages, per-region indexes, or Google-scale vocabulary."

8.2 When it doesn't fit — book's progression

StepSchemeProblem it leaves
1shard by first character → 26 shardsneed more than 26
2first two (aa–ag, ah–an…), then threeletters wildly uneven ("c", "s" ≫ "x", "z")
3volume-based ranges from historical data, kept in a shard map manager (e.g. "s" alone; "u"–"z" together)—

API server asks the shard map; "s" routes to shard 3; ranges sized by volume

  • Range vs hash: ranges keep related prefixes together and split exactly where volume is; hashing a fixed-length prefix spreads load but can't split one hot range. Volume-aware map is the book's answer.
  • Rebuild the shard map with each snapshot from the same aggregated data.
  • Hot shard → more replicas, not more shards (read-only scales by copying); Redis/CDN response cache absorbs the hottest prefixes.

9. Trie operations: create, update, delete

Create — workers from aggregated data (§6).

Update

OptionDetail
Rebuild + replace weekly (default)build offline, load alongside the old, switch in one step (blue-green, atomic); keep the old version for rollback
Update nodes in placechanging one query's frequency can change top-k at every ancestor ("true" → "tru", "tr", "t", root) — the price of the top-k cache. Book: only when the trie is small

Delete — harmful suggestions must vanish now, not next week:

  • Filter layer between API servers and trie cache checks a blocklist on the way out → a newly blocked phrase disappears in seconds.
  • Physically removed from the index at the next rebuild.

Manipulation defense (raise unprompted) — bots searching a phrase a million times would promote it under naive counting:

  • count distinct users per query, not raw searches,
  • cap per-user / per-IP contribution to a count,
  • minimum distinct-user threshold before a query is eligible.

10.1 Time decay

Weight recent periods more: each period's count × a factor that shrinks with age → a two-year-old fad stops outranking this week. Changes aggregation only; serving untouched.

Bar: a viral query should appear within minutes. Can't rebuild the whole index every minute → a second, small, fast path (same idea as the news feed's celebrity merge).

Search events → stream processor → Count-Min Sketch → min-heap of K → acceleration check → trending overlay

  • Stream search events (Kafka) → stream processor (Flink / Spark Streaming) counts over short windows (15 min, 1 h).
  • Billions of unique strings → no exact per-query counts. **Count-Min Sketch
    • min-heap of size K**: sketch = approximate counts in fixed memory; heap = current top K. Sketch can overestimate, never underestimate — the safe direction for heavy hitters.
  • Detect acceleration, not volume: recent rate ≫ usual rate. "weather" is always popular, not trending.
  • Publish a tiny trending overlay: prefix → a few hot queries, refreshed ~every minute (only thousands trend at once).

10.3 Personalization

Per-user data inside the shared index destroys caching (every response unique). Personalize at the API layer, not in the trie. Better still: keep recent searches on the device and merge client-side → server response stays fully global and cacheable.

10.4 Request-time blending

Snapshot top-k, trending overlay and personal history merge, pass the safety filter, top 5 to client

  • Heavy part stays global + cacheable — snapshot + trending is the same for everyone in a region → CDN with a short TTL (~1 min) so trending stays fresh.
  • Personal part small + private (or on-device).
  • Filter runs last, over everything — trending is the source an attacker can push fastest; nothing bypasses the blocklist.
  • ⚠️ Cache headers must follow the blend. Google's private, max-age=3600 (§7) fits a personalized response. Here the server response is global (personal merge on device) → public, max-age=60: CDN can cache it, and trending is at most a minute stale. A 1 h browser cache would freeze the overlay — and delay a blocklisted phrase leaving the dropdown by up to an hour.

10.5 Other signals / extensions

Signal / featureNote
Suggestion click-throughskipped suggestions are bad suggestions
Result qualitydid the search after selecting it lead to a click?
Locationper-region indexes ("football": London vs Dallas)
Languageper-locale indexes; Unicode in trie nodes
Typosfuzzy = all prefixes within an edit distance → expensive; bound it (distance 1, after 4+ chars) or spell-correct the prefix first; FSTs support fuzzy natively

All change aggregation/build; serving stays lookup + merge.


11. Failures and consistency

Autocomplete is allowed to be slightly wrong — yesterday's list is fine, a missing suggestion is fine, the user can just type.

FailureResponse
Serving replica diesread-only, identical replicas → pull it from the LB
Weekly build failskeep serving the old snapshot; a week staler
Bad new snapshot (skew, poisoned counts)validate before swap (size, top-query sanity), instant rollback to previous
Streaming path behind / deaddrop the overlay; base suggestions still serve
Coordinated manipulationdistinct-user counting, contribution caps, live blocklist
Region downroute to next-nearest region; slower but working

Degrade, never fail. Fall back to the last good snapshot; if even that is gone, the search box still works without suggestions.


12. End-to-end trace (narrate, then break)

  user types "tw"
  client   debounce 120 ms → browser cache miss → GET /autocomplete?q=tw  (request #7)
  edge     CDN miss → regional LB → API server
  server   in-memory snapshot "tw" → [twitter, twitch, twilight, twin peaks, twitch prime]
           trending overlay "tw" → breaking-news query (entered 4 min ago)
           merge + rerank → trending takes slot 3 → safety filter passes all 5
           → Cache-Control: public, max-age=60 (global response; §10.4)
  client   response is #7 and input still "tw" → render; merge 2 on-device recent searches
  submit   search appended to analytics log → sampled → stream window counts
           → this week's aggregates → next week's snapshot

  BREAK 1  "tw" pause → "twitch"; "tw" response arrives after the "twitch" request
           → stale prefix → dropped; dropdown never flickers backward
  BREAK 2  bot campaign pushes a defamatory "tw…" phrase → huge volume, few distinct users
           → never eligible; even if it were, blocklist removes it from every response in seconds
  BREAK 3  weekly build crashes → nothing visible: old snapshot serves, trending keeps it fresh,
           rerun in the morning

13. Interview script

  1. clarify      prefix-only · 5 results · frequency ranking · English · 10M DAU · <100 ms
                  FRESHNESS? PERSONALIZED? HARMFUL-SUGGESTION FILTER? locales? typos?  ← unprompted
  2. estimate     24k QPS (peak 48k) · 0.4 GB/day → storage tiny, latency + request count are the problem
  3. framing      two systems: fast lookup path, slow build path — never top-k at request time
  4. naive        LIKE + ORDER BY → scans & sorts per keystroke, worst on short prefixes
  5. trie         → cap prefix length → cache top-k per node (space + duplication)
  6. collapse     trie = build algorithm; prefix → top-k map = serving format; FST = buy option ← senior
  7. high level   logs → aggregators → aggregated → workers → trie DB → trie cache; client → LB → API
  8. client       AJAX · browser cache max-age · debounce · min prefix · drop stale · CDN · regions
  9. storage      MEMORY MATH FIRST → replicate fully if it fits; else first-char → volume shard map ← senior
 10. operations   blue-green weekly swap + rollback · in-place update costs ancestors · live filter
 11. ranking      time decay · trending stream (CMS + heap, acceleration) · personalization off-index
                  · merge at request time · filter last                                    ← senior
 12. abuse        distinct users · contribution caps · eligibility threshold              ← unprompted
 13. failures     degrade, never fail
 14. walk it      "tw" end to end, then break it three ways

Steps 6, 9, 11 and 12 separate a pass from a strong pass.


14. The lines that earn the most

  1. "Autocomplete is two systems: a fast path that only looks things up and a slow path that decides the answers — never compute top-k at request time."
  2. "Short prefixes are both the most frequent and most expensive requests, which is why the answer has to be precomputed."
  3. "Caching top-k at every node trades space and ancestor-update cost for O(1) lookups — and then the trie collapses to a prefix → top-k map; the trie is the build algorithm."
  4. "The client is part of the design: debounce, browser cache with max-age, minimum prefix length, and drop out-of-order responses."
  5. "I do the memory math before sharding: ~8.5 GB fits in RAM, so I replicate fully; volume-based sharding only when it doesn't fit."
  6. "New snapshots swap in atomically with rollback; harmful suggestions are removed by a live filter, not the next rebuild."
  7. "Trending is the celebrity merge of autocomplete: a small streaming overlay — Count-Min Sketch plus a heap, detecting acceleration — merged at request time."
  8. "Personalization stays out of the shared index so the global response stays cacheable — ideally merged on the device."
  9. "Count distinct users, cap per-user contribution — otherwise a bot can write your suggestions."
  10. "Autocomplete is allowed to be slightly wrong: degrade, never fail."

15. Numbers to remember

ThingValue
Book scope10M DAU · 10 searches/user/day · 20 chars/query · 5 suggestions
Requests per search~20 (one per character) before client optimizations
QPS~24,000 avg, ~48,000 peak
New data~0.4 GB/day (20% new queries)
Latency target≤ ~100 ms per keystroke
Browser cache (Google)Cache-Control: private, max-age=3600; with a trending overlay: public, max-age=60
Debounce~100–150 ms
Min prefix~2 characters
In-memory index estimate~50M prefixes × ~170 B ≈ ~8.5 GB
Hot-prefix cachetop ~10k prefixes ≈ ~60% of traffic (Zipf)
Rebuild cadenceweekly (book); trending overlay ~every minute
Trending latency targetminutes, not hours
Cross-continent RTT~150 ms → serve regionally

16. Common follow-ups

Follow-upAnswer skeleton
Why not just a database?LIKE + ORDER BY scans and sorts per keystroke; worst on short, most common prefixes
Trie memory too big?drop long tail; top-k per node only; KV prefix map or FST; shard only if it won't fit
How do you update?weekly blue-green rebuild + rollback; in-place updates touch every ancestor
Remove an offensive suggestion nowlive blocklist filter in front of the cache; purge at rebuild
Breaking newsstreaming overlay: windowed counts, CMS + heap, acceleration, merged at request time
PersonalizationAPI/client layer, not the shared index; on-device history
Typosbounded fuzzy matching or spell-correct the prefix; FST fuzzy
Multiple languages / countriesper-locale indexes, Unicode nodes, regional serving + CDN
Hot prefixesCDN/edge + Redis response cache; more replicas for hot shards
Bots gaming suggestionsdistinct-user counts, contribution caps, eligibility threshold
Uneven shardsvolume-based ranges in a shard map, rebuilt with each snapshot
Out-of-order responsestag requests, drop stale, cancel in-flight
Mid-string matching ("tweets" for "new tw")different problem: token/n-gram indexes — say so and scope it out

17. When NOT to go deep

Inside a bigger question ("design Google Search" / "design a marketplace"):

"Typeahead is served from an in-memory prefix → top-k map rebuilt offline from aggregated query logs, with a streaming trending overlay merged at request time, a live blocklist filter, and client debounce plus browser caching."

Go deep only when autocomplete IS the question, or the interviewer pushes on freshness, ranking or scale.


18. Glossary (A–Z, one line each)

TermMeaning
accelerationrecent query rate far above its usual rate — the trending signal
aggregatorsjobs rolling raw logs into per-period query counts
analytics logsappend-only (query, time) records of completed searches
blocklist / filter layerlive check removing harmful suggestions before they're returned
blue-green swapload the new snapshot beside the old, switch atomically, keep old for rollback
bottom-up top-kbuilding each node's top-k by merging its children's lists
browser cache (max-age)client-side caching of suggestions per prefix (Google: 1 h, private)
CDN / edge cachecaching global prefix responses near users
completion suggesterElasticsearch's FST-based prefix suggester with weights and fuzzy
Count-Min Sketchfixed-memory approximate counter; overestimates, never underestimates
data gathering serviceoffline pipeline that builds the suggestion index
data samplinglogging 1 in N searches to cut cost
debouncingwaiting for a typing pause before sending a request
distinct-user countingcounting unique users per query to resist manipulation
FSTfinite-state transducer — compressed trie-like automaton held in memory
fuzzy matchingmatching within an edit distance to tolerate typos
heavy hittersthe most frequent items in a stream
min-heap of Kkeeps the current top K by estimated count
personalization layerper-user blending outside the shared index (API or device)
prefix → top-k mapthe serving format: hash of prefix to its precomputed list
query serviceonline path: prefix in, five suggestions out
shard map managerlookup service mapping volume-sized prefix ranges to shards
stale-response discardclient drops responses that don't match the current input
time decayweighting recent counts more than old ones
top-k cache per nodeeach trie node stores its k best completions
trending overlaysmall, minute-fresh prefix → hot-query map merged at request time
trieprefix tree; one character per edge
trie cache / trie DBin-memory serving copy / durable snapshot store
Zipf distributionfew prefixes take most traffic — why caching works

On this page