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.

Case study32 minLesson 7 of 7
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

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

The first five minutes
Interviewer

Design the autocomplete for a search box.

You

Are we suggesting whole past queries ranked by popularity, rather than searching the documents themselves? And how many suggestions?

Interviewer

Past queries by popularity. Show five.

You

How fresh must popularity be? If something starts trending, should it show up within minutes or is a day fine? And are suggestions personal?

Interviewer

A day is fine for most; trending within an hour would be nice. Not personalised for now.

You

Scale and speed: how many users, and how fast should suggestions appear?

Interviewer

50 million daily users, about 6 searches each a day. Suggestions should feel instant.

You

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.
Estimate: Memory for every prefix's top five

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?

You know
  • 10 million queries kept
  • Queries average 20 characters
  • About 150 bytes per prefix entry (the prefix plus five suggestions)

Show the working
  1. Each query has up to 20 prefixes: 107 × 20 = 2 × 108 prefixes at most
  2. 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.

Your turn: Define the API

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

HTTP 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 typing

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

Your turn: Model the data

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 table and offline counts
-- 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 people

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

Your turn: High-level design

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.

Keystrokes go through the edge to suggest servers that hold the whole table in memory. Searches feed an offline build that publishes a new table as a file.Browser or app to Edge cache (CDN) (GET ?q=pi); Edge cache (CDN) to Suggest servers (on miss); Suggest servers to Blocklist (refresh); Browser or app to Search service (submit search); Search service to Query log stream (log query), asynchronous; Query log stream to Build job, asynchronous; Build job to Table snapshots (new version); Table snapshots to Suggest servers (load and swap).Browser or appEdge cache (CDN)Suggest serversBlocklistSearch serviceQuery log streamBuild jobTable snapshotsGET ?q=pion missrefreshsubmit searchlog querynew versionload and swap

request and replyasynchronous message

Keystrokes go through the edge to suggest servers that hold the whole table in memory. Searches feed an offline build that publishes a new table as a file.
Keystrokes, then a search that changes tomorrow's listUse the arrows or ← → keys
  1. Keystrokes, then a search that changes tomorrow's list, step 1Browser or app to Edge cache (CDN); Edge cache (CDN) to Suggest servers; Suggest servers to Blocklist; Browser or app to Search service; Search service to Query log stream, asynchronous; Query log stream to Build job, asynchronous; Build job to Table snapshots; Table snapshots to Suggest servers.Browser or applocal missEdge cache (CDN)Suggest serversBlocklistSearch serviceQuery log streamBuild jobTable snapshots
    ms used
    30

    1Mia types "p", then "i", and pauses for 30 ms. Her app normalises the text to pi and checks its own cache first: nothing yet.

  2. Keystrokes, then a search that changes tomorrow's list, step 2Browser or app to Edge cache (CDN); Edge cache (CDN) to Suggest servers; Suggest servers to Blocklist; Browser or app to Search service; Search service to Query log stream, asynchronous; Query log stream to Build job, asynchronous; Build job to Table snapshots; Table snapshots to Suggest servers.Browser or appEdge cache (CDN)missSuggest serversBlocklistSearch serviceQuery log streamBuild jobTable snapshots
    ms used
    40

    2It sends GET /v1/suggest?q=pi to the nearest edge location. This edge has not cached pi in the last hour: a miss.

  3. Keystrokes, then a search that changes tomorrow's list, step 3Browser or app to Edge cache (CDN); Edge cache (CDN) to Suggest servers; Suggest servers to Blocklist; Browser or app to Search service; Search service to Query log stream, asynchronous; Query log stream to Build job, asynchronous; Build job to Table snapshots; Table snapshots to Suggest servers.Browser or appEdge cache (CDN)Suggest serverslookup piBlocklistSearch serviceQuery log streamBuild jobTable snapshots
    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.

  4. Keystrokes, then a search that changes tomorrow's list, step 4Browser or app to Edge cache (CDN); Edge cache (CDN) to Suggest servers; Suggest servers to Blocklist; Browser or app to Search service; Search service to Query log stream, asynchronous; Query log stream to Build job, asynchronous; Build job to Table snapshots; Table snapshots to Suggest servers.Browser or appEdge cache (CDN)Suggest serversBlocklistSearch serviceQuery log streamBuild jobTable snapshots
    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.

  5. Keystrokes, then a search that changes tomorrow's list, step 5Browser or app to Edge cache (CDN); Edge cache (CDN) to Suggest servers; Suggest servers to Blocklist; Browser or app to Search service; Search service to Query log stream, asynchronous; Query log stream to Build job, asynchronous; Build job to Table snapshots; Table snapshots to Suggest servers.Browser or appEdge cache (CDN)hitSuggest serversBlocklistSearch serviceQuery log streamBuild jobTable snapshots
    ms used
    60

    5Another user near the same edge types "pi" a minute later. Edge hit: the request never reaches a suggest server.

  6. Keystrokes, then a search that changes tomorrow's list, step 6Browser or app to Edge cache (CDN); Edge cache (CDN) to Suggest servers; Suggest servers to Blocklist; Browser or app to Search service; Search service to Query log stream, asynchronous; Query log stream to Build job, asynchronous; Build job to Table snapshots; Table snapshots to Suggest servers.Browser or applocal hitEdge cache (CDN)Suggest serversBlocklistSearch serviceQuery log streamBuild jobTable snapshots
    ms used
    30

    6Mia types "z", then deletes it. The list for pi comes from her app's own cache: no network at all.

  7. Keystrokes, then a search that changes tomorrow's list, step 7Browser or app to Edge cache (CDN); Edge cache (CDN) to Suggest servers; Suggest servers to Blocklist; Browser or app to Search service; Search service to Query log stream, asynchronous; Query log stream to Build job, asynchronous; Build job to Table snapshots; Table snapshots to Suggest servers.Browser or appEdge cache (CDN)Suggest serversBlocklistSearch serviceQuery log streamBuild jobTable snapshots

    7She submits "pizza near me". The search service runs the search and, off the hot path, logs the query to a stream.

  8. Keystrokes, then a search that changes tomorrow's list, step 8Browser or app to Edge cache (CDN); Edge cache (CDN) to Suggest servers; Suggest servers to Blocklist; Browser or app to Search service; Search service to Query log stream, asynchronous; Query log stream to Build job, asynchronous; Build job to Table snapshots; Table snapshots to Suggest servers.Browser or appEdge cache (CDN)Suggest serversBlocklistSearch serviceQuery log streamBuild jobrank 10M queriesTable snapshots

    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.

  9. Keystrokes, then a search that changes tomorrow's list, step 9Browser or app to Edge cache (CDN); Edge cache (CDN) to Suggest servers; Suggest servers to Blocklist; Browser or app to Search service; Search service to Query log stream, asynchronous; Query log stream to Build job, asynchronous; Build job to Table snapshots; Table snapshots to Suggest servers.Browser or appEdge cache (CDN)Suggest serversBlocklistSearch serviceQuery log streamBuild jobTable snapshotsv 2026-11-15

    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.

  10. Keystrokes, then a search that changes tomorrow's list, step 10Browser or app to Edge cache (CDN); Edge cache (CDN) to Suggest servers; Suggest servers to Blocklist; Browser or app to Search service; Search service to Query log stream, asynchronous; Query log stream to Build job, asynchronous; Build job to Table snapshots; Table snapshots to Suggest servers.Browser or appEdge cache (CDN)Suggest serversnow 2026-11-15BlocklistSearch serviceQuery log streamBuild jobTable snapshots

    10Each suggest server loads the new file into spare memory in the background, then swaps one pointer. Requests never see a half-loaded table.

  11. Keystrokes, then a search that changes tomorrow's list, step 11Browser or app to Edge cache (CDN); Edge cache (CDN) to Suggest servers; Suggest servers to Blocklist; Browser or app to Search service; Search service to Query log stream, asynchronous; Query log stream to Build job, asynchronous; Build job to Table snapshots; Table snapshots to Suggest servers.Browser or appEdge cache (CDN)Suggest serversstill 2026-11-15BlocklistSearch serviceQuery log streamBuild jobdownTable snapshots

    11If tomorrow's build fails, servers keep yesterday's table. Suggestions are a day staler, but every one of them is still valid.

