Design typeahead (autocomplete)
Design typeahead (search autocomplete) for a system design interview: precomputed top-K per prefix served from memory, an offline index built from query logs with a streaming trending layer, CDN and client caching for hot prefixes, sharding, and a 100 ms latency budget.
On this page
🔎 What are we building?
Typeahead (also called autocomplete or search suggestions) is the drop-down list that appears under a search box while you are still typing. Type car and it offers "cars", "car insurance" and "car rental" before you finish the word. It acts like a helpful shop assistant who hears the first syllable and already knows the three things most people ask for next.
For this problem, we'll design query suggestions for a large consumer search box: the kind you see on a web search engine or a big online store. We'll focus on suggesting popular queries, not on searching the catalogue itself (that is Design product search). Interviewers like this one because the read path looks trivial ("it's a prefix lookup") but the real design lives in three places candidates forget: a latency budget measured in tens of milliseconds, an index that is built offline, and short prefixes that are far hotter than everything else.
The 30-second answer
"Suggestions are a read-heavy lookup with a hard latency budget, about 100 ms from keystroke to pixels. I'd precompute, for every prefix, the top 10 completions ranked by a time-decayed popularity score, and serve that table from memory on replicated suggest servers. The table is built offline from the search logs: a batch job rebuilds it every few hours, and a small streaming layer adds trending queries within minutes. In front of it, the client debounces keystrokes and caches results, and a CDN caches the short, very hot prefixes. If the table outgrows one machine, I shard by a hash of the prefix and replicate each shard."
What you’ll learn
- Explain why a precomputed prefix -> top-K table beats searching a trie or database on every keystroke
- Justify a ~100 ms end-to-end budget and show that the network, not the lookup, spends most of it
- Build the index offline from query logs in Kafka: batch decayed counts plus a streaming trending layer, swapped in as versioned snapshots
- Protect the hottest keys with client debounce and caching and CDN caching of short prefixes, then shard by
hash(prefix) - Add personalisation without killing cacheability, and filter offensive suggestions at build time and serve time
Interview radar
What they probe: whether you precompute top-K instead of searching at request time, how fresh suggestions get into the index, how you handle the one-letter prefixes everyone types, and how you keep offensive suggestions out.
Where candidates slip: drawing a database with LIKE 'car%', updating the trie synchronously on every search, and never stating a latency target or what eats it.
01Requirements
Let's pin the problem down before drawing anything. Here's the list I'd write on the board.
Functional
- Users should be able to see up to 10 suggestions for the text they have typed so far, updated as they type.
- Users should get suggestions ranked mostly by popularity, with recent spikes ("trending") able to climb quickly.
- Users should never see suggestions that break the content policy (hateful, sexual, dangerous), and the team should be able to remove a suggestion fast.
- Users who are signed in may see a few suggestions from their own recent searches.
Non-functional
- Latency: suggestions should feel instant. I'd set a target of under ~100 ms end to end at p99 (we'll justify the number in the deep dives), which leaves the server itself only around 10 ms.
- Scale: hundreds of millions of searches a day, which turns into tens of thousands of suggestion requests per second (estimated below).
- Availability over consistency: a slightly stale list is fine; an empty drop-down is not. If the suggest service is down, the search box must still work.
- Freshness: popular suggestions update within hours; breaking news should show within roughly 10-15 minutes (our target, agree it with the interviewer).
Below the line
- Searching the catalogue and ranking result pages (see Design product search).
- Spell correction and "did you mean" (we touch on typo tolerance in a follow-up).
- Rich entity suggestions with images and prices.
- Training a learned ranking model.
At this point, ask your interviewer: "Are we suggesting past queries or items from a catalogue? How many languages? Is personalisation in scope? How fresh must trending terms be?" Each answer changes the design. Queries from logs point to a precomputed table; catalogue items point to a search engine with an edge n-gram field.
02Core entities and API
Four things carry the design:
- Query: a normalised search string ("car insurance") with a score.
- Prefix entry: a prefix mapped to its top-K query IDs, already sorted. This is what we serve.
- Search event: one row per executed search (query text, time, locale, optional user ID). This is our raw signal.
- Block rule: a phrase or pattern that must never be suggested.
The API is tiny on purpose. The suggestion call is a plain GET so that browsers and CDNs can cache it.
GET /v1/suggestions?q=car&locale=en-IN&limit=10
200 OK
Cache-Control: public, max-age=300
{ "prefix": "car",
"suggestions": [ { "text": "cars" }, { "text": "car insurance" }, { "text": "car rental" } ],
"index_version": "2026-10-20T02:00Z" }
GET /v1/me/recent-searches?prefix=car&limit=3 (signed-in only, never cached publicly)
POST /v1/suggestions/report
{ "prefix": "car", "text": "...", "reason": "offensive" }
Notes worth saying out loud. The Cache-Control header is set only for anonymous, non-personal responses (MDN defines max-age as how long a cache may reuse the response). The personal list is a separate call so it never pollutes a shared cache. Search events are not an endpoint here: the search service already logs every executed search to Kafka, and we read from there.
03Back-of-envelope estimates
These are assumptions to agree with your interviewer, not facts about any real company. The arithmetic is ours and rounded.
| Quantity | Assumption | Roughly |
|---|---|---|
| Searches per day | 500 million | ~5,800 searches/s on average |
| Suggestion calls per search | ~5 after debouncing and client caching | 2.5 billion calls/day |
| Suggestion QPS | 2.5 B / 86,400 s | ~29,000/s average, ~90,000/s at a 3x peak |
| Distinct queries kept | 100 million after dropping rare ones | |
| Average query length | ~20 characters | 100 M x 20 B = ~2 GB of text |
| Prefix entries (upper bound) | one per character of every kept query | 100 M x 20 = 2 billion, far fewer in practice because prefixes are shared |
| Top-10 list per entry | 10 query IDs x 4 bytes | 40 B, so an upper bound of roughly 80 GB; shared prefixes cut this a lot |
| Search log volume | ~100 bytes per event | ~50 GB/day into Kafka |
The number that drives the design is ~90,000 reads per second under a ~100 ms budget, not storage. Writes are a batch job we control. That tells us to serve from RAM, replicate heavily for read throughput, and only shard if the table stops fitting in one machine's memory. For a refresher on doing this maths fast, see Back-of-envelope estimation.
04High-level design
Let's start with the simplest thing that works: one server holding a map from prefix to top 10, rebuilt every night from the logs. Then we grow it, one requirement at a time.
Typeahead: a fast read path and a slow build path
To show suggestions as the user types, the browser sends GET /v1/suggestions?q=<prefix> after a short pause in typing. The suggest API normalises the prefix (lowercase, trim, collapse spaces, Unicode normalisation), looks it up in the in-memory prefix table on a suggest server and returns the stored list. One lookup, no scoring at request time.
To rank by popularity and freshness, we never rank on the request path. Search events go to Kafka; batch and streaming jobs count them; an index builder keeps the top 10 per prefix and writes a versioned snapshot to blob storage, which suggest servers load in the background and swap in atomically.
To keep bad suggestions out, the builder drops blocked phrases before they reach a snapshot, and the API applies a small serve-time blocklist for urgent removals.
To personalise, a second, private call returns the user's recent matching searches, shown above the global list, which stays cacheable.
To hit the latency target at 90,000 QPS, we add client debounce and caching, a CDN for short prefixes, and read replicas.
05Deep dives
1) How do we look up a prefix fast?
Before reading on: for the prefix
c, how many stored queries might match, and what would it cost to sort them by popularity on every keystroke?
Bad: query the database at request time. SELECT text FROM queries WHERE text LIKE 'car%' ORDER BY score DESC LIMIT 10 works on a B-tree index, because a left-anchored LIKE becomes a range scan. The trouble is short prefixes. c matches millions of rows, and the database must read all of them to sort by score. It falls over exactly on the prefixes people type most.
Good: a trie, searched per request. A trie stores strings character by character, so all queries starting with car live under one node. Walking to that node costs only the prefix length. But you still have to visit the whole subtree below it to find the 10 best, which for a one-letter prefix is most of the tree. Same problem, smaller constant.
Great: precompute the top K at every node. Store the sorted top-10 list on every trie node at build time. Now a lookup walks len(prefix) nodes and returns one list. There's no search at request time at all.
A trie that stores the answer at every node
Once every node carries its answer, notice that you don't really need the tree at serve time. A flat hash map of prefix -> top-10 IDs gives the same result with one hash lookup, and it's trivial to shard (deep dive 3). The trie becomes a build-time tool. A Redis sorted set with lexicographic ranges (Redis sorted sets) can prototype prefix lookups, but I'd serve the hot path from the suggest servers' own memory.
When the suggestions are documents, not queries. If you're completing product names or people, a search engine is the natural home. Elasticsearch's completion suggester is built for this: it keeps data structures that are fast to query but "costly to build and are stored in-memory" (Elastic docs). The alternative is an edge n-gram field, which indexes Qu, Qui, Quic, Quick at index time while you search the raw typed text (edge n-gram tokenizer). It's more flexible (it can match words in the middle and combine with filters) and slower than a precomputed list. We cover both in Types of search and the structures underneath in Storage data structures.
| Option | Lookup cost | Build cost | Good for |
|---|---|---|---|
SQL LIKE 'p%' + sort |
grows with matches | none | prototypes |
| Trie, search subtree | prefix + subtree | low | small lists |
| Prefix -> top-K table | one hash lookup | high, offline | popular queries at scale |
| ES completion suggester | in-memory lookup | high | entity names, per-index |
| Edge n-gram field | inverted index query | medium | in-word matches, filters |
a, c, s) while 90,000 reads a second hit them. You've built lock contention into the read path. Count offline, rebuild, swap.
My default: a precomputed prefix-to-top-K table, built offline, held in RAM on every suggest server. Reach for Elasticsearch only when the suggestions are entities with filters.
2) How do we build the index, and keep it fresh?
The index is only as good as its scores. Google describes its own predictions as based on real searches, showing "common and trending ones" that also relate to your location and previous searches (How Google autocomplete works). That's the right mental model: popularity plus recency, adjusted per user.
Batch layer (the backbone). Every few hours, a batch job reads the search events from Kafka (or the copy archived in blob storage), normalises each query and counts it per locale. Raw counts favour old evergreen queries forever, so apply time decay: each day's count is multiplied by a factor like 0.9 per day of age (our example, tune it). Drop queries seen by too few distinct users; this removes noise and also stops one person from planting a suggestion. Apply the block rules. Build the trie, fill the top 10 per node, flatten it to a prefix table and write it as a versioned snapshot.
Streaming layer (the freshness). A batch every few hours can't surface breaking news. A streaming job reads the same Kafka topic and keeps counts over short sliding windows (say, the last 15 minutes). When a query's recent rate jumps well above its usual rate, it's trending. The streaming job publishes a small "fresh" table of a few thousand prefixes. At serve time the API merges the fresh list into the main list, so a trending query can appear within minutes without rebuilding the big index.
Swapping safely. Each suggest server downloads the new snapshot, loads it next to the old one, runs a few sanity checks (row count within a few percent of the last version, a handful of known prefixes return results), then flips a pointer. If anything looks wrong, it keeps the old one. The index_version in the response makes "why did suggestions change?" debuggable.
My default: batch every few hours plus a streaming trending layer, merged at serve time. Start with batch only if the interviewer says freshness within a day is fine.
3) How do we hit 100 ms at 90,000 QPS?
First, where does the 100 ms come from? Usability research has long put 0.1 second as the limit for a response to feel instantaneous (Nielsen Norman Group). Facebook's engineers wrote that spending more than 100 msec to retrieve a typeahead result makes it "stutter" (The life of a typeahead query). So 100 ms is the whole budget, and most of it is spent on the network, not in our code.
High Performance Browser Networking, which is why we serve from several regions or the CDN). Load balancer and API: 2-5 ms. In-memory lookup: well under 1 ms (a RAM reference is about 100 ns). Rendering the list: one or two frames, roughly 16-33 ms at 60 Hz. That totals roughly 40-90 ms, so there's no room for a database call on the hot path.
Layer 1: the client. Debounce: wait until the user pauses for a few tens of milliseconds before sending, which collapses a burst of keystrokes into one call (MDN: Debounce). Cancel in-flight requests that are now stale, and drop out-of-order responses (check that the response's prefix still matches the box). Cache every response in memory for the session, so backspace and retyping cost nothing. Facebook's typeahead went further and preloaded a user's friends and pages into the browser cache as soon as the box got focus.
const cache = new Map();
let timer, controller;
input.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(async () => {
const q = normalise(input.value);
if (cache.has(q)) return render(cache.get(q));
controller?.abort(); // drop the stale request
controller = new AbortController();
try {
const res = await fetch(`/v1/suggestions?q=${encodeURIComponent(q)}`, { signal: controller.signal });
const body = await res.json();
cache.set(q, body);
if (normalise(input.value) === body.prefix) render(body); // ignore late answers
} catch (e) {
if (e.name !== 'AbortError') showNothing(); // never block typing
}
}, 50); // our starting debounce; tune with A/B tests
});
Layer 2: the CDN. Short prefixes are a tiny keyspace with enormous traffic. With only lowercase a-z, there are 26 one-letter and 676 two-letter prefixes per locale (our arithmetic). Their answers barely change, so let the CDN cache them with a max-age of a few minutes. Most traffic for the hottest keys then never reaches us. See CDNs and edge caching.
Layer 3: replicas and shards. If the table fits in one server's RAM (tens of GB fits on ordinary servers), every suggest server holds a full copy and you scale reads by adding replicas. When it stops fitting, shard it.
Hot prefixes stop early; the rest hash to a shard
- Range by first letter (
a-fon shard 0, and so on) keeps neighbours together, but letters are wildly unequal in traffic, so some shards melt. - Hash of the whole prefix spreads keys evenly. This works only because a request reads exactly one key: we look up
car, never "everything underca". That's a direct win from precomputing top-K in deep dive 1.
Even with hashing, a single key like a is one hot partition. The CDN and client caches are what really protect it; replicas take the rest. We cover the general pattern in Sharding and partitioning and Caching.
My default: client debounce and cache, CDN for prefixes of one or two characters, full replicas until memory forces hash sharding.
4) Personalisation and keeping suggestions safe
Personalisation. The cheap, effective version: the user's own recent searches that match the prefix, shown first. Keep the last 100 or so per user in a small store such as Redis keyed by user ID, or on the device itself. The API (or the client) merges them above the global list and removes duplicates. Heavier personalisation, like location or language, is better done by building separate tables per locale or region, which stay cacheable. Personalising the global list itself per user would destroy the CDN hit rate, so be explicit about that trade-off.
Safety. Google's published policies remove predictions that are sexually explicit, hateful, violent or dangerous, and also ones that are spam or tied to piracy, with the guiding idea that autocomplete "should not shock users" (How Google autocomplete works). Design for it in three places:
- At build time: block lists and a classifier drop bad candidates before they enter a snapshot. This is where most filtering lives.
- At serve time: a small, frequently pushed blocklist checked on every response, for removals that can't wait for the next build (legal requests, a fresh incident).
- Thresholds: require a query to come from many distinct users before it can be suggested, which blunts manipulation campaigns.
My default: recent searches merged from a private call, per-locale global tables, and filtering at build time with a serve-time blocklist for urgent removals.
06What interviewers look for
| Level | What a strong answer shows |
|---|---|
| Mid-level | A working read path with a trie or prefix table, a clear API, popularity ranking, and caching. Understands that the index is built from logs. |
| Senior | Precomputed top-K and why, a stated latency budget, offline build with safe swapping, a streaming freshness layer, hot short prefixes handled by CDN and client, a sharding choice tied to the lookup shape. |
| Staff | Frames it as a read/write split with independent failure domains, discusses abuse and policy as first-class requirements, multi-locale tables, A/B testing of ranking, cost of personalisation versus cacheability, and what to measure (keystrokes saved, suggestion click rate). |
Green flags: naming the dominant number (QPS under a latency budget), moving ranking off the request path, recognising that one-character prefixes are the hot keys, mentioning debounce and out-of-order responses.
Red flags: a relational query per keystroke, synchronous trie updates on every search, no freshness story, no safety filtering, personalising everything and then putting a CDN in front of it.
07Follow-up questions
What if users make typos, like "carr insurence"?
Typeahead is mostly navigational, so keep the main path exact and cheap. Add a fallback: when the exact prefix returns few results, try a small fuzzy lookup over the prefix table limited to one edit, or route to a search engine field with fuzziness. Elastic's own docs say the completion suggester isn't meant for spell correction, so treat typos as a separate, slower path.
What if a breaking news query needs to show within a minute?
Shorten the streaming window and push the fresh table more often, every 30-60 seconds. The fresh table is small (thousands of prefixes), so pushing it is cheap. The main snapshot doesn't need to change. Gate it with a minimum distinct-user count so a bot can't trend a phrase.
What if legal asks us to remove a suggestion right now?
Add it to the serve-time blocklist, which every API server polls or receives by push within seconds. Purge the CDN entries for the affected prefixes, because cached responses would otherwise live for their full max-age. Then add the rule to the build-time list so the next snapshot never contains it.
What if we support 30 languages?
Build one table per locale, because normalisation is language-specific (case folding, accents, scripts without spaces). Locale becomes part of the cache key and the shard key.
What if a suggest server dies while loading a new snapshot?
Users don't notice: the load balancer's health check takes it out and replicas serve the traffic. On restart it loads the latest good snapshot from blob storage. Snapshots are immutable and versioned, so any server can rebuild its state from storage alone.
What if the suggestions should be products, not past queries?
Then the source of truth is the catalogue, not the logs, and you need filters like category and stock. I'd use a completion or edge n-gram field in the product search index and blend it with query suggestions. That's the territory of Design product search.
08How to say it in the interview
In the interview
"Typeahead is a read-heavy lookup with a hard deadline, so I'll design the read path and the build path separately. On the read path, I want one in-memory lookup per keystroke: a table from normalised prefix to its top 10 completions, precomputed so nothing is ranked at request time. The budget is about 100 ms end to end; the network takes most of it, so the server gets about 10. At roughly 90,000 requests a second, I'd add client debounce and caching, cache one and two character prefixes at the CDN, and run full replicas until the table no longer fits in memory, then hash the prefix to shard it. On the build path, search events go to Kafka; a batch job computes time-decayed counts every few hours and writes a versioned snapshot to blob storage, and a streaming job adds trending queries within minutes. Servers load snapshots in the background and swap atomically. Safety filtering happens at build time, with a small serve-time blocklist for urgent removals. Personal recent searches come from a separate private call, so the global list stays cacheable."
On the whiteboard
- Target: p99 < ~100 ms end to end, ~10 ms server
- Read: client (debounce, cache) -> CDN (short prefixes) -> API -> RAM table
- Table: prefix -> top 10 IDs, precomputed, versioned
- Build: Kafka -> batch (decayed counts) + stream (trending) -> builder -> blob snapshot -> swap
- Scale: replicas first; then shard by hash(prefix)
- Safety: build-time filter + serve-time blocklist + min distinct users
- Personal: separate private call, merged on top
Concepts used in this design
- CachingSoon
- CDNs and edge cachingSoon
- Sharding and partitioningSoon
- RedisSoon
- KafkaSoon
Go deeper in Articles
Optional long reads behind this design, for when you want the full story.
- Types of Search in System Design: Autocomplete, Fuzzy, Phrase, Geo, Faceted, Vector and Hybrid~16 min readMembers
- Data Structures for System Design: B-Tree, LSM Tree, Bloom Filter, Inverted Index, Geo and Vector Indexes~12 min readMembers
- How Search Engines Work: Inverted Index, Text Analysis, TF-IDF and BM25~25 min readFree
- Elasticsearch for System Design: Shards, Replicas, Write Path and Read Path~25 min readMembers
Further reading (10)
- The life of a typeahead query (Facebook Engineering, 2010): A real typeahead: browser-side preloading, a stateless aggregator over leaf services, and why more than 100 ms makes it stutter.
- How Google autocomplete works in Search (Google, 2018): Predictions come from real, common and trending searches; the policies for removing harmful ones.
- Response Times: The 3 Important Limits (Nielsen Norman Group): The 0.1-second limit for a response to feel instantaneous, the basis of our budget.
- Suggesters: completion suggester (Elastic docs): In-memory structures built for speed; navigational, not for spell correction.
- Edge n-gram tokenizer (Elastic docs): Index prefixes at write time and search the raw typed text.
- Redis sorted sets: Lexicographic ranges on a sorted set, a common way to prototype prefix lookups.
- Debounce (MDN glossary): Collapsing a burst of keystrokes into one call, with search suggestions as the example.
- Cache-Control (MDN): How max-age lets browsers and CDNs reuse a suggestion response.
- Primer on latency and bandwidth (High Performance Browser Networking): Speed-of-light round trips that set the floor for any end-to-end budget.
- Index 1,600,000,000 keys with automata and Rust (Andrew Gallant): How tries and FSTs share prefixes and suffixes to keep huge key sets compact.