Library
32 min read

Design Google search autocomplete

CoreSearchCachingUpdated

Read firstCaching patterns

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.

ClientCDNSuggest gatewayTrie shardsSafety filterSearch logAggregatorTerm storeIndex builder

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.

Step 1: Understand the problem#

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 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#

Functional requirements
What the system does.
  • 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.
Non-functional requirements
How well it has to do it. 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.
Out of scope
What you 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#

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.

Step 2: Propose the high level design#

The API#

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.

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

Figure 1 is the picture at the top of the page, with the two exceptions added. Walking it:

  1. 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.)
  2. 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.
  3. Separately, when someone actually searches, the client logs what they searched.
  4. 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.
  5. The aggregator keeps a count per term over a rolling window, like "last seven days".
  6. An hourly build reads the counts and constructs new trie shards from scratch.
  7. 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?

Step 3: Design deep dive#

Why the answer lives at the node#

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. 1. distributed systems900
  2. 2. discord820
  3. 3. disney plus760
  4. 4. distributed lock410
  5. 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.

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#

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.

Typos, and why they get their own path#

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
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.
Read every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.)

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.