Start typing "distr" into a search box and a list drops down: distributed systems,
distributed lock, and a few more. That list is autocomplete (also called typeahead).
It looks small. But every letter you type can be a new request, and each answer has to come
back before your finger reaches the next key. Nobody clicks a suggestion that shows up after
they finished typing.
Watch one keystroke go through the system, and then watch how the suggestions get made.
Ask. You pause after typing "distr", so the client asks for suggestions. Everyone typing "distr" gets the same five, so the CDN near you often has the answer already.
That gives you about fifty milliseconds per answer, and far more requests than there are
searches. The surprise is that this is not a search problem at all. It is a
precomputation problem: work out the answers in advance, so that answering is only a
lookup. Once you see that, the whole design falls out.
A few words come up all through this page:
A prefix is the start of a word. "dis", "dist" and "distr" are all prefixes of
"distributed".
Top five (written top-k in general) means the five most searched terms that start with
a prefix.
A trie (said "try") is a tree of letters. Each step down adds one letter, so the path
d, i, s, t, r leads to the node for "distr". You will build one by hand in the deep dive.
p99 means 99 out of 100 requests are at least this fast. "50ms at p99" allows only one
request in a hundred to be slower than 50ms.
The real Google autocomplete
This page builds the suggestions that appear under a search box as you type, like Google's autocomplete. The numbers and choices below are interview assumptions, not Google's own. The safety filter is one part that matches the real thing: Google publishes rules for what autocomplete may suggest, and removes predictions that break them.
The trap here is designing a search engine. Ask enough questions to establish that you
are not. Six questions do it, and the third one is the one most people forget.
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.
Three inputs. The first row is the one that surprises people. (New to this kind of
estimate? The back of the envelope chapter walks through
the method.)
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. Debouncing means waiting for a short
pause before acting, the way a lift waits a moment before closing in case someone else
is coming. Wait 50 to 100ms after the last keystroke, then ask, and cancel the request
still in flight from the previous letter. 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.
One endpoint does the real work, and every design decision is in the headers. Two words
first. A CDN is a network of cache servers spread across cities, so an answer can come
from a machine near you instead of from the data centre. A TTL (time to live) is how
long a cached answer may be reused before it must be fetched again. Both are covered in
caching patterns.
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.
The build side and the serve side store completely different things, which is the whole
trick. The serve side is a trie held in memory. Here is a small piece of one:
code
root
└ d
└ i
└ s ── top 5 here: distributed systems, discord, disney plus, ...
└ t
└ r ── top 5 here: distributed systems, distributed lock, district map
Every node stores its own short list of best answers. That list is the column called
top_k below. The trie is split into shards (slices kept on different machines) by
prefix range, so all prefixes starting with "d" live together. The
sharding chapter compares range sharding
with the other options.
suggestion indextrie in memory, one shard per prefix range
prefix
string
PK
Every prefix of every kept term, up to about 20 characters. "dis", "dist", "distr" are three separate entries.
top_k
array of 5
The 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.
scores
array of 5
Kept alongside so a merge across shards can rank correctly without recomputing.
blocked
boolean
Safety filter applied at build time. Checked again at serve time, because a term can be blocked between builds.
version
int
The build this node came from. Whole builds are swapped in atomically, never patched in place.
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
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.
Figure 1 is the picture at the top of the page, with the two exceptions added. Walking it:
The client debounces, then asks. A large share of these never reach you at all, because
the answer for a prefix is the same for every user, so the CDN can keep it. (The
what happens when you type a URL
article shows how much time a nearby cache saves.)
The gateway is stateless: it keeps no data of its own, so you can add more copies at
any time. It sends the prefix to the shard holding that part of the trie, which walks to
the node and reads the five strings sitting there. No ranking, no searching, no sorting.
This is the entire busy path, and it is a memory read.
Separately, when someone actually searches, the client logs what they searched.
Those events flow into an aggregator. The log is an event stream: an append only list
that several readers can each go through at their own pace. See
queue vs pub/sub vs event stream.
The aggregator keeps a count per term over a rolling window, like "last seven days".
An hourly build reads the counts and constructs new trie shards from scratch.
Finished shards are swapped in whole. A serving node has either 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,
for typos, 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.
Notice what the design accepts: suggestions can be up to an hour old. That is eventual
consistency, where readers may see slightly old data for a while, and it is a fine trade
here because nobody is hurt by a suggestion that is an hour stale.
Strong vs eventual consistency explains when it
is not fine.
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?
This is the question the whole design turns on, so take it in three steps.
How do you find the top five completions for a prefix, 100,000 times a second?
1
First try
Ask the database every time
Keep every past search in a table. For "distr", find the rows starting with "distr", count them per term, sort by count, and return the first five.
Where it breaks: An index can find the matching rows quickly, but the counting and sorting still happen on every keystroke, over every past search that starts with those letters. Do that 100,000 times a second inside 50ms and the database is the bottleneck on day one.
2
Better
A trie, ranked at request time
Load the distinct terms with their counts into a trie in memory. Walk to the node for "distr", then visit every term below it and keep the best five.
Where it breaks: The walk is cheap. The ranking is not: under a common prefix there are tens of thousands of terms, and you score all of them on every request to produce an answer that has not changed since the last build.
3
What you ship
A trie with the top five stored at every node
Do the ranking once, at build time, and save the answer at each node. A request walks to the node and reads five strings. The work moved from 100,000 times a second to once an hour.
Try it. Type a prefix and watch the walk, then compare what is stored with what would
otherwise have to be ranked.
Walk a trie
The walk
rootddidisdis
Stored at the node
1. distributed systems900
2. discord820
3. disney plus760
4. distributed lock410
5. dish tv300
Would have to rank
7
terms under this node, scored and sorted per request
3 hops, one per letter, then read the 5 answers stored at "dis". Without them stored, this request would have to score all 7 terms under "dis" and sort them. Under a real common prefix that is tens of thousands, on every keystroke.
Ten terms with made up counts, to keep it readable. A real index holds millions of terms.
Figure 2 steps through both paths on a real sized prefix. The difference between them 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.
Every hour there is a new index. Getting it onto the serving machines without a blip is the
second deep dive.
How does a new hour of search data reach the serving machines?
1
First try
Update the live trie as searches come in
Each new search bumps a count, and the top five at every prefix of that term is recomputed in place.
Where it breaks: A hundred thousand reads a second are walking the same structure while it changes. You need locks on the read path, a half applied update can serve a wrong list, and a bad update has no clean undo.
2
Better
Rebuild hourly, restart the servers on the new file
Build the trie offline from the counts. Stop each server, load the new trie, start it again.
Where it breaks: Every server is down while it loads, and comes back with cold memory. Do that every hour across the fleet and you are always restarting something.
3
What you ship
Build offline, load beside the old one, flip a pointer
The server loads the new build into memory next to the current one, then switches one pointer. Readers move to the new build at once, and the old one is dropped. Rolling back is pointing at the old build again.
The index is immutable: once built it is never changed, only replaced. That sounds
heavy until you compare it to changing a live trie while a hundred thousand requests a
second walk it. If the pointer flip feels familiar, it is the same idea as a blue green
deployment, covered in blue green vs canary vs rolling.
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.
Someone types "restrant". No term starts with that, so the trie walk ends with nothing.
The fix needs edit distance: how many single letter changes (add, remove, swap one
letter) turn one word into another. "restrant" is one edit from "restaurant", one missing
letter.
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. Three
ways to add it, from simplest to strongest:
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 "one letter" level is a hot partition: one shard gets most of the traffic because
the data it owns is suddenly popular. The repair is worth remembering as a pattern. When
the hot keys are also the small keys, copy them to every machine instead of trying to
spread them. (The sharding chapter has the general version.)
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.