SYSTEM DESIGN · DESIGN PROBLEMS · SEARCH AND DISCOVERY · EASY · 23 OF 41

Design Search Autocomplete: Tries, Top-K Caching and Fast Rebuilds

Design the typeahead box that suggests the top five queries as you type. Requirements and scale (over a hundred thousand requests a second at peak), the suggest API, a trie with top-K cached at every node, an offline pipeline that rebuilds it from query logs, a small real-time layer for trending terms, replication versus sharding, client-side tricks and the trade-offs.

Reported atGoogleMetaLinkedInOracleAdobe

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 18

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Start by fixing scope. Given a prefix, return the five most popular completions. New trending queries should appear within about an hour. The whole round trip must feel instant, so under a hundred milliseconds end to end, which leaves the server maybe twenty. Suggestions being a few minutes stale is completely fine. Spelling correction and personal history are out of scope for now; we will mention them as extensions.

Requirements
  • Functional
  • prefix in, top 5 queries out
  • ranked by popularity
  • new trends show within an hour
  • Non-functional
  • under 100 ms end to end
  • highly available, read-heavy
  • stale by minutes is fine
  • Out: spelling fix, personal

Ask scale

Now the numbers. A hundred million daily users doing ten searches each is a billion searches a day. Each search fires about six suggest calls after debouncing, so six billion requests a day, sixty thousand per second on average and close to two hundred thousand at peak. The key insight is size: the top fifty million queries are only a gigabyte of text, and the full trie fits in tens of gigabytes. It fits in memory.

Back of the envelope
100M daily users x 10 searches
  = 1B searches/day
about 6 requests per search (debounced)
  = 6B requests/day
6B / 100,000 s = 60,000 rps average
peak x3 = about 180,000 rps

log writes: 1B queries/day = 10,000/s
keep top 50M queries x 20 chars = 1 GB
trie with top-5 per node: 10-30 GB

Define API

The API is tiny. One GET with the prefix, a limit and a language, returning a list of strings. Add a Cache-Control header so browsers and the CDN can reuse answers for a few minutes. Notice what is not here: we do not log anything on this path. The real search service already records every submitted query, and that is our data source. An admin endpoint lets a trust and safety team block terms.

API
GET /v1/suggest?q=app&limit=5&lang=en
200 { "q": "app",
      "items": ["apple", "app store",
        "apple watch", "apps", "app ideas"]}
Cache-Control: max-age=300

search events are logged by the
search service, not this API

POST /v1/admin/blocklist { "term": ... }

Data model

There are two kinds of data. The serving data is a read-only map from prefix to its top five terms with scores, held in memory. The source of truth is an analytics table of query counts per hour, in a column store or data lake, because we only ever scan and aggregate it. Neither needs a relational database or transactions. The link between them is a versioned snapshot file that an offline job builds.

Two kinds of data
serving (in memory, read-only):
  prefix -> [(term, score) x 5]
  "app" -> apple 9.1, app store 8.7 ...

source of truth (column store):
  query_counts(term, hour, count)
  partitioned by hour

snapshot file: trie_v2026_10_24_14
  built offline, loaded atomically

High-level design

First, the naive version, because interviewers like to hear why it fails. Store queries with counts in a table and run a prefix match ordered by count. An index finds the matching range, but for a short prefix like a it still sorts millions of rows, and we need almost two hundred thousand of these a second. The answer to any prefix barely changes within an hour, so compute it ahead of time and just look it up.

Here is the read path. The browser asks for the prefix. Very short prefixes like a or ap are identical for everyone, so the CDN answers most of them. On a miss, a stateless suggest service asks a trie server, which walks three characters down an in-memory tree and returns a precomputed list. That lookup takes microseconds; the network is the slow part.

The write path is completely separate. Submitted queries land in a Kafka topic. An aggregator rolls them into hourly counts and applies a time decay so last week matters less than today. A builder job turns the scored terms into a new trie, writes a versioned snapshot, and the trie servers load it and swap it in atomically. Reads never wait on writes.

