Code a prefix-based suggestion engine
Open in the interactive app: practice, quiz, mock interview →In one minute
- In a low-level design round, autocomplete is a trie plus three policies that change independently: how text is normalised, how candidates are ranked, and which candidates are hidden. Keep each policy behind its own small interface so the trie never learns about business rules.
- Model the user's typing as a session object that holds a cursor into the trie. Each keystroke moves the cursor one node instead of walking the whole prefix again, and backspace pops it. This is the in-process equivalent of a client keeping state between requests.
- Rank with a sort key, not with nested
ifstatements: personal history first, then global popularity, then alphabetical order as a deterministic tie-breaker. Tests and users both need the same input to give the same list. - Filters run after ranking candidates are gathered and before truncation to k, or a blocked term silently eats a slot and the list comes back short.
- The distributed version (sharding by prefix range, decayed scores, hourly snapshots) is covered in Design Search Autocomplete System and Search and Typeahead. This lesson is about the object model inside one process.
Requirements
Functional:
add(term, count)loads a term with an initial popularity, for example from yesterday's query log.suggest(prefix, user)returns at most k completions that start with the prefix.- A user can start a typing session: each typed character or backspace returns fresh suggestions, and accepting a suggestion records it as a search by that user.
- Ranking: terms this user has picked before come first (most picked first), then by global count, then alphabetically.
- Normalisation: case-insensitive, leading spaces ignored, runs of spaces treated as one. A trailing space is meaningful:
newshould matchnew yorkbut notnewark. - A blocklist of words hides any term containing one of them as a whole word.
Non-functional:
- A keystroke is answered in well under a millisecond in process, because the network and rendering already use most of the 100 ms a user tolerates.
- Results are deterministic for a given index state, so they can be tested and cached.
- Reads vastly outnumber writes, and writes must never leave a reader seeing a half-inserted term.
Out of scope: spelling correction, multi-language tokenisation, sharding, and time-decayed popularity. Name them and place them in Extensibility.
Entities and relationships
Start from the nouns in the requirements and keep only those that hold state or vary.
- AutocompleteService is the façade callers use. It owns the trie, the global counts, the per-user history, and the three policies.
- TrieNode has a map from character to child and an optional terminal term. It is an internal detail; nothing outside the service sees nodes.
- TermStats holds a term's global count (a plain map in the practice code; production adds a last-used time for decay).
- UserHistory maps a user to the terms they accepted and how many times. It is the input for personal ranking.
- Normalizer turns raw text into the canonical form used as trie keys. Both terms and prefixes go through it, which is the only way they can ever match.
- Ranker turns a candidate list into an ordered list, given the user's history.
- SuggestionFilter decides whether a candidate may be shown. A blocklist filter is one implementation; "hide adult terms for child accounts" is another.
- TypingSession belongs to one user and holds the current prefix and a stack of trie nodes, one per typed character.
Relationships: the service is composed of the trie and the stats (they do not exist without it). Sessions reference the service but do not own it, and many sessions share one service. Policies are injected, so the service depends on their interfaces, not on concrete classes.
Class diagram
classDiagram
class AutocompleteService {
-root: TrieNode
-counts: Map~string, int~
-history: Map~UserId, Map~string, int~~
-k: int
+add(term, count) string
+select(user, term)
+suggest(prefix, user) string[]
+session(user) TypingSession
}
class TrieNode {
-children: Map~char, TrieNode~
-term: string
}
class TypingSession {
-user: UserId
-prefix: string
-path: TrieNode[]
+type(ch) string[]
+backspace() string[]
+accept(term)
}
class Normalizer {
<<interface>>
+normalize(text) string
}
class Ranker {
<<interface>>
+rank(candidates, history) string[]
}
class SuggestionFilter {
<<interface>>
+allows(term) bool
}
AutocompleteService "1" *-- "1" TrieNode
TrieNode "1" *-- "many" TrieNode
TypingSession --> AutocompleteService
TypingSession --> TrieNode
AutocompleteService --> Normalizer
AutocompleteService --> Ranker
AutocompleteService --> SuggestionFilter
PersonalThenPopular ..|> Ranker
WordBlocklist ..|> SuggestionFilterKey flows
The two flows that matter are a keystroke inside a session and accepting a suggestion. Note that the keystroke never re-walks the prefix from the root.
sequenceDiagram
participant U as User
participant S as TypingSession
participant A as AutocompleteService
participant F as SuggestionFilter
participant R as Ranker
U->>S: type("y")
S->>S: child of top node, push it
S->>A: collect(node, user)
A->>A: walk subtree, gather terms
A->>F: allows(term) for each
F-->>A: kept terms
A->>R: rank(kept, history[user])
R-->>A: ordered list
A-->>S: first k terms
S-->>U: ["new york", "new york times"]
U->>S: accept("new york times")
S->>A: select(user, term)
A->>A: counts +1, history[user] +1
S->>S: reset prefix and pathDesign patterns used and why
- Strategy for ranking. Ranking is the policy product managers change most: add recency, boost sponsored terms, demote terms with high abandonment. Each becomes a new
Rankerand the trie code is untouched. The default ranker is a sort on the tuple(-personal, -global, term), which is easy to explain and impossible to get subtly non-deterministic. - Chain of Responsibility or Specification for filters. A candidate passes through a list of filters and survives only if all allow it. Composing them as a list keeps each rule small and lets you add "legal takedown list" without editing the profanity filter. If filters need to be combined with
orandnot, the Specification pattern is the better fit. - Façade:
AutocompleteServiceis the only public entry point. That lets you swap the trie for a sorted array or a finite-state transducer later without touching callers. - Iterator for subtree traversal, so a smarter ranker can stop early once it has enough high-scoring terms. The session's node stack is a small undo history: backspace rewinds it without recomputation.
- Avoid a
Singletonindex: tests need a fresh one each, and two locales need two. Wire one instance inmain.
Concurrency and edge cases
- Readers during an insert. A keystroke walks the trie while a batch job inserts new terms. With a plain map per node, a concurrent write can corrupt a Go map (the runtime detects it and crashes the process, per the Go 1.6 notes) or expose a half-built path in other languages. Options: a reader-writer lock around the trie, or build a new trie off to the side and swap one pointer (copy-on-write snapshot). The swap gives lock-free reads and fits a "rebuild hourly" pipeline; the lock fits frequent small updates.
- Counts under concurrent accepts. Increment under the trie's lock or with an atomic counter per term. A lost count is harmless; a lost new term because two inserts raced on one node is not.
- Stale session cursor. A session typed
zq, which did not exist, so its cursor is empty. A moment laterzqxis added. If the session trusts its cached empty cursor it will keep returning nothing. Re-resolve from the root when the cursor is empty, or pin each session to an immutable snapshot and accept that it sees the index as of its first keystroke. - Trailing and repeated spaces. Normalising prefixes with
strip()throws away the trailing space and turnsnewintonew, which then matchesnewark. Collapse runs of spaces, drop leading ones, but keep a single trailing space. - Blocklist by substring. Blocking
assby substring hidesclassandassistant. Match whole words after normalisation. - Empty prefix. Returning the global top k on an empty prefix is a product decision (some sites show trending searches). Make it explicit; the practice code returns nothing.
- Unicode. Lowercasing is locale-sensitive and one visible character can be several code points. Apply one normalisation form (NFKC) at the boundary (Unicode UAX #15).
In the wild
- Elasticsearch 1.5.0, 2015: acknowledged writes lost. Jepsen tests showed the search index losing acknowledged documents under partitions, process pauses and
kill -9: about 22% of writes in one run that isolated a primary, and about 10% after kills because writes were acknowledged before the translog fsync, which defaulted to every 5 seconds. The recommendation was not to treat a search index as the system of record, but to keep data in a safer store and upsert it continuously (Jepsen). A rebuildable trie or inverted index in this lesson has the same role: derived data. - Discord, 2025: re-architecting message search. Two Elasticsearch clusters grew past 200 nodes, a 50-message bulk could fan out to 50 nodes, and indexes hit Lucene's roughly 2 billion document limit, after which indexing failed. Discord moved to many smaller clusters in cells, capped indexes at 200M messages, and routed bulk requests by destination node; median latency fell from 500 ms to under 100 ms (Discord).
Down the stack
Layer 1The contract
suggest(p, u) returns at most k distinct terms, each of which equals normalize(t) for some added term t, starts with normalize_prefix(p), and passes every filter. The list is sorted by (-history[u][t], -count[t], t). A session's type and backspace return exactly what suggest(session.prefix, user) would return at that moment, so the session is an optimisation, never a different answer. select(u, t) increases count[t] and history[u][t] by one and adds t if it is new.
Layer 2The mechanism
The trie maps each character to a child node; a node with a non-null term marks the end of a term. Finding the prefix node costs O(length of prefix). Collecting candidates walks the whole subtree below it, which for a one-letter prefix can be a large fraction of the index, so production systems precompute the top k at every node (the approach in the sdi lesson) or store the maximum score in each subtree and run a best-first search with a heap that stops after k results. A radix (compressed) trie merges chains of single-child nodes into one edge labelled with a string, cutting node count by 3 to 10 times for natural-language queries. Lucene's suggesters compile the terms into a finite-state transducer, which shares suffixes too.
Layer 3The machine
A trie is a pointer-chasing structure. Each step from parent to child is a dependent load; if the child is not in cache, that is a main-memory access of roughly 80 to 100 ns. A 10-character prefix in a cold trie can therefore cost around a microsecond before any ranking happens. Per-node memory matters too: a Python dict starts at about 64 bytes empty and over 200 bytes once it holds a few keys, plus about 56 bytes for the node object itself with __slots__. A Go map[rune]*node has a header of about 48 bytes plus buckets of 8 entries. An array of 26 child pointers is 208 bytes on a 64-bit machine, mostly empty below the first few levels. Ten million terms with an average of 12 characters can produce tens of millions of nodes, which is several gigabytes in Python and is why serving systems use compact arrays, radix tries or FSTs. A pointer-heavy Go heap also lengthens garbage collector marking.
Why it's built this way
- Users type faster than a server round trip, so the per-keystroke work must be tiny, which means navigation by prefix rather than scanning.
- Prefix navigation is exactly what a trie gives, and a session cursor removes even the repeated walk from the root.
- Walking subtrees is the expensive part, so ranking data is pushed up the tree (top k per node or subtree maximum) once the index grows.
- Pointer-heavy tries waste memory and cache, so production indexes are compressed into radix tries or FSTs that are built offline and swapped atomically.
- Business rules change weekly while the index structure changes rarely, so normalisation, ranking and filtering live outside the trie as injected policies.
Extensibility
- Recency. Replace raw counts with a decayed score: store
(score, last_update)and decay on read. OnlyTermStatsand the ranker change. - Precomputed top k. When the index grows past a few hundred thousand terms, cache the top k list in each node and update the lists along the path on every insert.
suggestthen stops walking subtrees. The public interface stays the same. - Fuzzy matching. To tolerate one typo, walk the trie with a Levenshtein automaton (Gallant) or allow one substitution per level with a bounded search. This is a new candidate generator; ranking and filters still apply.
Variant: full-text search engine
Autocomplete answers "which stored strings start with this prefix?" A small text search engine answers "which documents contain these words?", and that changes the data model more than the algorithm. The unit stored is no longer a term with a count but a document, and the trie gives way to an inverted index: a map from each token to its postings, the list of documents containing it.
- Pipeline of four objects. An analyzer turns text into tokens, the index maps tokens to postings, a query parser turns the user's string into a tree, and a scorer orders matches. The trie's normaliser, ranker and filter policies reappear here, but the analyzer carries more weight: it must run identically on documents at index time and on queries at search time. Most "why does search miss this" bugs are the two paths tokenising differently (Elasticsearch docs), the same failure as a prefix and a term normalised differently in the trie.
- Positions in the postings. Each posting holds the document id and the offsets of the token in that document. Ids alone are cheaper but cannot answer a quoted phrase such as
"new york", which needs the two tokens adjacent and in order. A phrase check intersects the postings of both words, then keeps documents where some offset of the second word is exactly one past an offset of the first. - Queries are trees. A query is a list of clauses: a bare word must appear, a quoted phrase must appear adjacently, and a leading minus excludes. Model each as a node in a Composite (term, phrase, must-not) with one
evaluate(index)method. A new operator becomes a new node type instead of another branch in a longsearchmethod, which is the same extensibility argument that justified Strategy for rankers above. - Different ranking inputs. Autocomplete ranks by popularity and personal history. Here a simple deterministic score is the total occurrences of the positive clauses in the document, ties going to the smaller document id. Real engines use length-aware formulas such as BM25 (Introduction to Information Retrieval), but the scorer stays behind its own interface.
- Updates and deletes. Re-adding a document id must remove its old postings first, or stale tokens keep matching. Deletion must clear every posting the document appears in, so keep a forward map from document id to its token list; without it, removal means scanning the whole index.
Concurrency matches the trie: readers during a write need a lock or an immutable snapshot swap. Memory differs, since postings with positions can rival the raw text, which is why production engines compress and segment them. Stemming and synonyms are extra analyzer stages, not index changes.
Leadership lens
- Build first: the normaliser and its tests. Every other bug in autocomplete (missing matches, duplicates that differ only by case or space) traces back to inconsistent normalisation between write and read.
- Ask in review: where does normalisation happen and is it the same function on both paths; what happens on a one-character prefix with a million terms below it; how is a new term made visible to readers without a lock on the hot path.
- Content risk is a product and legal concern, not just engineering. Make the blocklist data-driven and owned by a trust and safety team, with a fast path to hide a term within minutes.
- Measure keystroke-to-render latency and acceptance rate; a point of acceptance beats 50 microseconds saved.
Check yourself
Why must the filter run before the list is cut to k, not after?
Show answer
If you truncate first and then filter, every blocked term removes a slot and the user sees fewer than k suggestions even though valid ones exist. Filtering first keeps the list full.
A session typed
qzand has an empty cursor. Thenqzxis added. What should the next keystrokexreturn, and why?Show answer
It should return
qzx, because the session promises the same answer as a freshsuggest. The session must re-resolve its cursor from the root when it holds an empty cursor, or the cache becomes a correctness bug.Why is alphabetical order part of the ranking key?
Show answer
Without a final tie-breaker, equal-scored terms come back in hash-map or traversal order, which can change between runs and languages. A total order makes results testable and cacheable.
When would you prefer a copy-on-write snapshot over a reader-writer lock?
Show answer
When writes come in batches (an hourly rebuild) and reads are extremely frequent. Readers then never block and never see a partial insert; the cost is memory for two copies during the swap.
Sources and further study
Primary
- Jepsen: Elasticsearch 1.5.0: durability findings for a search index under faults.
- Discord: how Discord indexes trillions of messages: sharding and bulk routing for a large search cluster.
Systems docs
- Elasticsearch: index and search analysis: why index-time and query-time analyzers must agree.
- Apache Lucene core documentation: the inverted index, postings and scoring that the variant models in miniature.
Books
- Introduction to Information Retrieval, Manning, Raghavan and Schutze: chapter 1 (Boolean retrieval, inverted index), chapter 2 (term vocabulary and postings) and chapter 6 (scoring).
- Design Patterns: Elements of Reusable Object-Oriented Software, Gamma, Helm, Johnson and Vlissides: chapter 4 (Facade, Composite) and chapter 5 (Strategy, Iterator, Chain of Responsibility).
Other curricula
- system-design-primer: frames autocomplete as a trie with cached top results behind a cache, the distributed view of this object model.
Watch
- System design: Design Autocomplete or Typeahead Suggestions for Google search (Tushar Roy): the trie with cached top k per node, then how it scales.
- Strategy Pattern, Design Patterns ep 1 (Christopher Okhravi): the pattern behind pluggable ranking.
Free resources
- Trie (Wikipedia): operations, complexity and the compressed variants.
- Radix tree (Wikipedia): how merging single-child chains shrinks a trie.
- Index 1,600,000,000 Keys with Automata and Rust (Andrew Gallant): a clear, deep explanation of finite-state transducers for prefix search.
- Suggesters (Apache Solr Reference Guide): how a production search engine builds and serves suggestions.
- Unicode normalization forms (Unicode UAX #15): why NFKC matters before indexing user text.