Design Autocomplete Interview Questions | JiQuest

add

#

Design Autocomplete

System design deep dive · HLD

Design a search autocomplete / typeahead system: full high-level design.

Requirements, back-of-envelope capacity estimation, a high-level design diagram, a deeper deployment architecture diagram, sequence diagrams for the online lookup and offline rebuild paths, and an entity-relationship diagram for the supporting data model - with the reasoning an interviewer expects behind every box and arrow.

500MSearches / day
~70kPeak autocomplete QPS
<50msp99 latency budget
Clienttypes "lap" (debounced) Autocomplete svcroutes to shard Trending boostlast few minutes Top-K dropdownranked completions Trie shardin-memory, prefix "la" rendered on keystroke

1. Clarify requirements before drawing any box

Autocomplete looks like a small feature bolted onto search, but it has its own request pattern (every keystroke, not every submitted query) and its own freshness contract (near-real-time is fine; the base ranking can lag by hours). Pinning both down changes every diagram below.

Functional requirements

Prefix completionGiven a partial query prefix, return the top-K ranked suggestions that complete it.
Popularity rankingSuggestions are ordered by historical search frequency, not alphabetically.
Trending boostQueries spiking in the last few minutes should surface even if they aren't yet historically popular.
Fresh vocabularyNew queries that didn't exist yesterday must eventually enter the suggestion set.

Non-functional requirements

Extreme low latencyFires on every keystroke; target <50ms p99, ideally <20ms, or typing feels laggy.
Very high read QPSDebounced keystrokes still multiply request volume well above the underlying search QPS.
Relaxed freshnessBase rankings can be minutes-to-hours stale; only the trending layer needs near-real-time.
High availabilityA failed lookup should degrade to "no suggestions," never slow down or break the search box.
Explicitly out of scope Spelling correction ("did you mean"), multi-language stemming/tokenization, and full per-user personalized history replay are called out as extensions in the deep-dive section rather than core requirements, so the core design stays focused on prefix ranking.

2. Back-of-the-envelope capacity estimation