1 / 11

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.

Top two per prefix: searching at request time versus storing the answerUse the arrows or ← → keys
  1. rootcdcadocar50cat40dog60care25cart30

    1Five past queries with their counts, in a trie. Each edge is one letter; a node's name is the prefix that reaches it.

  2. rootcdcadocar50cat40dog60care25cart30
    nodes visited
    3

    2Request time, plain trie: the user types "ca". Walk root → c → ca: 3 nodes, one per character plus the root.

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

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

  5. rootcdcadocarcatdogcarecarecartcart

    5Build time instead. Start at the bottom: the deepest leaves, "care" and "cart", each keep a list holding just themselves.

  6. rootcdcadocarcar, cartcatcatdogdogcarecarecartcart

    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.

  7. rootccar, catddogcacar, catdodogcarcar, cartcatcatdogdogcarecarecartcart

    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.

  8. rootdog, carccar, catddogcacar, catdodogcarcar, cartcatcatdogdogcarecarecartcart

    8The root merges "c" (car 50, cat 40) with "d" (dog 60): dog, car. Every node now holds its own answer.

  9. rootcdcacar, catdocarcatdogcarecart
    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.

1 / 9
Where the top five come from
OptionGood atBad atPick it when
Plain trie, search the subtree per requestSmall; 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 nodeLookup 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 defaultOne 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.

Keeping popularity fresh
OptionGood atBad atPick it when
Daily offline rebuild Usual defaultSimple, 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 tableFresh 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 overlayA 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:

StepBudget
Wait for a pause in typing (debounce)30 ms
Round trip to a nearby edge15 to 30 ms
Edge to suggest server and back, on a miss30 ms if the region is near
Lookup on the serverunder 1 ms
Draw the list10 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 diesWhat happensWhat you do
An edge locationUsers there are routed to the next nearest one, a little slower.Nothing; the CDN handles it. Watch origin load rise.
A suggest serverRequests to it fail until health checks remove it.Every server is identical, so the others take its share.
All suggest servers in a regionMisses go to another region and exceed the budget.The client shows no list on timeout and search still works.
The build jobNo new table tonight.Servers keep yesterday's table; alert if the table is more than 2 days old.
The log streamSome searches are not counted.Popularity is a statistic: a few lost hours barely change the ranking.

What a strong answer sounds like

LevelWhat the answer does
JuniorPuts a trie on a server and searches it per request; updates counts on each search when asked about popularity.
MidStores the top five per node, rebuilds offline, adds a cache, and debounces on the client.
SeniorTurns "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 suggestionStrong answer
Interviewer

An offensive suggestion is showing for a common prefix. How fast can you remove it?

You

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.

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.

Self-review: typeahead search0 of 13

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure