🔎 System Design search autocomplete
🔎 Part III · Classic Case Studies · chapter 16 / 24

The top suggestions,
before you finish typing

Type one letter into a search box and a ranked list of likely queries appears in under a tenth of a second. This chapter builds the machine behind that magic — a trie with precomputed top-k lists, an offline pipeline that learns from billions of past searches, and a way to shard it all across servers.

Autocomplete feels like mind-reading, but it's really just very fast list-lookup. The user types a few characters — a prefix — and you must instantly produce the handful of complete queries that (a) start with those characters and (b) are the most popular.

That's the whole problem in one sentence, and almost everything interesting follows from one brutal constraint: it has to be fast. Every keystroke fires a new request, so "fast" means a full round trip and ranked answer in well under a tenth of a second, while the system as a whole is fielding millions of these per second.

We'll build it the way the chapter on scaling taught us — start with the obvious data structure, find where it's too slow, and peel that job off into something dedicated. The obvious structure is a trie; the thing it's too slow at is ranking; and the fix is to precompute the answers. Then we wrap that core in an offline learning pipeline, a fast serving layer, and a sharding scheme — and finish with the messy real-world extras: typos, trends, and personalization.

1What we're building & the latency budget

Before any boxes-and-arrows, pin down what the thing must do. Autocomplete has a surprisingly tight, surprisingly small spec — and naming it precisely is what keeps the design honest.

  • Prefix matching only. Given what the user has typed so far, return queries that begin with that text. We are not doing full-text search, not matching the middle of words, not (yet) tolerating typos. Just prefixes.
  • Top-k, ranked by popularity. "di" matches millions of queries; nobody wants a million suggestions. Return the k best — usually 5 to 10 — and "best" means most-searched. Popularity is the ranking signal.
  • Fast — really fast. The whole point is to beat the user's typing. The target is a suggestion list back in under ~100 ms, including network. Since every keystroke can fire a request, the read path has to be cheap to the point of trivial.
  • Read-heavy, write-rare. Suggestions are read constantly; the underlying popularity data changes slowly. This lopsided ratio is the gift that makes the whole design possible — we can do expensive work rarely and cheap work constantly.

Notice what's not here: we don't need the suggestions to reflect the very latest search this second, and we don't need perfect ranking. "Good enough, instantly" beats "perfect, eventually." That trade — sacrificing freshness and exactness to buy speed — is the seed of every decision below.

🎯
A 100 ms budget has to cover network round trip, server lookup, and rendering. The network alone can eat 30–50 ms, so the actual lookup must take single-digit milliseconds. That is the number that rules out "just scan the database on every keystroke."

2The trie — a tree of prefixes

Here's the natural way to store words so you can find everything starting with "di" instantly: build a tree where each node is one character, and the path from the root down to a node spells out a prefix. To find all words starting with "di", you walk root → "d" → "i" and everything in the subtree below is your answer set.

This tree of prefixes is called a trie (from retrieval, and yes, everyone pronounces it "try"). Shared prefixes are stored once: "disney", "discord", and "dictionary" all share the same "d" and "i" nodes near the top, then branch apart. At each node that completes a real query, we store how many times that query has been searched — its frequency. A node can be both a complete word and a stop along the way to longer ones ("disc" is a word and a prefix of "discord").

So a lookup is two phases: walk down to the prefix node (cheap — as many steps as there are characters typed), then explore the subtree to collect every completion and its frequency, sort by frequency, and take the top k. And that second phase is the problem.

For a hot prefix like "a" or "the", the subtree underneath holds an enormous number of words. Visiting all of them, gathering frequencies, and sorting — on every single keystroke — blows the millisecond budget instantly. The trie makes "find the prefix" fast, but leaves "rank the completions" slow. That's the gap the next section closes.

Interactive · build the trie Add words, then hover a node to see its top-k
hover any node to see the top-k completions cached there
λ
The FP framing: a trie is a textbook recursive algebraic data type — a node is a map from the next character to a sub-trie, plus a frequency. Trie(children: Map[Char, Trie], freq: Int). Insert and lookup are folds over the characters of a string. We'll write exactly that in the code section.

3Precompute top-k at every node

The fix is almost insultingly simple, and it's the heart of the whole system: store the answer on the node itself. At each prefix node, keep a small list — the top k completions for that exact prefix, already ranked. The "d-i" node doesn't just point at a subtree; it carries a ready-made list ["disney", "discord", "disco"].

Now a query is trivial. Walk down to the prefix node (a handful of character steps) and read the list that's sitting right there. No subtree exploration, no sorting at query time. The whole lookup costs about O(length of the prefix) — and prefixes are short. That's how you hit single-digit milliseconds.

