Design Typeahead Search
Design search-box autocomplete: top suggestions per prefix, trie versus precomputed table, offline popularity builds, and a latency budget met by caching.
On this page
You type "pi" into a search box and, before you type the next letter, a list drops down: "pizza near me", "pie crust recipe", "pilates". Each keystroke is a request, the answer must arrive before the next keystroke, and the list must reflect what millions of people actually search for.
Interviewers like this question because the obvious answer (a trie on a server) is half right. The real work is precomputing answers offline and spending a 100 ms budget across the client, the network and an edge cache. The steps follow A Framework for Any Design. If tries are new, read Tries first.
Clarify the problem
Design the autocomplete for a search box.
Are we suggesting whole past queries ranked by popularity, rather than searching the documents themselves? And how many suggestions?
Past queries by popularity. Show five.
How fresh must popularity be? If something starts trending, should it show up within minutes or is a day fine? And are suggestions personal?
A day is fine for most; trending within an hour would be nice. Not personalised for now.
Scale and speed: how many users, and how fast should suggestions appear?
50 million daily users, about 6 searches each a day. Suggestions should feel instant.
I'll take "instant" as under 100 ms from keystroke to list on screen, at least for most users. I'll support English first, prefix matches only, no spelling correction, and I'll make sure offensive suggestions can be removed quickly.
- Functional: for a typed prefix, return the 5 most popular past queries that start with it; popularity reflects recent searches; bad suggestions can be removed.
- Non-functional: under 100 ms from keystroke to list for most users; very high availability (the search box must never break, so an empty list is the fallback); popularity at most a day old, trending within an hour as a stretch.
- Out of scope: personalisation, spelling correction, languages other than English, searching the documents themselves (see Search Systems).
Estimate the scale
- Searches: 50 million × 6 = 300 million a day.
- Suggestion requests: a 20-character query typed one key at a time would send 20 requests. Fewer go out: people often pick a suggestion after a few letters, the client skips keystrokes that come in quick bursts (debouncing), and its cache answers repeats. Assume 6 requests per search. 300 million × 6 = 1.8 billion a day, and 1.8 × 109 ÷ 86,400 s ≈ 21,000 a second on average. Assume a 3x peak: about 62,500 a second.
- Query log: 300 million searches × 100 bytes ≈ 30 GB a day of raw input for the offline build.
We keep the 10 million most popular queries. If we store a top-5 list for every prefix of every query, how much memory does the table need at most?
- 10 million queries kept
- Queries average 20 characters
- About 150 bytes per prefix entry (the prefix plus five suggestions)
Show the working
- Each query has up to 20 prefixes: 107 × 20 = 2 × 108 prefixes at most
- 2 × 108 × 150 B = 3 × 1010 B = 30 GB
Estimate 30 GB (anything from 10 GB to 100 GB is a good answer)
This is an upper bound: queries share prefixes (every query starting with "pi" shares "p" and "pi"), so the real count is lower. Even the bound fits in one server's memory (60 GB while a new version loads beside the old), so every server can hold the whole table.
Write the suggestion endpoint. Think about what makes its responses cacheable by a CDN, and what the client should do before it sends the prefix.
Compare with a model answer
A plain GET with the prefix in the query string and a Cache-Control header, nothing personal in the request, and a prefix the client has already normalised (lowercase, trimmed) so that equal prefixes produce equal URLs and share one cache entry.
Define the API
GET /v1/suggest?q=pi&limit=5
200 OK
Cache-Control: public, max-age=3600
{ "q": "pi", "version": "2026-11-14",
"suggestions": ["pizza near me", "pie crust recipe", "pilates",
"pineapple upside down cake", "piano lessons"] }
q is empty 200 with an empty list
q longer than 50 400 Bad Request
service overloaded 503; the client shows no list and the user keeps typingThe decision worth arguing is making the response public and cacheable. No cookie, no user id, a normalised prefix: then every user typing "pi" in the same hour hits one cached response at the edge. The cost is that the API cannot personalise, which is why personal suggestions (your own recent searches) are merged on the client instead. The max-age of one hour fits the build: data that changes daily can be an hour stale at the edge.
What does the serving side store, and what does the offline side store? Which one is read 62,500 times a second?
Compare with a model answer
Serving holds one read-only table: prefix → its five best suggestions, rebuilt from scratch on each build. Offline holds query counts per day, which only the build reads. Keeping them apart means the hot path never computes anything.
Model the data
-- serving: read-only, rebuilt each build, held in memory on every server
prefix_top5
prefix VARCHAR(20) PRIMARY KEY -- "pi"
suggestions [ (query, score) x 5 ] -- about 150 bytes a row with the key
version DATE
-- offline: written by the log pipeline, read only by the build
query_counts
query VARCHAR(100)
day DATE
count BIGINT
PRIMARY KEY (query, day)
blocklist -- queries and patterns never to suggest; small, edited by peopleThe serving access pattern is a single-key lookup on a table that never changes between builds. That suits an in-memory hash map loaded from a file, on every server. No database sits on the hot path. If the table outgrew memory (many languages, longer prefixes), the next step is a key-value store partitioned by prefix. The offline counts are large, append-only and scanned in bulk by a batch job, so they belong in files in blob storage or a data warehouse, not a serving database.
Sketch two paths: a keystroke that turns into a list, and a search that eventually changes what the list shows. Where do caches sit?
Compare with a model answer
Read path: the client's own cache, then a CDN edge, then a fleet of identical suggest servers holding the table in memory. Write path: searches are logged, a batch job counts and ranks them, writes a new versioned table to blob storage, and suggest servers load it and swap. The two paths share only that file.
High-level design
The hot path only looks up a precomputed answer. Counting, ranking and filtering happen offline, and their only output is a file.
request and replyasynchronous message
- ms used
- 30
1Mia types "p", then "i", and pauses for 30 ms. Her app normalises the text to
piand checks its own cache first: nothing yet.- ms used
- 40
2It sends
GET /v1/suggest?q=pito the nearest edge location. This edge has not cachedpiin the last hour: a miss.- ms used
- 70
3The edge forwards to a suggest server. One hash map lookup in memory, well under a millisecond, returns the five stored for
pi. The blocklist copy in memory removes nothing.- ms used
- 90
4The answer goes back with
max-age=3600. The edge and the app each keep a copy, and the list appears. A miss at both caches fits in 100 ms only because the origin region is near.- ms used
- 60
5Another user near the same edge types "pi" a minute later. Edge hit: the request never reaches a suggest server.
- ms used
- 30
6Mia types "z", then deletes it. The list for
picomes from her app's own cache: no network at all.7She submits "pizza near me". The search service runs the search and, off the hot path, logs the query to a stream.
8Overnight, the build job reads the day's 300 million logged searches, adds them to the decayed counts, drops blocked queries and computes every prefix's top five.
9It writes the new table as a versioned file,
2026-11-15. Nothing is overwritten, so a bad build can be rolled back by loading the previous file.10Each suggest server loads the new file into spare memory in the background, then swaps one pointer. Requests never see a half-loaded table.
11If tomorrow's build fails, servers keep yesterday's table. Suggestions are a day staler, but every one of them is still valid.
Deep dives
A trie, or a precomputed top five per prefix
A trie stores queries letter by letter, so all queries starting with "pi" sit under one node. Finding that node takes one step per character. The trap is what comes next: the subtree under "p" may hold a million queries, and searching it on every keystroke to find the best five is far too slow. The fix is to do that search once, at build time, and store each node's top five on the node.
1Five past queries with their counts, in a trie. Each edge is one letter; a node's name is the prefix that reaches it.
- nodes visited
- 3
2Request time, plain trie: the user types "ca". Walk root → c → ca: 3 nodes, one per character plus the root.
- nodes visited
- 7
3Now visit every node below "ca" to collect completions: 4 more nodes, 4 queries. Under "p" on a real site this would be millions of nodes, on every keystroke.
- nodes visited
- 7
4Sort the 4 by count and keep the top 2: car (50) and cat (40). Correct, but the work grew with the subtree, not with the prefix.
5Build time instead. Start at the bottom: the deepest leaves, "care" and "cart", each keep a list holding just themselves.
6Node "car" merges its own query (50) with its children's lists (cart 30, care 25) and keeps the best 2: car, cart. It never looks deeper than its children.
7Node "ca" merges car's list (car 50, cart 30) with cat's (cat 40): best 2 are car, cat. Node "c" copies it from its only child; on the right, "do" and "d" get dog.
8The root merges "c" (car 50, cat 40) with "d" (dog 60): dog, car. Every node now holds its own answer.
- nodes visited
- 3
9Request time now: walk to "ca" and read its list. 3 nodes, however big the subtree. Store each prefix's list in a hash map and even the walk disappears: one lookup.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Plain trie, search the subtree per request | Small; always exact; easy to update. | Work grows with the subtree. Short prefixes, the most common requests, are the slowest. | Small vocabularies, such as a few thousand product names. |
| Trie with top five stored on each node | Lookup costs one step per character. Shared prefixes are stored once. | A custom structure to serialise, load and shard. | Memory is tight and prefixes share a lot. |
| Hash map: prefix → top five Usual default | One lookup. Any key-value store or a plain map serves it; easy to shard by hashing the prefix. | Each prefix is stored in full, so more memory than the trie. | Serving, when the table fits in memory. My choice; build it from a trie offline. |
The 30 GB bound fits in memory, so every suggest server holds the whole map and there is no sharding at all. If it stops fitting, shard by a hash of the prefix, never by first letter: far more queries start with "s" than with "x", so letter ranges give lopsided shards.
Updating popularity offline
Counting on the hot path would mean a write per search and a re-sort of up to 20 prefix lists per query. Do it in batch: the build reads the day's log, adds it to the history, filters, ranks, and writes a complete new table.
Recent searches should count for more. Weight each day's count by 0.8 for every day of age. A query searched 1,000 times exactly a week ago scores 1,000 × 0.87 ≈ 210; one searched 300 times today scores 300 and ranks higher. The build also drops queries searched fewer than a few times (they are mostly typos and private details such as names or addresses) and anything on the blocklist.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Daily offline rebuild Usual default | Simple, testable, easy to roll back: each build is a file you can inspect before serving. | A query that starts trending this morning appears tomorrow. | A day of staleness is acceptable. The base of my design. |
| Update counts live in the serving table | Fresh within seconds. | Writes on the hot path, locking around shared lists, and no file to inspect or roll back. | Rarely; the cost lands on the path that must stay fastest. |
| Daily rebuild plus a trending overlay | A stream job counts the last hour and publishes a small list of spiking queries, merged in at serve time. | Two systems, and the merge rule needs tuning. | The interviewer asks for trends within an hour. My extension. |
The latency budget and where caching happens
100 ms is the whole budget from keystroke to list, so spend it on purpose. Distance sets the floor: light in fibre travels about 200 km per millisecond, so a server 6,000 km away costs at least 2 × 6,000 ÷ 200 = 60 ms per round trip before any work. A rough plan for a user near an edge location:
| Step | Budget |
|---|---|
| Wait for a pause in typing (debounce) | 30 ms |
| Round trip to a nearby edge | 15 to 30 ms |
| Edge to suggest server and back, on a miss | 30 ms if the region is near |
| Lookup on the server | under 1 ms |
| Draw the list | 10 ms |
An edge hit lands at about 55 to 70 ms, a miss to a nearby region at about 85 to 100 ms, and a miss to a far region blows the budget. So caching does most of the work, at three levels:
- In the client. Keep every response for the session. Backspacing and retyping cost nothing. Replies can arrive out of order, so drop any whose prefix no longer matches the box. One subtle shortcut: if the list for "pi" had fewer than five entries, it is complete, and the client can filter it for "piz" without asking.
- At the edge. Short prefixes are few and hot: there are only 26 + 262 + 263 = 18,278 letter prefixes of up to three characters, and they cover a large share of keystrokes. Assume the edge answers 60% of requests: the 62,500 a second at peak become 25,000 a second at the servers. See CDNs.
- On the server. The whole table is already in memory, so there is nothing else to cache.
Run suggest servers in several regions so misses stay near. A new region needs a file copy, not replication.
Bottlenecks and failures
At 10x (625,000 requests a second at peak), the edge still takes 60%, leaving 250,000 a second for the suggest servers. They share nothing, so add servers; if one handles 10,000 a second (an assumption that depends on the HTTP stack, since the lookup itself is tiny), that is 25 servers plus spares. The build is the slower wall: ten times the log is 300 GB a day, so it must run as a distributed batch job split by query. Many languages would multiply the table; give each language its own table and shard by prefix hash when one no longer fits in memory.
| When this dies | What happens | What you do |
|---|---|---|
| An edge location | Users there are routed to the next nearest one, a little slower. | Nothing; the CDN handles it. Watch origin load rise. |
| A suggest server | Requests to it fail until health checks remove it. | Every server is identical, so the others take its share. |
| All suggest servers in a region | Misses go to another region and exceed the budget. | The client shows no list on timeout and search still works. |
| The build job | No new table tonight. | Servers keep yesterday's table; alert if the table is more than 2 days old. |
| The log stream | Some searches are not counted. | Popularity is a statistic: a few lost hours barely change the ranking. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | Puts a trie on a server and searches it per request; updates counts on each search when asked about popularity. |
| Mid | Stores the top five per node, rebuilds offline, adds a cache, and debounces on the client. |
| Senior | Turns "instant" into a 100 ms budget and spends it; makes responses cacheable at the edge on purpose; builds versioned tables offline with decay and filtering; removes bad suggestions at serve time; sizes the table and decides no sharding is needed. |
An offensive suggestion is showing for a common prefix. How fast can you remove it?
Within about a minute. The blocklist is checked at serve time, not only in the build: each server refreshes it every minute and filters results before returning them. The edge may still hold the old response for up to an hour, so I'd also purge that prefix from the CDN. Apps holding a copy drop it when the session ends. The next build drops the query from the table for good.
Follow-up questions
How would you personalise suggestions?
Keep the shared, cached list. The client stores the user's recent searches and puts matching ones first, so the shared path stays cacheable.
How do you show trends within an hour?
Add a stream job that counts queries over the last hour and compares them with their usual rate. It publishes a small list of spiking queries every few minutes; servers merge them into results at serve time, and the edge TTL drops to a few minutes for short prefixes.
How do you handle typos?
Fuzzy matching per keystroke is expensive. Mine common misspellings from the logs (people who typed X, then searched Y) and add them as extra prefixes pointing to Y's list.
What about other languages?
A table per language, chosen by the request locale, which joins the cache key. Normalisation differs by language (accents, scripts without spaces), so it lives in one library shared by the build and the client.
Why not use a search engine's prefix queries?
You can, for a small catalogue. At 62,500 requests a second, ranking at query time costs far more than a map lookup, and the answers change only daily.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".