The naive version
SELECT term FROM queries
  WHERE term LIKE 'app%'
  ORDER BY count DESC LIMIT 5

index helps find the range, but
  "a%" still sorts millions of rows
180,000 of these a second: no

fix: precompute the answer
  for every prefix, ahead of time

Deep dive: the trie

The core data structure is a trie, a tree with one character per edge. The naive version walks to the prefix node and then searches its whole subtree for the best terms, which for the letter a means millions of nodes. The fix is to store the top five directly at every node. Now a lookup is just a walk of a few characters. You pay in memory and in rebuild work, which is fine because we rebuild offline.

A simpler variant skips the tree entirely: precompute every prefix up to about twenty characters and store prefix to top five in a key-value store like Redis. It uses more memory because shared prefixes are not shared, but it is trivial to shard and any team can run it. Cap the prefix length, because almost nobody types twenty characters before picking a suggestion. Either answer is fine if you say why.

Trie with cached top-K
naive: walk to "ap", then search the
  whole subtree for the best 5
  = millions of nodes for "a"

better: every node stores its top 5
  root -> a -> p  [apple, app store..]
  lookup = O(prefix length)

cost: more memory, and top lists
  must be rebuilt when scores change
Alternative: prefix table
precompute every prefix up to 20 chars
  "a" "ap" "app" "appl" "apple"
store in a key-value store (Redis)
  GET sugg:en:app -> list of 5

+ simple, any KV store works
+ easy to shard by key
- more keys: no shared prefixes

cap length: long prefixes are rare

Deep dive: freshness

Popularity is a decayed count: each hour's count is weighted down by its age, here with a one-day half-life, so yesterday's trend fades naturally. Rare terms below a threshold are dropped, which also protects privacy. Every hour the builder makes a fresh trie from scratch. Each server loads the new version next to the old one, flips a pointer, then frees the old one. No locks, no partial states.

Hourly is not enough when news breaks. Add a small streaming job that counts the last fifteen minutes and flags only terms spiking far above their normal level. Their prefixes get a short hot list in Redis. The suggest service merges the trie's top five with that hot list and re-ranks. This is a lambda style split: a big accurate batch layer and a tiny fast layer.

Scoring and rebuild
score = sum over hours of
  count(h) x 0.5 ^ (age_h / 24)
  (half-life of one day)

drop terms seen under N times
filter blocklist at build time

every hour: build v43 from scratch
servers load v43 next to v42,
flip a pointer, free v42
Trending in minutes
problem: news breaks at 14:05,
  next build is at 15:00

stream job counts last 15 minutes
  only for terms spiking vs baseline
small hot list per prefix in Redis

suggest service merges:
  trie top 5 + hot list, re-rank

Deep dive: scaling

Should you shard? First check whether it fits. A twenty gigabyte trie fits on one machine, so the easiest move is replication: every server holds the full trie and the load balancer spreads requests. If it outgrows one machine, shard by prefix range, but not by first letter, since s alone is far bigger than x. Split ranges by measured traffic and keep the map in config. Separate languages into separate tries.

Replicate or shard?
trie 20 GB fits on one box
180,000 rps / 20,000 per box
  = 9 boxes, run 15 across zones
  each holds the full trie

if too big: shard by prefix range
  not by first letter: "s" is huge
  split by measured traffic
  a-ap | aq-b | ... kept in config
per language: one trie each

Deep dive: client

Half the scaling happens in the browser. Debounce, so a fast typist sends one request instead of five. Cancel the old request when a new key arrives. Cache every prefix result for the session, so backspacing costs nothing. The CDN can hold one to three character prefixes for everyone. And the user's own recent searches can come from local storage, which adds personalization without any server state.

Client-side tricks
  • debounce: wait 100-150 ms
  • cancel the in-flight request
  • when a new key arrives
  • cache results per prefix
  • in the page for the session
  • filter locally: if "app" gave
  • 5 results, "appl" may too
  • CDN caches 1-3 char prefixes
  • show your own recent searches
  • from local storage