This is a classic space–time tradeoff. We spend extra memory (every node now stores k suggestions) to buy extra speed (no work at query time). The cost is real — millions of prefix nodes each holding a small list — but memory is cheap and latency is sacred, so it's a trade we happily make. It's the same instinct as the cache from Chapter 1: remember the answer instead of recomputing it.

The natural question: when does that precomputed list get built? Not at query time — that would defeat the point. It's built offline, in advance, by a separate pipeline. Which is exactly the next section.

Interactive · cached top-k vs live traversal Same query, two ways — count the operations
900
live traversal · ops
precomputed top-k · ops
speedup

4Learning what's popular — the data-gathering service

Where do those frequencies come from? From watching what people actually search. Every query users type gets logged, and those logs are the raw material we turn into a ranked trie. The trick is to split the system cleanly into two halves that run at very different speeds.

The offline pipeline does the slow, heavy thinking. It takes the firehose of raw query logs, aggregates them (group identical queries, count how often each was searched over some window), filters out junk and offensive terms, and then rebuilds the trie from scratch with fresh frequencies and freshly precomputed top-k lists at every node. This runs on a schedule — say once a week, or nightly — far away from any user request.

  • Aggregate: count occurrences of each distinct query ("disney" → 1.2M this week). This is a big batch job — exactly the kind of work a data-warehouse / map-reduce style pipeline eats for breakfast.
  • Build: insert each query with its count into a fresh trie, then walk the tree once computing the top-k list for every node.
  • Ship: publish the finished trie to the query servers, which atomically swap the old one out for the new one.

The online query service does the opposite job: nothing but lightning-fast reads against the trie the pipeline handed it. It never aggregates, never rebuilds, never sorts. This separation — expensive batch work offline, trivial reads online — is the single most important architectural decision in the whole design. It's why a keystroke can be answered in microseconds: all the hard work already happened, hours ago.

🏭
Two clocks, deliberately. The offline pipeline ticks in hours or days; the online query service ticks in milliseconds. Keeping them apart means a slow, complex rebuild never touches the fast, simple read path.

5Serving suggestions at speed

The online side has one job — turn a prefix into a top-k list as fast as physically possible — and a few well-worn tricks make it scream.

  • Keep the trie in memory. Disk is far too slow for a 100 ms budget. The trie lives in RAM — in the query server's own process, or in an in-memory store like Redis in front of it. A lookup is then pure memory access, no I/O.
  • Debounce the client. Don't fire a request on every single keystroke. Wait a short beat (say 50–100 ms) after the user stops typing, and only then ask. This collapses a burst of "d", "di", "dis", "disn" into one request for "disn", cutting traffic dramatically. (And cancel the in-flight request when a newer keystroke arrives.)
  • Cache in the browser. If the user just got suggestions for "dis" and now types "disn", the browser may already hold what it needs — or the answer for the shorter prefix is a fine fallback while the new one loads. Standard HTTP caching of suggestion responses shaves more round trips.
  • Sample the logs. At millions of QPS, logging every query for the offline pipeline is itself expensive. Popular queries are popular precisely because they're frequent, so a random sample (log 1 in 100) captures the popularity distribution faithfully at a fraction of the cost.

Together these mean the server fields fewer requests than you'd fear, and each one it does field is a near-instant in-memory read. The expensive parts of the system are kept rare; the frequent part is kept cheap.

λ
The FP framing: the query service is a pure read over an immutable snapshot. Each rebuild produces a brand-new immutable trie; servers point at it and never mutate it. Swapping versions is just rebinding a reference — no locks, no in-place edits, perfectly safe to share across every thread and replica.

6When the trie outgrows one machine — sharding

A trie covering every query in every language, with top-k lists on every node, can grow far past what fits on one server. Same move as Chapter 1: chop it into pieces and put each piece on its own machine. The natural cut is by first letter — one shard owns all prefixes starting with "a", another owns "b", and so on. A query for "disney" goes straight to the "d" shard.

But splitting evenly by letter spreads letters evenly, not traffic. The letter "s" starts a colossal share of English queries; "x" and "z" start almost none. Give each its own shard and the "s" server melts while the "x" server idles — the hotspot problem again. The fix is load-aware splitting: keep heavy letters on their own (or split them further by the second letter — "sa", "sb", "sc"…), and bundle many light letters together onto one shard ("x", "y", "z", "q" can comfortably share).

Once shards no longer map one-to-one to letters, you need a small lookup layer that knows the current layout — a shard map that, given a prefix, names the shard holding it. The query path becomes: consult the shard map, route to that shard, read its in-memory top-k. When traffic shifts, you re-split the map and rebalance, and the routing layer just follows along.

