Chapter 13 · System Design Fundamentals
Design a Search Autocomplete System
Suggest likely query completions while the user is still typing. The whole problem is shipping the right ranked list in under a hundred milliseconds — on every keystroke, for billions of keystrokes a day. The cleverness lives in the data structure and in when you do the work, not in the lookup.
▶ Open the companion slidesAutocomplete looks like a trivial lookup and is anything but. It sits in the user's hot path: the box is on every page, and every character they type is a separate request. That flips the usual read/write ratio on its head — reads dwarf writes by orders of magnitude — and it makes latency the single hardest constraint. The design answer is to move all the expensive work off the request path: precompute answers offline, store them so a lookup is a pointer chase, and let a cache cascade absorb the traffic curve.
Alex Xu, System Design Interview (Vol. 1), Chapter 13. Companion deck: slides — jump to any slide with the Slide N chips. Go deeper: Elasticsearch's completion suggester, a production autocomplete built on a prefix data structure (an FST) held in memory.
What the system must do Slide 2
Nail the constraints first, because they are what make an easy-sounding feature hard. The functional surface is small: given a prefix, return the top K matching queries ranked by popularity, updating as the user adds or deletes characters. The non-functional demands are where the design is decided — a strict latency ceiling, freshness measured in hours, and a request rate several times the underlying query rate because every keystroke fires.
| Requirement | What it demands | Why it bites |
|---|---|---|
| Latency | End-to-end under ~100 ms at p99, felt directly as the user types. | No time for a subtree scan or a database round trip per keystroke. |
| Throughput | Every keystroke is a request — roughly 10× the query rate. | A 7 K QPS product becomes tens of thousands of QPS at the typeahead. |
| Freshness | Trending queries should surface within hours, not days. | Rebuilding the whole index constantly is expensive; staleness is the trade. |
| Availability | Degrade gracefully; never show a 5xx in the box. | A broken typeahead is more visible than a slow one — fail to empty, not to error. |
A quick envelope (see Chapter 2): 10 M DAU × 10 queries/day × ~6 keystrokes each ≈ 600 M requests/day ≈ 7 K QPS average, 20–30 K at peak. Spelling correction, semantic search, and long-form completion are explicitly out of scope for the first cut.
The trie: a tree of shared prefixes Slide 3
The data structure is the design. A trie (prefix tree) stores strings one
character per edge, so every query that shares a prefix shares a path down from the root. Finding "all
queries starting with swi" is then just walking three edges — O(P) in the
prefix length P — and reading the subtree hanging underneath. That prefix-sharing is the whole reason
autocomplete can fit inside a latency budget at all.
s→w→i and only diverge at the last character; teal boxes mark terminals —
the end of a real query.Attach a frequency to every terminal Slide 4
A trie tells you which strings exist; it says nothing about which ones people actually search for. To rank suggestions you store a frequency counter on each terminal node — the number of times that exact query was issued in the recent window (often a sliding 30-day count, and newer hits can be weighted up with exponential decay so trends surface). Intermediate nodes hold no count, because they aren't complete queries.
The counts come from an offline aggregation of query logs, never from the live request path. Only the rank order matters, not the absolute value, so once normalised each count can be a small integer. Ranking by popularity is the default; personalisation and locale can rerank on top later.
Naïve lookup: walk, then explore everything below Slide 5
The textbook algorithm has four steps: descend to the prefix node (one edge per character), depth-first search the whole subtree collecting every terminal, then heap-select the top K by frequency and return them. It is correct — and it falls apart precisely when the prefix is short, which is exactly when traffic is heaviest.
The subtree under a single letter like s can hold millions of distinct queries. Doing a
full traversal of it on every keystroke, for every user overshoots the latency budget by orders
of magnitude. The prefix walk is free; the subtree explosion is the killer.
| Step | Cost | Verdict |
|---|---|---|
| Descend to prefix node | O(P) | Fine — P is tiny. |
| DFS subtree (N terminals) | O(N) | N grows with traffic — fatal on hot prefixes. |
| Top-K via heap | O(N log K) | Still linear in N. |
| Total per keystroke | O(P + N log K) | Blows the 100 ms budget for short prefixes. |
Cache the answer at every prefix node Slide 6
Turn the expensive read into a cheap one by doing the work ahead of time. Precompute the top K for every prefix and store that list on the node itself. A lookup collapses to: walk to the node, return its cached list. That is O(P) total — constant in the size of the corpus, however many billions of queries sit underneath. This is the same instinct as caching in the Key-Value Store chapter: precompute on writes so reads are trivial.
s→sw→swi carries its own precomputed
top-K. The query is a pointer chase: the answer is already sitting where the walk ends.You bought speed with memory — now prune Slide 7
Nothing is free. Storing K strings at every node multiplies memory by roughly K. A trie of 50 M nodes with K = 10 means ~500 M cached strings — the speed trick is really a speed-for-memory trade. The good news is that memory is a knob, not a cliff: a handful of pruning techniques bring the footprint back to something a fleet can hold in RAM.
50M nodes × K=10 × ~16B → the naïve cache is ~10× the bare trie
| Pruning lever | What it does | Effect |
|---|---|---|
| Min-frequency cutoff | Drop queries searched fewer than N times — the long tail nobody sees. | ~10× → ~6× |
| Max prefix depth | Stop precomputing past ~depth 6; deep prefixes have few candidates, a quick DFS is fine. | ~6× → ~3.5× |
| Variable K | Store more suggestions for short hot prefixes, fewer for deep rare ones. | Shaves the tail further. |
| Radix compression | Collapse single-child chains into one edge (a radix / Patricia tree). | ~3.5× → ~2× |
Strings can also share a single pooled buffer and be referenced by offset, so a suggestion costs a few bytes of pointer rather than a full copy at every node it appears in.
Build the trie offline from query logs Slide 8
The serving trie is a derived, immutable artifact. A periodic batch job reads raw
search logs, aggregates them into query → count, builds a fresh trie with the top-K
already precomputed, serialises it to a blob, and publishes it to the serving fleet. The live request
path never mutates state — it only reads a read-only structure that some other pipeline produced.
How fresh is fresh enough? Slide 9
Batch rebuilds hand you new trends every few hours, which is fine for most products — most queries don't trend on a minute-by-minute basis. When breaking news or a live event genuinely demands minute-level freshness, layer a streaming path on top rather than making it the default.
Path A · Delayed batch refresh
Rebuild the trie every 1–6 hours from the rolling log window and atomic-swap it into the serving nodes, so readers never see a half-built state. Simple, cheap, stale by ~hours. The right default.
Path B · Streaming top-up
Tail the query log through Kafka / Flink and maintain a small mutable delta trie of recently-spiking queries. At query time, merge the base trie's top-K with the delta's. Fresh in ~minutes — but now two systems must agree, and the delta is wiped on every batch swap.
The streaming path roughly doubles the moving parts. Reach for it only when the product truly needs minute-level trends; otherwise the batch swap alone keeps the design simple and the serving trie blissfully immutable.
Most keystrokes hit the same few hundred prefixes Slide 10
Prefix popularity follows a steep Zipf-style curve: a small set of short prefixes accounts for most traffic. That makes a cache cascade extremely effective. A CDN edge answers the handful of universal, short prefixes; a regional Redis catches longer and per-locale variants; the trie servers only ever see the long tail and any personalised reranks.
Invalidation stays lazy: short TTLs let the edge go a little stale, and a version tag bumped on each fresh trie deploy makes caches refill on their own. Never block a request waiting on a cache — fall through fast.
Shard the trie when one box no longer fits Slide 11
A full trie can outgrow a single machine's RAM. There are two common partitioning schemes, and they trade routing simplicity against load balance — the same tension that shows up whenever you shard.
By first character
Shard A holds a–c, shard B d–g, and so on; the router picks a shard from the first byte. Routing is trivial, but load is badly skewed — the "s" shard is enormous and hot while "z" is idle. Mitigate by splitting hot ranges further (sa-, se-, sh-, …).
By hash of the prefix
Store each (prefix, top-K) on hash(prefix) mod N. Load is uniform and a lookup is one hop, but the trie structure is no longer shared across shards, so you duplicate prefix slices — it's really a flat key-value store keyed by prefix.
In practice many systems lean toward the hash / key-value approach precisely because even load and single-hop lookups matter more at serving time than sharing trie edges — the redundancy is a price worth paying. This is the consistent-hashing idea from earlier chapters applied to prefixes.
Principles to carry forward Slide 12
The data structure is the design
Choosing a trie locks in O(P) lookup and prefix sharing — the only reason any of this fits the latency budget.
Precompute on writes, not reads
The cheapest read returns a pointer. Move top-K selection into the offline build and leave nothing to compute at query time.
Treat the serving trie as immutable
Build offline, swap atomically. Mutating on the hot path invites locking and consistency pain.
Match freshness to the product
Batch every few hours covers most cases; stream-merged deltas exist for the minority who truly need minutes.
Cache cascades absorb the curve
Prefix popularity is Zipfian, so a small edge cache plus a regional Redis takes load off the trie fleet for nearly free.
Memory is a knob you control
Min-frequency cutoffs, depth caps, variable K, and radix compression turn the speed-for-memory trade into a dial.
Active recall
Cover the answers. Say each one out loud before you tap to check.
s) has a subtree of millions of
terminals. DFS-ing all of them on every keystroke is O(N) work exactly where traffic is
heaviest — orders of magnitude over budget.