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.
Search autocomplete / at a glance
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.
The trap here is designing a search engine. Ask enough questions to establish that you
are not.
You ask
They say
What 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.
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.
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.
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.
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.
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.
often ends hereGET /suggest?q=distr1walk to node2only if emptyblock late additionswhat they searched34last 10 minutescounts56swap in whole shards7ClientdebouncedCDNshort TTL, sharedSuggest gatewaystatelessTrie shardsin memory, by prefixFuzzy indexedit distance 1Safety filterblock listSearch event logwhat people typedTrending pathminutes, not hoursAggregatorcounts per termTerm storeterm to weekly countIndex builderhourly, immutable
often ends hereGET /suggest?q=distr1walk to node2only if emptyblock late additionswhat they searched34last 10 minutescounts56swap in whole shards7ClientdebouncedCDNshort TTL, sharedSuggest gatewaystatelessTrie shardsin memory, by prefixFuzzy indexedit distance 1Safety filterblock listSearch event logwhat people typedTrending pathminutes, not hoursAggregatorcounts per termTerm storeterm to weekly countIndex builderhourly, immutable
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:
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.
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.
Separately, the client logs what was actually searched.
Those events flow into an aggregator.
Which maintains counts per term over a rolling window.
An hourly build reads the counts and constructs new trie shards from scratch.
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?
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.
one char at a time"distr"five charactersWalk the trie5 pointer hopsNode "distr"top 5 stored hereSubtree below~40,000 termsFive strings
one char at a time"distr"five charactersWalk the trie5 pointer hopsNode "distr"top 5 stored hereSubtree below~40,000 termsFive strings
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.
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.
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
1lookup each23"restrant"Generate edits~50 per querySame trieBest match
1lookup each23"restrant"Generate edits~50 per querySame trieBest match
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.
on a misswalkClientsCDNGatewayTrie shardsby prefix
on a misswalkClientsCDNGatewayTrie shardsby prefix
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.
Read every step as text
100k/s · 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.
400k/s · Degraded. The gateway saturates first, on request parsing and TLS rather than on anything to do with tries. This is the good bottleneck: it holds no state, so you add instances.
one letter · Down. A new product launches and everyone types the same first letter. Every request for prefixes starting with that letter lands on one shard, which is at 100% CPU while the others idle. Sharding by prefix range has the same hot partition problem as every other range sharding scheme.
rebuild · Degraded. A new build lands. Memory usage on each serving node roughly doubles while both versions are resident, and if you sized the machines with no headroom, the swap is when you find out.
repaired · Healthy. Replicate every shard rather than only partitioning them. Short prefixes are the hottest and the smallest, so keeping the first two levels of the trie on every node costs almost nothing and removes the hot shard entirely. Stagger rebuilds across the fleet so no more than a fraction is doubling memory at once.
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.
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.
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.