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 slides
Reading time ~12 min Prerequisites Ch 6 · Key-Value Store, Ch 2 · Estimation Audio 🔊 Hinglish read-aloud Next Design YouTube →

Autocomplete 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.

Primary source

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.

RequirementWhat it demandsWhy it bites
LatencyEnd-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.
ThroughputEvery keystroke is a request — roughly 10× the query rate.A 7 K QPS product becomes tens of thousands of QPS at the typeahead.
FreshnessTrending queries should surface within hours, not days.Rebuilding the whole index constantly is expensive; staleness is the trade.
AvailabilityDegrade 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.

root s w u i swim swig sup
One path per shared prefix. swim and swig share the path 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.

terminal
swiss
f = 8,400
terminal
sushi
f = 3,600
terminal
sweet
f = 1,250
terminal
summit
f = 740

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.

1 · descend
walk the prefix, one node per char → O(P), cheap
2 · explore
DFS the subtree, visit every descendant terminal → O(N)
3 · select
heap the top K by frequency → O(N log K) — and N can be millions

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.

StepCostVerdict
Descend to prefix nodeO(P)Fine — P is tiny.
DFS subtree (N terminals)O(N)N grows with traffic — fatal on hot prefixes.
Top-K via heapO(N log K)Still linear in N.
Total per keystrokeO(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’ TOP-3 swiss sushi sun ‘sw’ TOP-3 swiss sweet swim ‘swi’ TOP-3 swiss swim swipe + w + i return the cached list Lookup is O(P): walk P nodes, return the list at the last one — no subtree scan, no sort.
Each node along the path 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.

memory  ≈  nodes × K × avg suggestion bytes
50M nodes × K=10 × ~16B → the naïve cache is ~10× the bare trie
Pruning leverWhat it doesEffect
Min-frequency cutoffDrop queries searched fewer than N times — the long tail nobody sees.~10× → ~6×
Max prefix depthStop precomputing past ~depth 6; deep prefixes have few candidates, a quick DFS is fine.~6× → ~3.5×
Variable KStore more suggestions for short hot prefixes, fewer for deep rare ones.Shaves the tail further.
Radix compressionCollapse 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.

Offline build, online serve Query logs raw events S3 / Kafka Aggregate count + decay Spark / MapReduce Build trie insert + precompute K serialise to blob Publish push to fleet atomic swap Serve in-memory read-only Runs every few hours. Readers swap atomically from old blob to new — never a partial state.
The pipeline is write-side work: all the counting, ranking, and top-K selection happens here, so the read path has nothing left to compute.

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.

Freshness is a product decision, not a default

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.

user CDN edge ~5 ms · prefix → top-K Redis (regional) ~15 ms · per-locale Trie server ~30–50 ms · in-memory ~80% of traffic ~15% of traffic ~5% of traffic Each layer absorbs the hot prefixes and protects the next; dashed = fall-through on a miss.
A cache cascade turns a Zipfian traffic curve into a cheap one. Autocomplete tolerates slight staleness, so TTL expiry at the edge is perfectly acceptable.

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.

Why does a trie make prefix lookup O(P)?
Think about shared paths.
Every query that shares a prefix shares a path from the root, one character per edge. Finding all completions of a P-character prefix is just walking P edges — independent of how many queries sit underneath.
Where are frequency counts stored, and why only there?
Which nodes are complete queries?
On terminal nodes only, because intermediate nodes aren't complete queries. The count is that query's popularity over a recent window; it's produced offline, never on the request path.
Why does the naïve DFS lookup blow the latency budget?
What happens on a short prefix?
A short, popular prefix (e.g. 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.
What does precomputing top-K at every node buy, and what does it cost?
Speed for what?
It makes lookup O(P) — walk to the node, return its cached list, no scan. The cost is memory (~K×), tamed by min-frequency cutoffs, depth caps, variable K, and radix compression.
Why build the trie offline and treat it as immutable?
What should the read path never do?
So the hot read path never mutates state. A batch job builds a fresh blob and the fleet atomic-swaps to it — avoiding locking, partial reads, and consistency headaches on every request.
First-character vs hash sharding — the trade-off?
Routing vs load.
First character: trivial routing, but badly skewed ("s" is hot). Hash of prefix: even load and one-hop lookups, but it duplicates prefix data and drops the shared trie structure.

Check yourself

Q1 Why does the naïve "walk then DFS the subtree" lookup fail on short prefixes?
Why: The O(P) walk is trivial. The subtree under a hot short prefix can contain millions of terminals, so the O(N) DFS on every keystroke is what overshoots the budget.
Q2 After precomputing the top-K on every node, what is the per-lookup cost?
Why: The list is already sitting on the node. You just walk P edges and return it — O(P), with no dependence on how many queries live below.
Q3 What is the main cost of caching top-K at every node, and a fix?
Why: Storing K strings per node multiplies memory by roughly K. Min-frequency cutoffs, a max prefix depth, variable K, and radix compression bring it back down.
Q4 Why build the serving trie offline and swap it in atomically?
Why: An immutable, read-only trie plus an atomic swap keeps mutation off the hot path — no locking, no half-built index visible to readers.
Q5 What is the drawback of sharding the trie by first character?
Why: First-character routing is trivial but load is badly uneven. Hashing the prefix evens the load (at the cost of duplicating prefix data and dropping the shared structure).