Interactive · shard-by-letter load map Click a hot (red) letter to split & rebalance it
peak shard load: —
shards
hottest shard load
balance (max ÷ avg)
🗺️
The shard map is itself a lookup service (callback to Chapter 6). It's small, changes rarely, and is cached everywhere — so consulting it on the read path adds almost nothing while letting the layout underneath evolve freely.

7Keeping suggestions fresh

The offline rebuild is what makes the system fast, but it's also what makes it stale: between rebuilds, the trie is frozen. Most of the time that's fine — popularity drifts slowly. The hard case is sudden news: a celebrity, an event, a product launch that everyone starts searching for in the same hour. A weekly rebuild won't show it for days. So freshness is a spectrum of choices, traded against cost and complexity.

  • Periodic full rebuild. The baseline — simplest, most robust, but only as fresh as its schedule. Rebuild more often (nightly, hourly) to tighten the lag, at more compute cost.
  • Incremental updates. Instead of rebuilding the whole trie, patch in changes — bump a frequency, insert a new query, refresh the affected top-k lists along one path. Cheaper and faster to apply, but fiddlier to get right, and updating a top-k list can ripple up several ancestor nodes.
  • A real-time / trending layer. Run a separate, fast-moving pipeline that watches the last few minutes of traffic for surging terms, and merge those into results on top of the slow base trie. This is how "breaking news" terms appear within minutes, not days.
  • Recency weighting. When counting popularity, weight recent searches more than old ones (a decay). Yesterday's spike should fade as it stops being searched, so the rankings track what's hot now, not what was hot last month.

Most production systems run a hybrid: a sturdy periodic rebuild for the long tail, plus a nimble trending layer for the head. The base trie gives you correct, comprehensive coverage; the trending layer gives you the same-day relevance the base can't.

⚠️
The freshness–cost dial. There's no free freshness. Every notch toward "more current" costs more compute (rebuild more often) or more complexity (incremental patching, a second pipeline). Pick the coarsest update cadence your users will tolerate, then add a trending layer only for the head where it matters.

8Beyond the basics — the real-world extras

The clean prefix trie is the skeleton. Real autocomplete grows several extra organs, each a small system of its own bolted onto the core.

  • Typo tolerance (fuzzy matching). People misspell. "diney" should still surface "disney". This means matching prefixes within a small edit distance of what was typed — usually by exploring a few near-miss paths in the trie, or precomputing common misspelling variants. It costs more lookup work, so it's applied carefully.
  • Personalization. The same prefix can mean different things to different people. A developer typing "java" wants the language; a tourist wants the island. Blend a per-user signal (history, location, language) into the ranking — typically as a re-rank layer on top of the global top-k, so the heavy global trie stays shared.
  • Multi-language & Unicode. "Each node is one character" gets subtle fast: accents, right-to-left scripts, and characters that span multiple code units. Often you shard or build separate tries per language/region, chosen by the user's locale.
  • Filtering offensive terms. Popularity is amoral — vile queries can be very popular. A safety filter (a blocklist plus classifiers) prunes offensive, hateful, or unsafe suggestions during the offline build, so they never enter the served trie in the first place.

None of these change the core idea. They're re-rank layers, filters, and variant-expansions wrapped around the same engine: a trie with precomputed top-k, fed by an offline pipeline, served from memory, sharded by prefix. Here's the whole machine as a checklist:

  1. 1Nail the spec — prefix match, top-k by popularity, <100 ms, read-heavy.
  2. 2Store prefixes in a trie — node = char, path = prefix, frequency at terminal nodes.
  3. 3Precompute top-k per node — turn each query into ~O(prefix length) reads; trade memory for speed.
  4. 4Gather data offline — aggregate query logs, rebuild the trie periodically, ship it.
  5. 5Serve from memory — Redis/in-process trie, debounce, browser cache, sample logs.
  6. 6Shard by prefix — load-aware splitting plus a shard-map routing layer.
  7. 7Keep it fresh — periodic rebuild + a trending layer + recency weighting.
  8. 8Add the extras — fuzzy match, personalization, multi-language, safety filtering.

9The trie, as a recursive ADT

Section 2 promised the trie is "a node is a map from char to sub-trie, plus a frequency." Here it is, exactly that, in both languages. insert folds a word into the tree one character at a time, bumping the frequency at the terminal node. topK walks down to the prefix node, gathers every completion in its subtree with its frequency, sorts, and takes k — the live version, the one section 3 told us to precompute and cache. Watch how both versions are just folds over a Map.

λ
The trie is a recursive ADT and both operations are folds over its Map of children — insert rebuilds one path, topK aggregates a subtree. In production you'd run a single pass that attaches the sorted top-k list to every node, so the query path never calls this collect at all. The precomputed cache is just this fold, memoized onto the structure.