LearnHLDDesign search autocomplete

Design search autocomplete

Type six characters into a search box and you have made six requests, each of which has to come back before your finger reaches the next key. Nobody clicks a suggestion that appears after they have finished typing.

That gives you a latency budget of about fifty milliseconds and a request rate several times higher than the searches themselves. The interesting part is that this is not a search problem at all. It is a precomputation problem, and once you see that, the whole design falls out.

Step 1: Understand the problem

The trap here is designing a search engine. Ask enough questions to establish that you are not.

You askThey sayWhat it settles
How many suggestions, and how are they chosen?Top five, by popularity of past searches.Everything. If the answer is popularity of a fixed corpus, the top five for any prefix can be computed in advance, and serving becomes a lookup rather than a search.
What is the latency budget?Under 50ms at p99, from the user's device.No disk, no ranking at query time, and the whole index in memory. It also means suggestions get served from the edge, close to the user, because 50ms does not leave room for a trip across a continent.
How fresh do suggestions have to be?Hours is fine for most, minutes for breaking news.Permission to precompute. This one answer is what separates a lookup table from a live aggregation, and it is the question most candidates never ask.
Personalised to the user?No, apart from their own recent searches, which the client can hold.The results are shared, so they cache. Personalisation would multiply the key space by the user count and make the whole precomputed design impossible.
Do we need typo tolerance?Basic. "restrant" should still find restaurants.A second lookup path for fuzzy matches, kept off the main one, because edit distance search and prefix lookup want completely different structures.
Can anything offensive or unsafe be suggested?Never. Suggestions are seen as endorsements.A filter applied at build time and again at serve time, plus a manual block list. This is a real requirement with legal consequences and it belongs in the design, not in a postscript.

What you are building, and what you cut

In scope
  • Return the top five completions for a prefix. Ranked by popularity, in under 50ms.
  • Collect what people actually search for. The suggestions are made of yesterday's queries.
  • Rebuild the suggestion index regularly. Hourly for the long tail, minutes for what is trending.
  • Filter unsafe and blocked terms. At build time and again at serve time.
The numbers you commit to
  • 100,000 prefix lookups a second at peak.
  • Under 50ms at p99, measured at the device.
  • Suggestions may be hours stale, but must never be wrong or unsafe.
  • A rebuild must never take the serving path down.
Cut, and say so out loud
  • The search itself. Autocomplete suggests queries, it does not run them, and that is a separate page.
  • Full personalisation. Worth naming as a client side merge of the user's own history with the global list.
  • Multi language tokenisation beyond the basics. Real, deep, and its own specialism.
  • Spell correction of the final query. Different problem, later in the funnel.

Back of the envelope

Request rate and index size
20 thousand
6
100 million
Prefix lookups per second20k x 6 x 0.7 debounce = 84,000
Lookups per actual search4.2 to 1
Distinct prefixes worth keeping100M terms, deduped = 6 million
Precomputed top 5 per prefix6M x 5 x 40B = 1 GB
Budget per lookupabout 50ms, including the network
84,000 lookups/sec
for 20,000 actual searches, which is the whole reason this is hard

The multiplier is the point. Every search costs you 4.2 autocomplete requests, so this endpoint is busier than search itself by almost an order of magnitude. And 1GB of precomputed answers fits in memory across a modest fleet, which is what makes the precomputation approach viable rather than clever.

The number people get wrong

Almost everyone computes the search rate and designs for that. The autocomplete rate is five to ten times higher, because every keystroke past the second one is a request. Before anything else, debounce on the client: wait 50 to 100ms after the last keystroke and cancel the in flight request. That single client side change removes about a third of your traffic and costs nothing, and mentioning it early tells an interviewer you have shipped one of these.

Step 2: Propose the high level design

The API

One endpoint, and every design decision is in the headers.

GET/v1/suggest?q=distr&limit=5&lang=en
returns 200 { suggestions: ["distributed systems", "distributed lock", ...], v: 41207 }
Why: A GET with a short cache header, because the answer for a given prefix is the same for everybody. That single property is what lets a CDN and a browser cache absorb a large share of the traffic before it reaches you. The version number lets a client discard a late response that arrived after a newer one.
GET/v1/suggest?q=restrant&fuzzy=true
returns 200 { suggestions: [...], corrected: "restaurant" }
Why: Fuzzy matching is a separate flag and a separate path, because it is an order of magnitude more expensive. Try the exact prefix first, fall back to fuzzy only when it returns nothing, and never make the common case pay for the rare one.
POST/v1/events/search
{ "query": "distributed systems", "at": "...", "lang": "en" }
returns 202
Why: The system is fed by its own output. Logging what people actually searched, rather than what they clicked in the suggestion list, is what stops the index from getting stuck recommending only what it already recommends.

The data model

The build side and the serve side store completely different things, which is the whole trick.

