Design Autocomplete
Design Autocomplete for a system design interview: top 5 suggestions per keystroke in about 100 milliseconds at 5,000,000,000 searches a day.
Problem statement
The prompt is short: design autocomplete. A search box shows completions while a person is still typing, before any search has run, and the service behind the box has to supply those completions for every keystroke. The completions are called search suggestions: the terms the box offers for the text typed so far, drawn from what people have searched before.
The scale is fixed by the prompt and it is large. The service handles 5,000,000,000 searches a day, which is 57,870 searches per second, and typing multiplies that load because each new letter sends a fresh request. The answer is also tightly bounded in two directions: the box shows the top 5 suggestions for the typed prefix, and the whole answer must come back in about 100 milliseconds, or the suggestions arrive after the typist has already moved on.
Two properties make the problem hard rather than merely large. The first is the shape of the traffic: requests are tiny, answers are tiny, but they arrive at 57,870 searches per second and every one of them is impatient. The second is that popularity changes: the counts that rank suggestions shift as new searches land all day, so an answer that was right an hour ago can quietly become the wrong order, or miss a rising term altogether.
In scope
- Returning the top 5 suggestions for the typed prefix, ranked by how often each term is searched
- One fresh answer per keystroke, inside a budget of about 100 milliseconds
- Building the ranking from a log of finished searches, counted into term counts
- Keeping suggestions reasonably fresh as new searches arrive through the day
- Serving every language from counts and terms of that language
Out of scope
- Running the search itself and showing the results page; the box only suggests terms
- Personalised suggestions drawn from one person's own history; one stored list serves every user for a prefix
- Spelling correction and query rewriting beyond offering completions
- Ranking the results of a finished search
The scope line matters because autocomplete is an assistant to search, not search itself. A suggestion service that is down must never take the search box down with it, and a suggestion list that is slightly stale is a far smaller problem than a search that never runs. Those two facts shape the failure behaviour and the freshness choices that follow.
Requirements
The requirements are the contract the rest of the design is judged against. A prefix is the starting letters of a word, or the text typed so far in the box, and almost every requirement is stated in terms of prefixes and their suggestion lists. Each functional item names a behaviour with its consequence, and each non-functional item names a bar that behaviour has to clear.
Functional requirements
- Answer a prefix: given the text typed so far, return the top 5 suggestions for that prefix, ranked by past search count, so the most likely completions are the ones on screen
- Answer every keystroke: each new letter asks for a fresh answer, so a person typing a 5 letter word sends 5 requests and the list must keep up with the typing
- Rank by popularity: terms are ordered by how often each term is searched, because the box is a popularity contest among the terms sharing a prefix
- Count finished searches: every completed search is recorded in a search log, because the counts that rank tomorrow's suggestions come from today's searches
- Refresh the lists: stored suggestions are rebuilt from the newest counts on a regular cycle, so rising terms eventually appear and fading terms eventually leave
- Serve each language separately: a prefix in one language is answered from that language's terms and counts, because the same letters mean different completions in different languages
Non-functional requirements
- Speed: the whole answer comes back in about 100 milliseconds, because a slower answer is overtaken by the next keystroke and wasted
- Throughput: the service sustains 57,870 searches per second, and the per-keystroke traffic that sits behind those searches, without the suggestion path becoming the bottleneck of the search product
- Small answers: each answer holds at most 5 suggestions, so responses stay tiny and cheap to send at very high rates
- Bounded staleness: suggestions may lag real searches by up to one rebuild cycle, and no longer, so a new term has a known worst-case wait before it can appear
- Equal answers: the stored list for a prefix is the same for every user, which keeps serving a plain lookup and is also the stated limit on personalisation
- Graceful failure: if suggestions fail, typing and searching carry on without them, because the assistant must never break the tool it assists
The ranking rule deserves one precise statement before any structure is chosen. Terms carry a count of past searches, and the box ranks the matches for the typed text by that count, highest first. Only the first few matches are shown, even when more terms match, and the demo below shows the shape exactly: 6 terms match the prefix in the demo data, the counts order them, and the answer holds the top 5 while the lowest-count match is left out.
What the box shows
terms = [
("photo", 900),
("photos", 800),
("photo editor", 700),
("photography", 500),
("photo print", 300),
("photo book", 200),
("pizza", 950),
]
prefix = "pho"
matches = [pair for pair in terms if pair[0].startswith(prefix)]
matches.sort(key=lambda pair: -pair[1])
for term, count in matches[:5]:
print(term, count)
print("suggestions shown:", len(matches[:5]))That small demo fixes the meaning of a correct answer for the whole design. An answer is not every term sharing the prefix, and it is not the terms in alphabetical order; it is the 5 highest-count matches, in count order. Every structure considered later โ the trie, the count table and the stored lists โ exists to produce exactly that answer, fast enough and fresh enough.
Scale estimates
The estimates start from the figures the prompt itself fixes, and every later number is derived from those inputs in the open. The stated inputs are 5,000,000,000 searches a day, the top 5 suggestions per answer, and a budget of about 100 milliseconds per answer. One day holds 86,400 seconds, and that constant converts the daily count into the rate the serving fleet actually feels.
The scale of the problem
| Quantity | Value | How derived |
|---|---|---|
| Searches a day | 5,000,000,000 | Stated in the prompt |
| Seconds in a day | 86,400 | Stated constant |
| Searches per second | 57,870 | 5,000,000,000 รท 86,400 |
| Suggestions per answer | 5 | Stated in the prompt: the top 5 |
| Answer budget | About 100 milliseconds | Stated in the prompt |
| Requests for one 5 letter word | 5 | One request per letter typed |
| Requests for 2 example searches | 10 | 5 letters + 5 letters |
The request multiplication comes first, because it is the easiest load to underestimate. Typing the word photo sends one request per letter, 5 requests in all, one for each growing prefix. The printout shows the text growing letter by letter, and the count at the end is the load multiplier hiding inside a single search.
Each keystroke sends a request
typed = "photo"
prefix = ""
for letter in typed:
prefix = prefix + letter
print("typed so far:", prefix)
print("requests sent:", len(typed))The daily count becomes a rate by a single division. A daily count becomes a per-second rate by dividing by the seconds in a day, and the printout shows both numbers for the service: the stated daily total and the rate that falls out of it. That rate, 57,870 searches per second, is the average the fleet must sustain before any peak or any per-keystroke multiplication is considered.
The daily count as a rate
searches_per_day = 5000000000
seconds_per_day = 86400
per_second = round(searches_per_day / seconds_per_day)
print("searches per day:", searches_per_day)
print("searches per second:", per_second)The arithmetic itself is short enough to state in one line: 5,000,000,000 รท 86,400 = 57,870 searches per second. The average hides the peaks of a real day, and it also hides the keystroke effect, because the finished searches counted in the daily total each began as a short stream of prefix requests. Both effects push the true request rate upward from the average, which is why the serving path is designed to be a single cheap lookup rather than any kind of search through the term list.
The closing model of the ask combines the two effects on a small stage. Two typed words send one request per letter, each finished word is answered from the stored counts, and the two searches take 10 requests in all. Each answer holds the top suggestions for its word, and the word with only one matching term simply returns a shorter list.
Design Autocomplete: The Ask
terms = [
("photo", 900),
("photos", 800),
("photo editor", 700),
("photography", 500),
("photo print", 300),
("photo book", 200),
("pizza", 950),
]
def suggest(prefix):
matches = [pair for pair in terms if pair[0].startswith(prefix)]
matches.sort(key=lambda pair: -pair[1])
return [term for term, count in matches[:5]]
total_requests = 0
for word in ["photo", "pizza"]:
prefix = ""
for letter in word:
prefix = prefix + letter
total_requests = total_requests + 1
print(word, "->", suggest(word))
print("total requests:", total_requests)Three conclusions follow from the estimates and drive everything after them. First, the answer must be produced by lookup, not by computation, because 57,870 searches per second leave no room for scanning or sorting inside a budget of about 100 milliseconds. Second, the ranking work has to happen somewhere else and at some other time, because the counts behind the ranking change constantly while the budget never does. Third, the stored answer for a prefix can be very small โ 5 short terms โ so keeping ready answers for popular prefixes is cheap in space even when the prefix set is large.
API design
A small set of operations carries the whole design, and only one of them faces the typing user. The suggestion read is synchronous: the box is waiting, the budget is about 100 milliseconds, and nothing may queue in front of it. Everything else โ recording a finished search, counting it, rebuilding the stored lists โ is asynchronous work that travels through a queue, because none of it is allowed to slow down either the search itself or the next keystroke.
Core operations
| Operation | Request | Response | Notes |
|---|---|---|---|
| Get suggestions | GET /suggest with the typed prefix and the language | The top 5 suggestions for the prefix, in count order | The hot path; answered from one stored list on one shard |
| Record a finished search | POST /searches with the searched term and the language | Confirmation | Appends to the search log; the counting happens later, off the request path |
| Rebuild stored lists | Internal trigger on the rebuild cycle | Fresh stored lists published to the shards | Runs on the cycle, not per request; swaps a whole build in at once |
The split between the synchronous read and the queued work is the point of the shapes. A suggestion read does exactly one thing: it takes the prefix, finds the shard and the stored list for that prefix, and returns the list. Recording a search does the opposite: it accepts the term, appends it to the search log durably, and returns, leaving the counting to workers that drain the log between rebuilds.
Two details of the read operation repay care. An empty or very short prefix still gets an answer โ the stored list for that prefix, which may hold the most popular terms overall โ because the box must never flicker into an error state while someone types. A prefix with no matching terms returns an empty list rather than an error, for the same reason: no suggestions is a normal answer, and the box simply shows nothing.
Data model
Four stored shapes carry the design, and each exists because one flow needs it. The search log is the raw record of finished searches, in the order they happened. The count table holds the number of past searches for each term, counted from the log, and the trie and the stored suggestion lists are both built from those counts.
Core entities
| Entity | Key fields | Notes |
|---|---|---|
| Search log entry | Term, language, time | One entry per finished search; append-only, and the source every count is built from |
| Term count | Term, language | The number of past searches for the term; the ranking key for every suggestion list |
| Trie node | Letter, child nodes | One node per letter along a term; the end node of a term also holds its count |
| Stored suggestion list | Prefix, language | The top 5 terms for the prefix, kept as a single ready answer |
| Shard | First-letter range | One machine holding the tries and stored lists for its range of first letters |
The stored suggestion list is the deliberate denormalisation at the heart of the model. Denormalisation means storing data in the shape a read needs, copied and rearranged, instead of the single shape a write prefers. A stored list copies the top 5 terms for a prefix into one small record, so a read fetches one record and checks nothing else, and the price is that the copies have to be rebuilt whenever the counts move enough to matter.
The partition choice follows from the read shape. The shard key โ the value that decides which machine holds a piece of data โ is the first letter of the typed prefix, because a lookup always knows its first letter before it knows anything else. Every prefix therefore lives on exactly one shard, a lookup visits exactly one machine, and no read ever has to fan out across the fleet and merge partial answers inside the budget.
High-level architecture
Requests enter through a load balancer, which spreads them across a fleet of suggestion servers, and each server routes a request by the first letter of the prefix to the shard that owns that letter range. A shard is one machine holding the tries and stored lists for its range of first letters, with the data held ready to serve from memory. Behind the serving path, the search log feeds a queue of counting jobs, workers fold new searches into the counts, and a builder rebuilds the stored lists on a cycle and publishes them to the shards.
โโโโโโโโโโโ prefix per keystroke โโโโโโโโโโโโโโโโโ
โ Search โ โโโโโโโโโโโโโโโโโโโโโโถ โ Load balancer โ
โ box โ โโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโ
โโโโโโโโโโโ top 5 suggestions โ
โ โผ
โ finished search โโโโโโโโโโโโโโโโโ
โผ โ Suggestion โ
โโโโโโโโโโโ โ servers โ
โ Search โ โโโโโโโโโโโโโโโโโ
โ log โ โ first letter
โโโโโโโโโโโ โผ
โ โโโโโโโโโโ โโโโโโโโโโ โโโโโโโโโโ
โผ โ Shard โ โ Shard โ โ Shard โ
โโโโโโโโโโโ โ a-i โ โ j-r โ โ s-z โ
โ Queue + โ โโโโโโโโโโ โโโโโโโโโโ โโโโโโโโโโ
โ countingโ โฒ โฒ โฒ
โ workers โ โโโโโโโโโโโโดโโโโโโโโโโโ
โโโโโโโโโโโ fresh lists, hourly rebuildThe suggestion flow
- The box sends the typed prefix and the language with each keystroke
- The load balancer passes the request to a suggestion server
- The server reads the first letter and routes the request to the one shard owning that letter range
- The shard reads the single stored list for the prefix and returns it
- The box shows the top 5 suggestions from the returned list
The counting and rebuild flow
- Each finished search is appended to the search log and placed on the counting queue
- Workers drain the queue and fold each search into the pending counts
- On the rebuild cycle, the builder adds pending counts to the stored counts and rebuilds the stored lists
- The fresh lists are published to the shards, replacing the previous build in one swap
Scaling the serving side means splitting the stored tries across machines, so no single machine holds the whole alphabet. The trie is sharded by first letter, and each lookup visits one shard, which keeps the read a single-machine operation however large the term set grows. The cost is visible immediately in a small model: the demo deals a fixed term list into 3 shards by first letter, and the shards hold 5, 7 and 2 terms, so first letters do not split the store evenly.
Terms land by first letter
terms = ["apple", "apricot", "banana", "cherry", "grape", "kiwi", "lemon", "mango", "peach", "pear", "plum", "quince", "strawberry", "watermelon"]
ranges = [("a-i", "a", "i"), ("j-r", "j", "r"), ("s-z", "s", "z")]
def shard_for(letter):
for name, low, high in ranges:
if low <= letter <= high:
return name
shards = {"a-i": [], "j-r": [], "s-z": []}
for term in terms:
shards[shard_for(term[0])].append(term)
for name in ["a-i", "j-r", "s-z"]:
print(name, "holds", len(shards[name]), "terms:", shards[name])The routing rule is what makes the split cheap to use. The typed prefix pe belongs to one shard only, so only that shard is checked, and the printout shows the 7 terms checked there against the 14 terms in the whole store. Halving the checked set is not the real prize at production scale; the prize is that the checked set stays one shard's worth, however many shards the fleet grows to.
A lookup visits one shard
terms = ["apple", "apricot", "banana", "cherry", "grape", "kiwi", "lemon", "mango", "peach", "pear", "plum", "quince", "strawberry", "watermelon"]
ranges = [("a-i", "a", "i"), ("j-r", "j", "r"), ("s-z", "s", "z")]
def shard_for(letter):
for name, low, high in ranges:
if low <= letter <= high:
return name
shards = {"a-i": [], "j-r": [], "s-z": []}
for term in terms:
shards[shard_for(term[0])].append(term)
typed = "pe"
name = shard_for(typed[0])
answer = [term for term in shards[name] if term.startswith(typed)]
print("shard visited:", name)
print("answer:", answer)
print("terms checked:", len(shards[name]))
print("terms in the whole store:", len(terms))Language is the second split, and it is a split of stores rather than of machines. Each language needs its own trie and its own counts, because suggestions differ by language, and the same typed prefix is answered from two language stores with two different answers in the demo. One shared store cannot serve both languages, so language travels with every request and every stored list from the start.
One store per language
stores = {
"english": ["mango", "maps", "market"],
"spanish": ["madrid", "manzana", "mar"],
}
typed = "ma"
for language in ["english", "spanish"]:
answer = [term for term in stores[language] if term.startswith(typed)]
print(language, "answer for " + typed + ":", answer)The routing model closes the scaling picture by running several prefixes end to end. Four typed prefixes are routed by their first letter and answered by their own shard, and each line shows the prefix, the shard it visited and the answer that shard returned. Every functional requirement lands on a component in the diagram: suggestions on the shards and their stored lists, counting on the log, the queue and the workers, freshness on the rebuild, and language separation on the per-language stores.
Autocomplete: Sharding the Trie
terms = ["apple", "apricot", "banana", "cherry", "grape", "kiwi", "lemon", "mango", "peach", "pear", "plum", "quince", "strawberry", "watermelon"]
ranges = [("a-i", "a", "i"), ("j-r", "j", "r"), ("s-z", "s", "z")]
def shard_for(letter):
for name, low, high in ranges:
if low <= letter <= high:
return name
shards = {"a-i": [], "j-r": [], "s-z": []}
for term in terms:
shards[shard_for(term[0])].append(term)
for typed in ["ap", "ki", "pe", "st"]:
name = shard_for(typed[0])
answer = [term for term in shards[name] if term.startswith(typed)]
print(typed, "-> shard", name, "->", answer)Deep dive: finding the words under a prefix โ the trie
The first hard sub-problem is retrieval: given a prefix, find every term that starts with it, quickly enough to leave room in a budget of about 100 milliseconds. A prefix can match a handful of terms or a very large family, and the match set has to be found without looking at terms that start with other letters. Two structures compete for the job, and the difference between them is where the shared beginnings of words are paid for.
Two ways to find the terms under a prefix
| Option | How it works | Strengths | Weaknesses |
|---|---|---|---|
| Flat list scan | Keep every term in one list; for a prefix, check each term in turn and keep the ones that start with it | One simple list to store and rebuild; no structure to maintain | Every lookup pays for every term, including all the terms that start with other letters |
| Trie walk | Store terms one letter per node in a tree; walk one node per typed letter, then collect every term stored under that node | The walk costs one step per typed letter, and terms with other beginnings are never touched | A tree of nodes to build and hold; words are stored as paths rather than as whole strings |
A trie is a tree that stores words one letter per node, so words with the same start share their early nodes. Lookup walks one node per typed letter, then collects every word stored under that node, and the demo builds a trie from a fixed word list and walks to the node for car. Every word collected under that node starts with car, and no word outside that subtree is visited at all.
Words under one prefix
words = ["cat", "car", "card", "care", "dog", "door"]
def build_trie(words):
root = {}
for word in words:
node = root
for letter in word:
if letter not in node:
node[letter] = {}
node = node[letter]
node["end"] = True
return root
def collect(node, prefix):
found = []
if "end" in node:
found.append(prefix)
for letter in sorted(node):
if letter != "end":
found = found + collect(node[letter], prefix + letter)
return found
def walk(trie, prefix):
node = trie
for letter in prefix:
if letter not in node:
return None
node = node[letter]
return node
trie = build_trie(words)
node = walk(trie, "car")
for word in collect(node, "car"):
print(word)Sharing is where the trie earns its keep. A flat list stores every letter of every word, even the letters words share, while the trie stores a shared beginning once and branches only where words diverge. The printout counts the two shapes for the same words: the 6 words in the demo hold 21 letters in a flat list, but the trie stores them in 12 nodes, root included.
Shared starts save nodes
words = ["cat", "car", "card", "care", "dog", "door"]
def build_trie(words):
root = {}
for word in words:
node = root
for letter in word:
if letter not in node:
node[letter] = {}
node = node[letter]
node["end"] = True
return root
def collect(node, prefix):
found = []
if "end" in node:
found.append(prefix)
for letter in sorted(node):
if letter != "end":
found = found + collect(node[letter], prefix + letter)
return found
def walk(trie, prefix):
node = trie
for letter in prefix:
if letter not in node:
return None
node = node[letter]
return node
trie = build_trie(words)
def count_nodes(node):
total = 1
for key in node:
if key != "end":
total = total + count_nodes(node[key])
return total
flat_letters = 0
for word in words:
flat_letters = flat_letters + len(word)
print("letters in a flat list:", flat_letters)
print("nodes in the trie, root included:", count_nodes(trie))The walk also behaves cleanly at the edges, which matters for a box that receives every half-typed fragment. Each typed prefix gets its own short walk down the trie, starting again from the root, and a prefix no word starts with collects an empty list rather than causing an error. An empty answer is a normal answer for the box, and the structure produces it by simply failing to find the next node.
One walk per prefix
words = ["cat", "car", "card", "care", "dog", "door"]
def build_trie(words):
root = {}
for word in words:
node = root
for letter in word:
if letter not in node:
node[letter] = {}
node = node[letter]
node["end"] = True
return root
def collect(node, prefix):
found = []
if "end" in node:
found.append(prefix)
for letter in sorted(node):
if letter != "end":
found = found + collect(node[letter], prefix + letter)
return found
def walk(trie, prefix):
node = trie
for letter in prefix:
if letter not in node:
return None
node = node[letter]
return node
trie = build_trie(words)
for prefix in ["c", "do", "z"]:
node = walk(trie, prefix)
if node is None:
print(prefix, "->", [])
else:
print(prefix, "->", collect(node, prefix))Ranking joins the structure at exactly one point. The trie stores words only, and the ranking counts live at the end node of each word, so collecting the words under a prefix also collects their counts. The closing model stores each word at a node that also holds its search count, ranks the words under pho by those counts, and shows the top 5 in count order, with the lowest-count word left out.
Tries: One Node per Letter
terms = [
("photo", 900),
("photos", 800),
("photo editor", 700),
("photography", 500),
("photo print", 300),
("photo book", 200),
]
def build_trie(terms):
root = {}
for term, count in terms:
node = root
for letter in term:
if letter not in node:
node[letter] = {}
node = node[letter]
node["count"] = count
return root
def collect(node, prefix):
found = []
if "count" in node:
found.append((prefix, node["count"]))
for letter in sorted(node):
if letter != "count":
found = found + collect(node[letter], prefix + letter)
return found
trie = build_trie(terms)
node = trie
for letter in "pho":
node = node[letter]
matches = collect(node, "pho")
matches.sort(key=lambda pair: (-pair[1], pair[0]))
for term, count in matches[:5]:
print(term, count)
print("suggestions shown:", len(matches[:5]))The verdict is the trie. The flat scan charges every lookup for the whole term list, while the trie charges one step per typed letter plus the collection of the subtree the typist actually asked about, and shared prefixes are stored once instead of once per word. The cost is a node structure that must be built, held in memory and rebuilt as counts change โ and that last cost is exactly what the precomputation choice, examined next, turns into an advantage.
Deep dive: serving from the trie or serving precomputed suggestions
Finding the matches is only half the answer, because the matches still have to be ranked, cut to the top 5 and returned, all inside about 100 milliseconds and at 57,870 searches per second. The ranking work is the same work for every keystroke that lands on the same prefix, and popular prefixes receive that same keystroke very many times. The question is when the ranking work happens: once per keystroke, at the moment of asking, or once per build, before anyone asks.
Two times to do the ranking work
| Option | How it works | Strengths | Weaknesses |
|---|---|---|---|
| Serve live from the trie | Each keystroke walks the trie, collects the terms under the prefix, sorts them by count and returns the top 5 | The answer always reflects the newest counts; nothing is stored per prefix | The collecting and sorting repeat for every keystroke, on the hot path, inside the budget |
| Precomputed stored lists | Counts are built from the search log ahead of time, and the top 5 terms for each popular prefix are stored as one ready answer | Serving reads one stored list and checks nothing else; the expensive work happens once per build | The answer is only as fresh as the last rebuild; lists must be stored for every popular prefix |
Precomputation is working out an answer before it is asked and storing it ready to serve, and its inputs are built in two steps. The first step counts the past: the search log, the record of finished searches, is counted into a count table holding the number of past searches for each term. The demo counts a fixed log into a table where each term appears once with its total beside it, 16 searches counted into 6 counted terms in all.
Counting past searches
log = ["photo", "photos", "photo", "pizza", "photo editor", "photo", "photos", "photo print", "pizza", "photo", "photography", "photo", "photos", "pizza", "photo editor", "photo"]
counts = {}
for term in log:
counts[term] = counts.get(term, 0) + 1
for term in sorted(counts):
print(term, counts[term])
print("searches counted:", len(log))The second step turns counts into ready answers. For each popular prefix, the counted terms that start with it are sorted by count, and the stored list keeps the top 5 as a single ready answer. The printout shows the stored list for each prefix in the demo, highest count first, and a prefix with only one matching term stores a list of one.
Storing the top 5 per prefix
log = ["photo", "photos", "photo", "pizza", "photo editor", "photo", "photos", "photo print", "pizza", "photo", "photography", "photo", "photos", "pizza", "photo editor", "photo"]
def count_terms(entries):
counts = {}
for term in entries:
counts[term] = counts.get(term, 0) + 1
return counts
def build_store(counts):
store = {}
for prefix in ["ph", "pi"]:
matches = [term for term in counts if term.startswith(prefix)]
matches.sort(key=lambda term: (-counts[term], term))
store[prefix] = matches[:5]
return store
counts = count_terms(log)
store = build_store(counts)
for prefix in sorted(store):
print(prefix, "->", store[prefix])Serving then collapses to a single read. A lookup reads a single stored list instead of checking all 6 counted terms, and the printout shows the answer, the lists touched and the terms a live scan would check: 1 stored list touched against 6 counted terms. At production scale the same shape holds with far larger counts on the scan side, which is precisely why the precomputed answer fits the budget and the live one strains it.
One stored list per lookup
log = ["photo", "photos", "photo", "pizza", "photo editor", "photo", "photos", "photo print", "pizza", "photo", "photography", "photo", "photos", "pizza", "photo editor", "photo"]
def count_terms(entries):
counts = {}
for term in entries:
counts[term] = counts.get(term, 0) + 1
return counts
def build_store(counts):
store = {}
for prefix in ["ph", "pi"]:
matches = [term for term in counts if term.startswith(prefix)]
matches.sort(key=lambda term: (-counts[term], term))
store[prefix] = matches[:5]
return store
counts = count_terms(log)
store = build_store(counts)
typed = "ph"
touched = 0
answer = store[typed]
touched = touched + 1
print("answer for " + typed + ":", answer)
print("stored lists touched:", touched)
print("counted terms a live scan would check:", len(counts))Building for every prefix of every term shows the true storage shape of the choice. The closing model counts the log first, then stores the top 5 terms for every prefix of every counted term, and the build makes 28 stored lists from the small demo vocabulary. Each typed prefix after that reads exactly one list, so storage grows with the number of distinct prefixes while serving cost stays flat at one read.
Precomputed Suggestions
log = ["photo", "photos", "photo", "pizza", "photo editor", "photo", "photos", "photo print", "pizza", "photo", "photography", "photo", "photos", "pizza", "photo editor", "photo"]
def count_terms(entries):
counts = {}
for term in entries:
counts[term] = counts.get(term, 0) + 1
return counts
def build_store(counts):
store = {}
for prefix in ["ph", "pi"]:
matches = [term for term in counts if term.startswith(prefix)]
matches.sort(key=lambda term: (-counts[term], term))
store[prefix] = matches[:5]
return store
counts = count_terms(log)
store = {}
for term in counts:
for i in range(1, len(term) + 1):
prefix = term[:i]
matches = [t for t in counts if t.startswith(prefix)]
matches.sort(key=lambda t: (-counts[t], t))
store[prefix] = matches[:5]
print("stored lists:", len(store))
print("pho ->", store["pho"])
print("p ->", store["p"])The verdict is precomputed stored lists for serving, with the trie kept as the building structure underneath. Serving becomes one lookup on one shard, which is the only shape that comfortably holds about 100 milliseconds at 57,870 searches per second, and the counting and sorting work happens once per build rather than once per keystroke. The admitted cost is freshness: a stored list is a snapshot, and keeping the snapshot honest is a separate problem with its own cycle.
Deep dive: keeping suggestions fresh
A precomputed answer is a snapshot, and search counts change every hour of the day. New terms are born when news breaks or a name trends, and existing terms surge and fade, while the stored lists say whatever the last build computed. Freshness โ how recent the stored suggestions are โ is therefore a designed quantity with a chosen value, not an accident of the pipeline.
Three rebuild rhythms
| Option | How it works | Strengths | Weaknesses |
|---|---|---|---|
| Rebuild every hour | Workers count new searches continuously, and the stored lists are rebuilt from the newest counts every hour, 24 rebuilds a day | A new term waits at most one cycle; rebuild work stays modest and predictable | Suggestions still lag real searches by up to an hour at the worst moment |
| Rebuild once a day | The same counting runs, but the stored lists are rebuilt on a daily rhythm | The least rebuilding work of the cycle options | A term that breaks in the morning can be missing from suggestions for most of a day |
| Update on every search | Each finished search updates the affected stored lists at once | Suggestions are as fresh as the counts themselves | Every search becomes write work across many prefix lists, on the same path the design keeps deliberately cheap |
The stale window is concrete rather than theoretical. Between rebuilds, new searches are logged and counted, but the stored lists stay as they are, so a brand-new term appears only after the next rebuild. In the demo, a new term searched 6 times still waits for the next rebuild: the stored answer is unchanged, and the new term is missing from it despite its searches.
New searches wait for a rebuild
log = ["photo", "photos", "photo", "pizza", "photo editor", "photo", "photos", "photo print", "pizza", "photo", "photography", "photo", "photos", "pizza", "photo editor", "photo"]
new_searches = ["photo booth", "photo", "photo booth", "photo booth", "photos", "photo booth", "photo booth", "photo booth"]
def count_terms(entries):
counts = {}
for term in entries:
counts[term] = counts.get(term, 0) + 1
return counts
def build_store(counts):
store = {}
for prefix in ["ph", "pi"]:
matches = [term for term in counts if term.startswith(prefix)]
matches.sort(key=lambda term: (-counts[term], term))
store[prefix] = matches[:5]
return store
store = build_store(count_terms(log))
answer = store["ph"]
print("stored answer for ph:", answer)
print("photo booth in the answer:", "photo booth" in answer)
print("searches for photo booth since the rebuild:", new_searches.count("photo booth"))Counting does not stop while the lists wait, and that separation is what makes the cycle workable. New searches wait in a queue as counting jobs, and a worker takes them one by one, folding each into the pending counts. The printout shows the pending counts for the waiting searches and the emptied queue, ready for the builder whenever the cycle turns.
Workers count between rebuilds
new_searches = ["photo booth", "photo", "photo booth", "photo booth", "photos", "photo booth", "photo booth", "photo booth"]
queue = []
for term in new_searches:
queue.append(term)
pending = {}
while len(queue) > 0:
job = queue.pop(0)
pending[job] = pending.get(job, 0) + 1
for term in sorted(pending):
print(term, pending[term])
print("jobs left in the queue:", len(queue))The rebuild is the moment the snapshot moves. The rebuild adds the pending counts to the old counts and stores fresh lists, and the printout shows the new term inside the answer, ranked by its count among the established terms. Nothing about serving changes at that moment: the shards swap one build for the next, and the following keystroke reads the fresh list by the same single lookup as before.
The second rebuild picks it up
log = ["photo", "photos", "photo", "pizza", "photo editor", "photo", "photos", "photo print", "pizza", "photo", "photography", "photo", "photos", "pizza", "photo editor", "photo"]
new_searches = ["photo booth", "photo", "photo booth", "photo booth", "photos", "photo booth", "photo booth", "photo booth"]
def count_terms(entries):
counts = {}
for term in entries:
counts[term] = counts.get(term, 0) + 1
return counts
def build_store(counts):
store = {}
for prefix in ["ph", "pi"]:
matches = [term for term in counts if term.startswith(prefix)]
matches.sort(key=lambda term: (-counts[term], term))
store[prefix] = matches[:5]
return store
counts = count_terms(log)
for term in new_searches:
counts[term] = counts.get(term, 0) + 1
store = build_store(counts)
answer = store["ph"]
print("stored answer for ph:", answer)
print("photo booth in the answer:", "photo booth" in answer)Two cycles side by side show the rhythm as a whole. The closing model runs two rebuild cycles over the same growing counts: the first cycle answers without the new term, the second cycle answers with it, and the prefix whose counts did not change keeps its old answer. A prefix nobody's counts touched is rebuilt to the same list, which is wasted work the cycle accepts in exchange for a simple, uniform build.
Keeping Suggestions Fresh
log = ["photo", "photos", "photo", "pizza", "photo editor", "photo", "photos", "photo print", "pizza", "photo", "photography", "photo", "photos", "pizza", "photo editor", "photo"]
new_searches = ["photo booth", "photo", "photo booth", "photo booth", "photos", "photo booth", "photo booth", "photo booth"]
def count_terms(entries):
counts = {}
for term in entries:
counts[term] = counts.get(term, 0) + 1
return counts
def build_store(counts):
store = {}
for prefix in ["ph", "pi"]:
matches = [term for term in counts if term.startswith(prefix)]
matches.sort(key=lambda term: (-counts[term], term))
store[prefix] = matches[:5]
return store
counts = count_terms(log)
store = build_store(counts)
print("cycle 1 answer for ph:", store["ph"])
print("cycle 1 answer for pi:", store["pi"])
for term in new_searches:
counts[term] = counts.get(term, 0) + 1
store = build_store(counts)
print("cycle 2 answer for ph:", store["ph"])
print("cycle 2 answer for pi:", store["pi"])The verdict is the hourly rebuild: the design rebuilds its lists every hour. An hourly cycle bounds the stale window at one cycle โ a new term's worst wait is known and short enough for a popularity signal โ while keeping rebuild work at 24 builds a day rather than a continuous write load. The cost is stated plainly: suggestions lag real searches, and a fast-breaking term spends its first hour invisible to the box.
Bottlenecks and failure modes
The first component to saturate is a hot shard, and the cause is built into the shard key. First letters are not equally popular, in vocabulary or in searches, so the shard holding a busy range receives a disproportionate share of the 57,870 searches per second while quieter shards idle. Adding machines does not help until the letter ranges are redrawn, because the routing rule sends every prefix to exactly one shard by design.
The rebuild pipeline is the second pressure point, and its symptom is staleness rather than error. If counting workers fall behind, pending counts grow and the next build is computed from older data, so lists drift further behind real searches while serving continues untouched. If a build itself overruns its cycle, the swap simply happens late, and the fleet serves the previous build for longer than planned.
- A shard fails: prefixes in its letter range lose their suggestions until a replica of that shard takes over, and every other letter range keeps answering normally
- A suggestion server fails: the load balancer routes around it, because any server can route any prefix to its shard
- Counting workers stall: searches are still logged, counting resumes from the log when workers return, and only freshness suffers in the interval
- A rebuild publishes a bad build: the previous build is kept until the new one is complete, so shards swap back or stay on the old lists rather than serving a half-built store
- The whole suggestion service fails: the box shows no completions, and typing and searching carry on exactly as before, which is the failure the scope promised to allow
The degradation order follows the requirements. Freshness degrades first, because stale suggestions are a mild fault; coverage degrades next, when one letter range goes dark; and speed must not degrade at all, because an answer slower than about 100 milliseconds is worse than no answer โ it arrives after the typist has moved on and trains people to ignore the box. The one outcome that must never degrade is the search itself, which the suggestion path can slow or stop only by being wrongly coupled to it.
Trade-offs
The decisions and their costs
| Decision | Chosen | Given up |
|---|---|---|
| When ranking work happens | Precomputing the lists and rebuilding them on a cycle | Answers that always reflect the newest searches |
| Cycle length | An hourly rebuild, 24 times a day | The lower build cost of a daily rebuild, and the freshness of continuous updates |
| Shard key | The first letter of the typed prefix | Even shard sizes; busy first letters make hot spots |
| Answer size | The top 5 suggestions only | Visibility for every term ranked below the fifth |
| Personalisation | One stored list per prefix, the same for every user | Suggestions shaped by a person's own history |
| Languages | A separate trie and counts per language | One shared store; storage and builds multiply by language |
Every choice trades speed, freshness or storage against the others, and the ask decides which side wins. The sharpest of those trades is the first one, and the demo states it without decoration: the stored answer and a live answer are built from different counts for the same prefix, and the two lists side by side do not match. The stored answer is the fast one, and the live answer is the fresh one, and no design gets both at once.
Fresh versus stored
stored = ["photo", "photos", "photo editor", "photo print", "photography"]
counts = {"photo": 7, "photos": 4, "photo booth": 6, "photo editor": 2, "photo print": 1, "photography": 1, "pizza": 3}
matches = [term for term in counts if term.startswith("ph")]
matches.sort(key=lambda term: (-counts[term], term))
live = matches[:5]
print("stored answer:", stored)
print("live answer:", live)
print("answers match:", stored == live)The work accounting behind that trade is equally plain. Three lookups served both ways show 18 term checks the live way against 3 stored reads the precomputed way, because each live lookup checks all 6 counted terms while each precomputed lookup reads 1 list. The ratio grows with the vocabulary: the live cost is per keystroke and per term, while the precomputed cost is per build.
Work paid once or per keystroke
counts = {"photo": 6, "photos": 3, "photo editor": 2, "photo print": 1, "photography": 1, "pizza": 3}
store = {"ph": ["photo", "photos", "photo editor", "photo print", "photography"], "pi": ["pizza"]}
lookups = ["ph", "pi", "ph"]
live_checks = 0
stored_reads = 0
for typed in lookups:
live_checks = live_checks + len(counts)
stored_reads = stored_reads + 1
print("term counts checked the live way:", live_checks)
print("stored lists read the precomputed way:", stored_reads)Sharding by first letter carries the mirror-image cost, paid in balance rather than freshness. Search counts totalled per shard for a fixed set of terms show one shard taking most of the searches while the others stay quiet: the s-z shard takes 90 searches, the a-i shard takes 15 searches, and the j-r shard takes 8 searches. Lookups stay on one machine, which was the point of the key, and the busiest machine sets the fleet's real capacity, which is the price.
The hot spot cost
searches = {"sun": 40, "sand": 30, "sea": 20, "apple": 10, "avocado": 5, "plum": 8}
ranges = [("a-i", "a", "i"), ("j-r", "j", "r"), ("s-z", "s", "z")]
def shard_for(letter):
for name, low, high in ranges:
if low <= letter <= high:
return name
totals = {"a-i": 0, "j-r": 0, "s-z": 0}
for term in searches:
totals[shard_for(term[0])] = totals[shard_for(term[0])] + searches[term]
for name in ["a-i", "j-r", "s-z"]:
print(name, "takes", totals[name], "searches")The closing model runs the whole design end to end as a final check on the assembled choices. A log is counted, stored lists are built, and a typed prefix is routed to its shard for the answer, with 2 stored lists built for the demo prefixes. Each printed line is one stage of the design โ counting, building, routing, answering โ and the stages appear in the order the flows use them.
Autocomplete: Trade-Offs and Wrap-Up
log = ["photo", "photos", "photo", "pizza", "photo editor", "photo", "photos", "photo print", "pizza", "photo", "photography", "photo", "photos", "pizza", "photo editor", "photo"]
def count_terms(entries):
counts = {}
for term in entries:
counts[term] = counts.get(term, 0) + 1
return counts
def build_store(counts):
store = {}
for prefix in ["ph", "pi"]:
matches = [term for term in counts if term.startswith(prefix)]
matches.sort(key=lambda term: (-counts[term], term))
store[prefix] = matches[:5]
return store
ranges = [("a-i", "a", "i"), ("j-r", "j", "r"), ("s-z", "s", "z")]
def shard_for(letter):
for name, low, high in ranges:
if low <= letter <= high:
return name
counts = count_terms(log)
store = build_store(counts)
typed = "ph"
print("typed:", typed)
print("shard visited:", shard_for(typed[0]))
print("answer:", store[typed])
print("stored lists built:", len(store))Taken together, the design spends storage and build work to buy speed at the keystroke, and spends freshness to buy both. Stored lists make each answer one read, the hourly cycle keeps that read honest within a known bound, and first-letter sharding keeps the read on one machine as the vocabulary grows. A far smaller search service, with a vocabulary that fits on one machine and a rate far below 57,870 searches per second, should make the opposite exchange and serve live from the trie, because the shape of the right answer follows the shape of the load.
Common questions
How fast does autocomplete have to answer?
The whole answer must come back in about 100 milliseconds, measured against traffic of 5,000,000,000 searches a day, which is 57,870 searches per second. The budget is strict because typing does not wait: each new letter sends a fresh request, and an answer that arrives after the next letter has been typed is discarded unread. That is why serving is one stored read rather than any form of computation at request time.
Why are the suggestion lists precomputed instead of served live from the trie?
Live serving repeats the collecting and sorting work for every keystroke, while precomputing pays that work once per build. The counted comparison is 18 term checks the live way against 3 stored reads the precomputed way for the same 3 lookups, and the gap widens as the vocabulary grows. The trie still does the finding during the build; precomputation changes when the work happens, not whether it happens.
When does a brand-new search term appear in the suggestions?
A brand-new term appears after the next rebuild of the stored lists, and the design rebuilds every hour, 24 times a day. Workers count the new searches in the interval โ a term searched 6 times is fully counted long before it is visible โ but the stored lists change only at a rebuild. The stale window is therefore bounded by one cycle, and that bound is the freshness guarantee the design offers.
Why is the trie sharded by first letter?
The first letter is the one fact every lookup knows before any work happens, so it routes each prefix to exactly one shard and keeps the read on one machine. The demo routing checked 7 terms on the visited shard against 14 terms in the whole store, and the same rule keeps a production lookup inside one shard however large the vocabulary grows. The accepted price is imbalance: busy first letters make hot spots, and one busy shard in the counted demo took 90 searches while the quietest took 8 searches.
The estimate at a glance
| Measure | Value |
|---|---|
| Searches a day | 5,000,000,000 |
| Searches per second | 57,870 |
| Seconds in a day | 86,400 |
| Suggestions shown | Top 5 |
| Answer budget | About 100 milliseconds |
Exercise
Which choice makes answers fast but possibly stale?