These numbers decide whether a database can even be on the hot path (it can't), how many trie shards are needed, and how much memory the whole serving fleet needs. Scope: a large e-commerce/search product doing roughly 500M full searches/day - not the ~8-9B/day scale of a global web search engine.

MetricAssumptionResulting estimate
Search volume500M searches/day500M ÷ 86,400s ≈ 5,800 QPS average, ≈17,400 QPS peak (3× diurnal)
Autocomplete request volumeFires per keystroke after a ~150ms debounce; ≈4× search QPS net of debounce savings≈23,000 QPS average, ≈70,000 QPS peak
Query log volume (90-day rolling window)500M/day × 90 days, ≈120 bytes/row (text + user_id + timestamp + flag)45 billion rows ≈ 5.4 TB raw log storage
Distinct historical query stringsHeavy repetition, ≈1 distinct string per 150 searches45B ÷ 150 ≈ 300 million distinct queries; top 20M cover ≈95% of autocomplete traffic
In-memory trie sizeTop 20M queries, compressed/radix trie (shared prefixes collapse the tree ≈4-5×) ≈80M nodes, ≈180 bytes/node (char + child pointers + cached top-10 completions)80M × 180B ≈ 14.4 GB total trie
Shard countShard by first 2 characters of the prefix, consolidated into 32 practical shards (≈450MB each), replicated 3× per region × 2 regions32 × 3 × 2 = 192 shard-replica instances, each a small fraction of one host's RAM
Why this matters Autocomplete QPS is roughly 4× the underlying search QPS even after debouncing, and the latency budget is 2-5× tighter than a normal search request. Together those two facts rule out any database on the hot path - the entire design below exists to keep every lookup inside an in-memory, horizontally replicated data structure.

3. High-level design (HLD)

The HLD names the major components and the one-directional data flow between them - the online read path that serves keystrokes, and the offline pipeline that keeps the trie's rankings fresh - without committing yet to replica counts or regions, which belongs in the architecture diagram next.

Clientdebounced keys API gatewayL7, TLS terminate Autocomplete svcread path, merges boost Trending counterstreaming, minutes window Trie shards (in-memory)sharded by prefix, replicated Search serviceupstream, external Query log ingestionasync, every completed search Offline batch aggregationdaily/hourly Spark job Trie builder / publisherrebuilds & ships new shards
Stateless servicesFast-path infraDurable / batchAsync / upstream

What each box owns

Autocomplete service (read path)

Accepts the raw prefix, hashes/routes it to the trie shard responsible for that prefix range, reads the precomputed top-K list already sitting at that trie node, asks the trending counter for a boost score, merges the two into a final ranked list, and returns it. It never touches a database and never blocks on the offline pipeline.

In-memory trie shards

Each shard owns a contiguous prefix range (e.g. "a-c") and holds a trie where every node already caches its own top-K completions, computed at build time. Shards are replicated across nodes and regions purely for read fan-out and availability - there is no write traffic to them at request time.

Query log ingestion & trending counter

Every completed search from the search service is written asynchronously into the query log - this never blocks the user-facing search response. A streaming job continuously aggregates the last few minutes of that log into a small "trending terms" table, catching spikes (breaking news, a flash sale) long before the next batch rebuild would notice them.

Offline batch aggregation & trie builder/publisher

A scheduled job (daily, or hourly for a fresher base ranking) re-aggregates the full query log window, recomputes frequency counts, ranks and truncates completions per node, and hands the result to a builder that constructs new trie shards and publishes them to serving nodes without ever taking the read path offline.

4. Detailed architecture diagram

The architecture diagram splits the system along its most important seam: an online serving path that only ever reads an immutable, replicated, in-memory trie, and an offline build pipeline that produces the next version of that trie and swaps it in - plus a real-time trending path merged in at read time for freshness the batch pipeline can't provide fast enough.

Edge layer GeoDNS / Anycast Client-side debounce~150ms, coalesces keystrokes API gateway + L7 LB Per-client rate limitercaps abusive keystroke floods Region: us-east-1 Autocomplete svc ×16 pods Trending cache ×3 Trie shards, a-z split32 shards × 3 replicas≈450MB per shardread-only, memory-mapped Region: eu-west-1 (active-active) Autocomplete svc ×8 pods Trending cache ×2 Trie shards, a-z split32 shards × 2 replicassame version as us-east-1 no cross-region writes needed Query log store Append-only log / stream 90-day rolling window, ≈5.4TB Offline batch pipeline Spark job Trie builder publishes versioned shards to blob store Blue-green shard swap Node pulls vN+1 into slot B atomic pointer swap, slot A drains
DecisionChoiceReasoning
Rebuild strategyOffline batch rebuild + a merged real-time trending overlay, not online incremental trie updatesRecomputing global ranks incrementally on every write is complex and error-prone at 23k+ QPS; batch keeps ranking logic simple, and the trending layer covers the freshness gap batch can't.
Sharding schemePrefix-range sharding (first N characters), not consistent hashing of the full queryPrefix sharding keeps every completion for a given prefix on one shard, so a lookup never fans out across the cluster; consistent hashing would scatter completions for "lap" across many nodes and defeat per-node top-K caching.
Data structureIn-memory trie with cached top-K per node, not a sorted string table or ternary search treeA trie gives O(prefix length) descent to the answer with zero extra ranking work at request time; a TST saves some memory but adds comparison overhead per character, and a sorted list needs a binary search plus a scan to collect the top-K on every request.
Multi-region replicationRead-only replicas per region, no cross-region write pathBecause the trie is immutable between versions, "replication" is just shipping the same published artifact to every region - none of the write-conflict problems a live multi-region database would have.

5. Sequence diagrams for the two critical flows

A sequence diagram is where an interviewer checks whether you understand exactly which hop is on the synchronous, latency-critical path and which hop is asynchronous - and here, whether you understand that the trending merge is a second, parallel lookup rather than a blocking chain.

5.1 Online lookup (keystroke to dropdown)

Client Autocomplete svc Trie shard Trending counter 1. debounce (150ms) fires, GET /suggest?q=lap 2. hash("la") → route to shard 3. GET topK(node "lap") 4. return precomputed top-10, sorted 5. GET boost(candidates) 6. recent-window scores 7. merge(popularity, trending) → top-8 8. 200 OK {suggestions}

Step 3 is a single hop because the trie shard already stored the top-K completions at that exact node when it was built - there is no subtree traversal at request time. Steps 5-6 run in parallel with (or shortly after) step 3 against a tiny, separately-hosted trending cache, not the trie itself, so a slow trending lookup never blocks the base result; a request that times out on step 6 simply merges with a zero boost.

5.2 Offline pipeline (log to published trie)

Query logs Batch job Trie builder Blob store Serving node 1. read 90-day window (02:00 UTC trigger) 2. GROUP BY query_text, SUM(count) 3. rank & truncate to top-K per node 4. ranked frequency table 5. build vN+1 trie shards (per prefix range) 6. upload shards + manifest 7. poll: new manifest found 8. pull shard into slot B (background) 9. atomic swap, slot A drains and frees

Step 3 is the step that actually keeps the trie fast: ranking and truncation happen once, offline, per node - not once per request. Steps 7-9 are drawn as a poll-and-pull rather than a push specifically so a slow or unreachable serving node can catch up on its own schedule without the publisher needing to track per-node delivery state; the blue-green swap in step 9 guarantees no in-flight lookup ever reads a half-loaded trie.

6. Entity-relationship diagram and schema

The hot path never touches a schema at all - it reads an in-memory trie. But three supporting tables exist around that trie: the raw log the rankings are computed from, a small hot table for trending spikes, and a control-plane table that tracks which trie version is published where.

query_logs PK log_id BIGINT raw_query_text TEXT user_id BIGINT NULL searched_at TIMESTAMP result_clicked BOOLEAN append-only, 90-day TTL trending_terms PK term_id BIGINT UQ query_text VARCHAR recent_count BIGINT window_start TIMESTAMP computed_at TIMESTAMP small, minutes-window only trie_shards PK shard_id VARCHAR prefix_range VARCHAR version BIGINT built_at TIMESTAMP storage_ref VARCHAR N1 N1 many log rows aggregate (streaming job) into one trending_terms row many query strings are ranked into (batch job) one trie shard's prefix range

Key modeling decisions

query_logs is append-only, not permanentOnly the recent window feeds ranking, so a rolling 90-day TTL is enforced instead of storing every keystroke-driven search forever.
trending_terms is deliberately tinyOnly spiking terms get written here at all, so it stays a few hundred thousand rows and comfortably serves point lookups by query_text at low latency.
trie_shards stores metadata, not the trieThe actual trie lives in-memory on serving nodes; this table is control-plane only, letting a node know its version is stale and where to pull the new artifact.
No per-user table on the hot pathPersonalization is layered on as a thin re-ranking step at read time (see the deep dive below), not as a per-user trie or per-user row here.
Storage choiceUse whenWatch out for
Columnar/log store (Kafka + data lake, or a wide-column store) for query_logsIngest volume is enormous (23k+ writes/sec) and the only access pattern is append plus full-window batch scan.Terrible for point lookups; never query this table on the request path, only from the batch job.
Relational store (Postgres) for trie_shards metadataYou need simple, consistent queries by shard_id or prefix_range and an audit trail of published versions.This table is tiny and never on the hot path - don't over-engineer it, it exists purely for the control plane.

7. Deep dives interviewers actually probe

How does a single trie lookup return ranked suggestions without scanning a subtree?

The naive approach - descend to the prefix node, then walk the entire subtree beneath it collecting and sorting every completion - is far too slow for a <50ms budget on a popular one- or two-character prefix with millions of descendants. Instead, the top-K completions are precomputed and cached directly on every trie node at build time, so a request only has to descend to the node (O(prefix length)) and return the list that's already sitting there.

// Build time: propagate top-K up from leaves, cheapest first
struct TrieNode {
    children: Map<char, TrieNode>,
    topK: Vec<(String, u64)>  // precomputed, sorted by score desc
}
// merge child topK lists into the parent, keep only the best K
fn buildTopK(node) {
    for child in node.children.values() { buildTopK(child) }
    node.topK = mergeAndTruncate(node.children.values().map(c => c.topK), K)
}
// Request time: O(prefix length), no traversal, no sorting
fn lookup(prefix) -> Vec<(String,u64)> { descend(prefix).topK }

Offline batch rebuild vs online incremental updates - why not update the trie live?

Updating the trie live on every search would mean recomputing per-node top-K lists on the write path, which is exactly the kind of global, order-dependent aggregation that's hard to do correctly and cheaply at 23k+ QPS - a single popular query jumping in rank can touch every ancestor node up to the root. Batch rebuild sidesteps this entirely: ranking is recomputed from scratch on a schedule, which is simple, easy to test, and easy to roll back (just don't publish the new version). The cost is that a brand-new trending query can't appear until the next batch run - which is exactly why the trending counter exists as a separate, much cheaper real-time overlay merged in only at read time, rather than trying to make the trie itself real-time.