suggestion indextrie in memory, one shard per prefix range
prefixstringPKEvery prefix of every kept term, up to about 20 characters. "dis", "dist", "distr" are three separate entries.
top_karray of 5The answer, precomputed. Stored at the node, so a lookup is a walk to the node and a read, with no traversal of the subtree below it.
scoresarray of 5Kept alongside so a merge across shards can rank correctly without recomputing.
blockedbooleanSafety filter applied at build time. Checked again at serve time, because a term can be blocked between builds.
versionintThe build this node came from. Whole builds are swapped in atomically, never patched in place.
Sample row
"distr" | ["distributed systems", "distributed lock", ...] | [88102, 41200, ...] | false | 41207
Storing the answer at every node is the entire design decision. The alternative is walking the subtree at query time to find the best five, which is correct, slower, and does more work on every one of a hundred thousand requests a second than it does on one hourly build.

The whole system on one whiteboard

Figure 1. Two loops that never block each other. The bottom loop turns yesterday's searches into a new index, once an hour. The top loop answers a hundred thousand lookups a second out of memory and never computes anything.

Walking Figure 1:

  1. The client debounces, then asks. A large share of these never reach you at all, because the answer for a prefix is identical for every user and caches beautifully.
  2. The gateway routes by prefix to the shard holding that part of the trie, walks to the node, and reads the five strings sitting there. No ranking, no subtree traversal, no sorting. This is the entire hot path and it is a memory read.
  3. Separately, the client logs what was actually searched.
  4. Those events flow into an aggregator.
  5. Which maintains counts per term over a rolling window.
  6. An hourly build reads the counts and constructs new trie shards from scratch.
  7. Finished shards are swapped in whole. A serving node either has build 41207 or build 41208, never half of each, which makes a bad build a one line rollback.

Two boxes beside the main path are the exceptions that make it usable. A fuzzy index, consulted only when the exact prefix returns nothing. And a safety filter that can block a term between builds, without waiting an hour for the next one.

Your answer

Step 2 reads five strings sitting at the node. The obvious alternative is to walk the subtree under the prefix and rank what you find. What does storing the answer at every node cost you, and why is it still the right call?

Step 3: Design deep dive

Why the answer lives at the node

A trie gives you the prefix walk for free. What it does not give you is the ranking, and the difference between the two implementations is the difference between fifty milliseconds and five.

1/4 Five pointer hops, one per character. This part is the same in both designs and it costs almost nothing: five memory dereferences on a structure that is entirely in cache for popular prefixes.
Figure 2. Both paths return the same five suggestions. One does the work once an hour on a build machine, the other does it a hundred thousand times a second on a serving machine.

The general principle worth stating out loud, because it transfers to half the other questions in this course: when reads outnumber writes by orders of magnitude, move the work to the write side. Here the write side is an hourly batch job, which is the most comfortable place work can possibly live.

The follow up you will get

“How much memory does that cost?” Five strings of about 40 bytes at a few million nodes is single digit gigabytes, before compression. Then say the two optimisations: store references into a string table rather than the strings themselves, since “distributed systems” appears at every one of its prefixes, and stop storing top-k below a depth where the subtree is small enough to walk. Both are real, both are what a production implementation does, and knowing them separates reading about tries from building one.

Rebuilding without going down

The index is immutable and gets replaced wholesale, which sounds heavy until you compare it to the alternative of mutating a live trie while a hundred thousand requests a second walk it.

The build runs on its own machines: read the term counts, filter blocked and unsafe terms, construct the shards, verify them against a set of known queries, and publish. A serving node downloads the new shard, builds it in memory alongside the current one, and flips a pointer. Memory usage doubles briefly, which is the price, and the swap is atomic, which is the point.

Two things this buys you that are worth naming. A bad build is rolled back by pointing at the previous version, which takes seconds and needs no code change. And because nothing is ever mutated, a serving node needs no locks at all on the read path, so a lookup is genuinely just a memory walk.

The fast path for trending terms is the exception, and it should be built as an exception rather than by making the whole pipeline faster. A small separate structure holds terms that spiked in the last ten minutes, the gateway merges it into the results, and it is allowed to be approximate. When a plane goes down, people search for it within seconds and an hourly build is useless, but that is a few hundred terms, not a few million.

Typos, and why they get their own path

Prefix lookup and fuzzy matching want different structures. A trie answers “what follows this prefix” in five hops and answers “what is one edit away from this” not at all.

Typo tolerance
Generate every string within edit distance one, which is about 50 variants for an eight character query, and look each one up in the trie you already have. No new index, and it works. The cost is 50 lookups instead of one, which is why it runs only when the exact prefix returned nothing.

Pick the deletion index if you can afford the build, and say why: eight lookups on the query side instead of fifty, paid for with offline storage. Then add the important qualifier, which is that none of this runs on the common path. Exact prefix first, always, and fuzzy only on an empty result.

Break it

Prefix traffic
100k/s
100k/s400k/sone letterrebuildrepaired
Healthy. Design load. A large share is answered at the CDN because prefixes are shared across users, and what gets through is a memory walk. Nothing is warm, let alone hot.

The repair is worth remembering as a pattern. When the hot keys are also the small keys, replicate them everywhere instead of trying to spread them.

Trade-offs

