system-design

Design Typeahead / Autocomplete (Search Suggestions)

Difficulty: Tier 2 Asked at: Google, Meta, Amazon, Careem Time budget: 45 min

Every search box that suggests as you type is a typeahead system. The challenge is latency — a suggestion must appear within milliseconds of each keystroke, at enormous query volume — which forces a precomputed, in-memory prefix structure (a trie) rather than querying a database per keystroke. The signature data structure is the trie, and the signature tension is precompute-vs-freshness.

Prerequisites: Caching, Search Systems, Batch vs Stream


1. Requirements

Functional:

Non-functional:

Out of scope: the full search backend (separate), spell correction (mention).


2. Estimation


3. The core data structure: the trie

🚨 A trie (prefix tree) stores strings by shared prefix — each path from the root spells a prefix, and each node can hold the top-K completions for that prefix, precomputed.

        (root)
        /    \
       c      t
       |      |
       a      e
      /|      |
     r t      a  ...
   "car"   "tea"

Memory: billions of queries make a naive trie huge; compress it (radix tree), store only popular prefixes, and shard by prefix.


4. High-level design

flowchart TB
    User -->|keystroke: prefix| API[Suggestion Service]
    API --> Cache[(In-memory trie<br/>top-K per node, sharded)]
    Cache -->|top-K completions| User

    Logs[(Search query logs)] --> Batch[Batch aggregation<br/>count frequencies]
    Batch --> Build[Build/update trie]
    Build --> Cache
    Stream[Trending stream] -.fast path.-> Cache

Serve path (hot): keystroke → suggestion service → navigate the in-memory trie → return top-K. Pure memory read, milliseconds.

Build path (cold, offline): aggregate search logs to count query frequencies (a batch job), rebuild the trie with fresh top-K rankings, and deploy it to the serving tier. 🚨 Separate the fast read path from the slow build path — the trie is built offline and served online.


5. Deep dives

5a. Why precompute top-K per node?

If you stored only frequencies and ranked at query time, each keystroke would gather all completions under a prefix and sort them — too slow at this QPS. Instead, precompute and store the top-K (e.g. top 10) directly at each node during the offline build. Query time becomes “walk to the node, return its list” — O(prefix length), no sorting. The cost is a heavier build and more storage, paid offline where it’s cheap.

5b. Building & updating the trie (freshness)

Suggestions come from aggregating real search frequencies. Do this in a batch pipeline: collect query logs, count/weight by recency, compute top-K per prefix, build the trie, and swap it into the serving tier periodically (e.g. hourly/daily). 🚨 The trade-off: precomputed = fast but slightly stale. For trending terms (breaking news), add a faster streaming path that nudges hot prefixes in near-real-time. Most of the trie updates slowly; a small trending overlay stays fresh. (Batch vs Stream)

5c. Scaling the serving tier

Replicate the trie across many servers (it’s read-only at serve time) → scale QPS horizontally, each replica serving from memory. Shard by first characters if the trie is too big for one machine (prefixes starting “a…” on one shard, etc.), routing by prefix. A CDN/edge cache can even serve very common prefixes.

5d. Ranking & personalization

Base ranking = query frequency (+ recency weighting so old-but-once-popular terms fade). Personalization (your history, location) and context can re-rank the precomputed candidates at query time cheaply — take the node’s top-K candidates and lightly reorder, rather than ranking from scratch.

5e. Handling typos & partial matches

Real systems tolerate typos (fuzzy matching) via edit-distance-tolerant structures or a secondary correction step. Mention it as an extension; the core trie handles exact prefixes.


6. Bottlenecks & scaling further

  1. Query latency → in-memory trie with precomputed top-K per node.
  2. QPS → replicate the read-only trie across many servers; edge-cache common prefixes.
  3. Trie size → radix compression, prune rare queries, shard by prefix.
  4. Freshness → periodic batch rebuild + a streaming overlay for trending.
  5. Build cost → offline batch pipeline, decoupled from serving.

7. Trade-off summary

Decision Chosen Alternative Why
Data structure Trie with precomputed top-K DB LIKE query per keystroke O(prefix) memory read hits the latency budget
Ranking Precompute offline Rank at query time Query-time sorting too slow at this QPS
Freshness Batch rebuild + trending overlay Real-time updates everywhere Most terms change slowly; overlay covers trends
Serving Replicated read-only in-memory Shared database Scales QPS; memory-speed reads

8. Follow-up questions

Why a trie instead of a database query per keystroke? Because the latency and QPS budgets make a per-keystroke database query impossible. Typeahead fires on every character of every searching user, so it sees more requests than search itself — hundreds of thousands to millions per second — and each must respond in tens of milliseconds so suggestions feel instant. A database LIKE 'prefix%' query, even indexed, involves network round trips and query planning and can't reliably hit that latency at that volume, and ranking the matches would add sorting cost. A trie keeps all prefixes in memory as a tree where walking to a prefix is O(prefix length) and the top-K completions are precomputed and stored right at each node, so a lookup is a short in-memory walk returning a ready-made list — no query, no sort, no disk. That's what meets the budget.
How do suggestions stay fresh if the trie is precomputed offline? Through a two-speed design. The bulk of the trie is rebuilt periodically (say hourly or daily) by a batch pipeline that aggregates recent search logs, weights queries by frequency and recency, recomputes the top-K per prefix, and swaps the fresh trie into the serving tier — which is fine because most query popularity changes slowly. For genuinely trending terms (a breaking-news query spiking in minutes), a faster streaming path detects the surge and nudges the affected prefixes' suggestions in near-real-time as a small overlay on top of the batch-built trie. So you get the speed and simplicity of precomputation for the vast majority of prefixes, plus a lightweight fast path for the small set of things that are changing right now — accepting mild staleness everywhere else as a good trade for the latency and simplicity gains.
How do you scale to millions of QPS? Exploit that the serving trie is read-only. Once built, it can be replicated across a large fleet of servers, each holding the trie in memory and serving lookups independently, so QPS scales horizontally just by adding replicas. If the trie is too large for one machine, shard it by prefix (e.g. by first character or two) and route each request to the shard owning its prefix. Extremely common short prefixes can even be cached at the edge/CDN. Because every lookup is an in-memory read with no shared mutable state during serving, there's no contention bottleneck — the design is embarrassingly parallel on the read side, which is exactly what the huge QPS demands.
How would you personalize suggestions without blowing the latency budget? Keep personalization as a cheap re-rank of the already-precomputed candidates rather than a from-scratch computation. The trie node gives you the top-K globally-popular completions for the prefix in one fast read; then you lightly reorder or blend that small candidate list using per-user signals (search history, location, current context), which is a quick operation over ~10 items, not a ranking over the whole corpus. You can precompute per-user or per-segment signals offline so the query-time step is just a small weighted reshuffle. This preserves the millisecond budget because the expensive part (finding and globally ranking candidates) stays precomputed, and only a tiny, bounded personalization pass happens per request.

9. What junior / mid / senior answers look like


Further reading