Trade-offs

Failure modes are friendly here because reads and builds are decoupled. A trie server dies, and the load balancer routes around it. The build job fails, and servers keep the last good snapshot while an alert fires on snapshot age. A bad build is the scary one, like an empty trie or offensive terms, so validate size, sample queries and blocklist hits before publishing, and keep one-click rollback.

Monitor p ninety-nine latency, CDN hit rate, snapshot age and build duration, plus the business metric: how often people click a suggestion. At ten times the traffic you just add replicas and lean harder on the CDN. Ten times the vocabulary is what forces range sharding or pruning low-score tails. Typo tolerance belongs in a separate fuzzy service using edit distance or n-grams.

Failure modes
  • trie server dies: LB drops it,
  • replicas absorb the load
  • build job fails: serve last
  • good version, page on age
  • bad build (empty, offensive):
  • validate before publish,
  • instant rollback to v-1
  • Kafka lag: freshness slips,
  • reads are unaffected
Monitor and grow
  • watch: p99 latency, CDN hit
  • rate, snapshot age, build
  • time, suggestion click rate
  • 10x traffic: more replicas,
  • bigger CDN share; trivial
  • 10x terms: shard by range,
  • or prune low-score tails
  • fuzzy match: separate service
  • (edit distance, n-grams)

Interview summary

In the interview, say it in this order. Top five suggestions per prefix in under a hundred milliseconds. Around two hundred thousand requests a second at peak, but the data fits in memory. A cacheable GET endpoint. A trie with top five stored at every node. An hourly offline rebuild from query logs with decayed counts and an atomic swap. A streaming hot list for breaking trends. Replicate before you shard. And debounce on the client.

Say it like this
  • top 5 per prefix, under 100 ms
  • 180k rps peak, data fits in RAM
  • GET /suggest?q=, CDN-cacheable
  • trie with top-K at every node
  • hourly offline rebuild from logs
  • decayed counts, atomic swap
  • streaming hot list for trends
  • replicate first, shard by range
  • debounce + client cache

Interview questions

Why store the top K results at every trie node?

Without it, each lookup must search the entire subtree under the prefix, which can be millions of nodes for one or two characters. Caching the top K at each node makes a lookup proportional only to the prefix length, at the cost of extra memory and offline rebuild work.

How do you keep suggestions fresh without slowing down reads?

Rebuild the trie offline from aggregated query logs on a schedule, such as hourly, and load the new snapshot alongside the old one before atomically switching. For breaking trends, a small streaming job maintains a short hot list per prefix that the service merges at query time.

How would you rank suggestions?

Start with a time-decayed popularity score, such as hourly counts weighted by a half-life, so recent interest outranks old interest. You can later blend in click-through rate, freshness, locale and personal history.

Would you shard the trie? How?

Only if it does not fit in memory on one machine; otherwise replicate the full trie on every server for simplicity. If sharding is needed, split by prefix ranges chosen from measured traffic rather than by first letter, because some letters are far more common than others.

What can the client do to reduce load?

Debounce keystrokes by around 100 to 150 milliseconds, cancel in-flight requests when a newer prefix is typed, cache results per prefix in the page, and rely on HTTP caching so the CDN can answer common short prefixes.

How do you prevent offensive or harmful suggestions?

Filter terms against a blocklist and minimum-count threshold when building the trie, validate each new snapshot before publishing it, and keep a small runtime blocklist that the serving layer applies instantly for urgent removals.

Trie versus a prefix hash table in a key-value store?

A trie shares common prefixes and is memory efficient, but needs custom serving code. A flat prefix-to-list table in a store like Redis is simpler to build and shard, at the cost of more keys and memory.

What happens if the hourly build fails?

The serving layer keeps using the last good snapshot, so users see slightly stale suggestions but nothing breaks. An alert on snapshot age tells the team to fix the pipeline.