ChoiceWhat you gainWhat you payPick it when
Precomputed top-k at each nodeA lookup is a prefix walk and an array read, with no ranking on the request path.Several gigabytes of memory, and suggestions only change when you rebuild.Whenever reads outnumber index updates by orders of magnitude, which for autocomplete is always.
Immutable index, swapped wholeNo locks on the read path, and a rollback is pointing at the previous version.Memory doubles during a swap, and the smallest possible change costs a full build.Any index that is rebuilt on a schedule rather than updated continuously.
Separate trending pathBreaking terms appear in minutes without making the hourly pipeline run every minute.Two sources merged at serve time, and the merge can produce odd rankings for a while.Any domain where an event can make a term go from unknown to the most searched thing in an hour.
Fuzzy only on empty resultsThe common path never pays for typo tolerance.A prefix that legitimately has few results gets a slower response than one that has none.Always. Making every request pay for the rare case is the most common way this design gets slow.

Interview replay

Interviewer
Someone types five characters into a search box. What happens?
Opening. They want to see whether you treat this as a search problem.
You
The client debounces, so we probably see three requests rather than five. Each one routes to the shard owning that prefix range, walks the trie five nodes deep, and reads the five suggestions stored at that node. There is no ranking at request time, because the answer was computed by an hourly batch job and has not changed since. The entire hot path is a memory walk, which is what gets us inside the fifty millisecond budget.
Says "no ranking at request time" in the first answer. That is the whole design and most candidates get there ten minutes later.
Interviewer
Why store the answer at every node? Is that not a lot of duplication?
Testing whether the choice was deliberate or copied.
You
It is, and it is worth it. Walking the subtree under a common prefix means visiting tens of thousands of terms and ranking them on every request, to produce a result that changes once an hour. Precomputing costs a few gigabytes of memory, which I would reduce by storing references into a shared string table rather than the strings themselves, since a term is duplicated at every one of its prefixes.
Names the cost, accepts it, then gives the optimisation that halves it. That last part is what sounds like implementation experience.
Interviewer
A news event happens. People search for a name that is not in your index at all.
The freshness question. Most designs have no answer.
You
The hourly build cannot help, so I would not try to make it faster. I would add a small separate structure holding terms that spiked in the last ten minutes, computed from the same event log with a streaming job, and merge it into results at serve time. It is a few hundred terms, it is allowed to be approximate, and it means the main pipeline stays a comfortable batch job instead of becoming a real time system.
Solves the exception as an exception. Rebuilding the whole pipeline for a rare case is the wrong instinct and they are checking for it.
Interviewer
One shard is at 100% CPU and the rest are idle.
The hot partition question, in its geospatial-free form.
You
Prefix range sharding puts all the traffic for one letter on one shard, and if something makes that letter popular you get exactly this. The fix I like is that short prefixes are both the hottest and the smallest, so I would replicate the first two levels of the trie onto every node. The hot data is a few megabytes and it stops being a partitioning problem at all.
Identifies the shape, then gives a fix that exploits a property of this specific data rather than a generic "add caching".
Interviewer
How would you decide how many suggestions to show?
Open ended, and not a technical question at all.
You
That is a product question and I would answer it with an experiment rather than an opinion. The metric I would watch is not click through rate on suggestions, it is whether the searches people ultimately run get better, because a long suggestion list can just be teaching people to pick from a list instead of typing what they wanted. I do not have data on that, so I would treat any number I gave you now as a guess.
Refuses to invent a number, and reframes the metric. Naming the wrong metric that everyone optimises is a memorable answer.

Checkpoint

Checkpoint

1. Why is the autocomplete request rate several times higher than the search rate?

2. What does storing the top five at every trie node actually buy you?

3. Same design, but suggestions must now be personalised per user. What breaks first?

Worth memorising
  • Six keystrokes per search means autocomplete runs 3 to 4 times hotter than search itself.
  • 50ms at p99 including the network. That budget is why nothing is ranked at request time.
  • Top five stored at every trie node. A lookup is five pointer hops and an array read.
  • Hourly builds, plus a ten minute trending path for the handful of terms that cannot wait an hour.
Say this in 60 seconds

Autocomplete looks like search and is really a precomputation problem. Every keystroke past the first is a request, so this endpoint runs several times hotter than search itself, and the budget is about fifty milliseconds including the network. So nothing is computed at request time. An hourly batch job reads a log of what people actually searched, counts terms over a rolling window, and builds a trie where the top five completions are stored at every node. A lookup is a five hop walk and an array read, out of memory, with no locks because the index is immutable and swapped in whole. Responses are identical for every user, so a CDN absorbs a large share of the traffic before it reaches us. Two exceptions sit beside the main path: a small trending structure for terms that spiked in the last ten minutes, since an hourly build is useless when news breaks, and a fuzzy lookup that runs only when the exact prefix returns nothing. The failure mode I would call out is that prefix sharding puts one popular letter on one shard, and the fix is to replicate the short prefixes everywhere, because the hottest data here is also the smallest.

IndGeek provides solutions in the software field, and is a hub for ultimate Tech Knowledge.