How would you add personalization without blowing up the index?

A per-user trie is a non-starter - it would multiply the 14.4GB base index by however many active users exist. Instead, personalization is layered on as a thin re-ranking step that runs after the global top-K comes back: a small client-held or session-scoped list of the user's own recent queries (tens of entries, not millions) is used to boost or re-order matching completions, or to prepend an exact recent-query match ahead of the global ranking. This keeps the expensive, shared part of the system (the trie) completely user-agnostic while still giving each user a noticeably personalized-feeling result.

How do you keep offensive or embarrassing suggestions out of the dropdown?

Filtering happens in the offline pipeline, before anything is ever published: the batch job runs candidate completions through a denylist/classifier stage and drops or suppresses matches before the trie builder ever sees them, so a bad suggestion never reaches a serving node in the first place. Because that only runs once a build cycle, a fast-path emergency denylist (a small, frequently-refreshed set the autocomplete service checks in-line) is also needed so a suggestion that suddenly turns embarrassing can be suppressed within minutes rather than waiting for the next scheduled rebuild.

What is the single biggest bottleneck as this scales 10x?

Not the trie build - that's an offline batch job and can simply run on a bigger cluster or more often. The real pressure is on the online serving fleet: 10x the QPS (roughly 700k peak) needs 10x the read fan-out, and the 14.4GB trie itself would also grow well past 100GB if query diversity grows with traffic. The fix is the same lever twice - add more replicas per shard to absorb QPS, and split shards more finely (e.g. 3-character prefixes instead of 2) to keep each individual shard's memory footprint and hot-key skew manageable, rather than trying to make any single node bigger.

8. Summary: what a strong answer covers

Recognized QPS > underlying search QPSJustified every number with a calculationKept the database off the hot path entirely Precomputed top-K per trie node, not per requestSeparated batch freshness from real-time trendingUsed blue-green swap for zero-downtime rebuilds
Interview tip When asked to design autocomplete, the strongest signal is recognizing that this is fundamentally a read-only, precomputation-heavy problem: almost all of the real engineering work happens offline, before a single keystroke arrives, so that the online path can stay a dead-simple, memory-only lookup fast enough to run on every character the user types.
No comments
Leave a Comment