Search & Discovery
Design Search Autocomplete
01
What it must do
What it must do
- As the user types, return up to 10 suggested completions for the current prefix.
- Rank by how often people search for each phrase, weighted toward recent searches.
- Show the user's own recent searches that match the prefix above global suggestions.
- Surface sudden trends, such as breaking news, within about 10 minutes.
- Vary by language and country: "football" completions in Manchester and Dallas differ.
- Never suggest phrases on a blocklist, personal data, or completions that defame a person.
- Server-side latency p99 under 60 ms; end to end the list must appear before the next keystroke, roughly 100 to 150 ms.
- High availability, but an empty list is an acceptable degraded answer; search itself must never wait on suggestions.
- Suggestions may be hours old for steady queries, minutes old for trending ones.
- Removal of a harmful suggestion takes effect everywhere within 15 minutes.
- Cost per request must stay tiny, since there are several requests per search.
Settle early that you are designing query completion, which suggests what the user is about to type, and not instant results, which shows documents as they type. Completion works from a log of past queries and can be precomputed. Instant results need a full search index on every keystroke and make a much heavier system. Also ask whether spelling correction is in scope; it usually is not.
02
Estimating load
Estimating load
Assume 3 billion searches a day. With debouncing, a typical search produces about six suggestion requests before the user picks a suggestion or presses enter. Browsers and the CDN answer about half of those from cache, mainly for short, popular prefixes.
| Quantity | Working | Result |
|---|---|---|
| Searches per day | assumption | 3B |
| Suggestion requests per day | 3B x 6 after debouncing | 18B |
| Request rate | 18B / 86,400 s, peak 2.5x | about 208,000 / s, peak 520,000 / s |
| Reaching our servers | half absorbed by browser and edge caches | about 260,000 / s at peak |
| Distinct queries in 60 days | from logs, most seen once | about 1.4B |
| Phrases eligible to suggest | seen by at least 25 distinct users, passes filters | about 120M across all locales |
| Prefixes with a stored top-10 list | prefixes covering enough traffic, about 2.5 per phrase | about 300M |
| Serving index size | 300M x (12 B key + 10 x 4 B ids) + 120M phrases x 30 B | about 19 GB in total |
| Largest single locale | English, US | about 5 GB |
| Serving hosts | 260,000 / s at about 25,000 / s per host, x 2 for zones | about 21 per region, more for headroom |
| Log ingest | 3B searches x about 200 B per event | about 600 GB a day |
Without it, a 24-character query sends 24 requests. Waiting for about 100 ms of typing pause before sending cuts that to around six, because people type in bursts. That one client setting changes the server fleet by a factor of four.
At about 5 GB for the largest locale, every serving host can hold a complete copy of the locales it serves. No sharding, no cross-host fan-out, no network hop on the hot path. Scaling becomes adding replicas.
03
API design
API design
The global response depends only on prefix and locale, so browsers and the CDN can cache it for minutes. Mixing a user's history into it would make every response private and uncacheable. Keeping two calls, merged on the client, lets the expensive one be shared by millions of people.
04
System overview
System overview
flowchart LR
U["Search box"] --> CDN["Edge cache"]
CDN -->|miss| SV["Suggest servers"]
SV --> IDX[("In-memory prefix index")]
SV --> TR[("Trending overlay")]
U --> PS["Personal history service"]
U -. "search events" .-> K[["Event log"]]
K --> BATCH["Batch aggregation, every 6 h"]
K --> STR["Streaming trend detector"]
BATCH --> FIL["Filters and safety"]
FIL --> BLD["Index builder"]
BLD --> ART[("Versioned index files")]
ART --> SV
STR --> TRThree paths make up the system. The request path is a fleet of suggest servers, each holding complete in-memory indexes for its region's locales. A request does a single lookup of the normalised prefix, gets back ten phrase ids with scores, merges a few trending entries and returns. Nothing on this path touches a disk or another service.
The batch path runs every six hours. It reads the search event log, counts each phrase with time decay, removes anything that fails safety and privacy filters, and produces a new index file per locale. Servers download the new version, load it alongside the old one, and switch atomically. The streaming path watches the same events in near real time, spots phrases whose rate has jumped far above their usual level, and publishes a small trending overlay that servers merge at query time.
Stateless apart from the index they load. Each handles tens of thousands of lookups per second because each one is a hash or trie lookup in RAM.
Maps each stored prefix to its precomputed top ten. Built offline, read-only at serve time, which makes it compact and lock-free.
A few thousand fast-rising phrases per locale with short-lived boosts. Small enough to push to every server every minute.
A distributed job over the last 60 days of events with exponential decay, so last week counts more than last month. Output is a scored phrase table.
Minimum distinct users, blocklists, PII detectors, and a classifier for hateful or defamatory completions. Suppressions apply both here and live on servers.
The last few hundred searches per signed-in user in a key-value store, prefix-matched on request. Users can view and delete it.
05
Deep dive: making the lookup free
Deep dive: making the lookup free
A plain trie finds every phrase under a prefix, but for a short prefix like s that is millions of phrases, and sorting them per keystroke is hopeless. So store the answer, not just the structure: every node keeps its own ten best completions, computed when the index is built.
From a trie to a top-K table
Start with a trie of all eligible phrases, each leaf carrying a score. Walk it bottom up once: each node's top ten is the best ten among its own phrase, if it ends one, and its children's top tens. That merge is cheap because every child list is already sorted and short. After the walk, a query for any prefix is a descent of at most a couple of dozen characters and a read of ten ids, which is microseconds of work regardless of how many phrases share the prefix.
For serving, the tree shape itself is not needed. We flatten it into a hash table from prefix string to a list of ten phrase ids, plus a phrase table from id to text. We also skip storing lists for deep prefixes with very little traffic. If someone types a rare 30-character prefix, the server finds the deepest stored ancestor and filters its phrases, or returns nothing. That cut takes the index from billions of prefixes to about 300 million.
flowchart TD
K["User types, 100 ms pause"] --> BC{"Browser cache has prefix?"}
BC -->|yes| SHOW["Render list"]
BC -->|no| EDGE{"Edge cache has prefix and locale?"}
EDGE -->|yes| SHOW
EDGE -->|no| SRV["Suggest server"]
SRV --> N["Normalise: lowercase, trim, fold accents"]
N --> LK["Look up top 10 for prefix"]
LK --> TO["Merge trending boosts"]
TO --> SUP["Drop live suppressions"]
SUP --> RET["Return and cache 5 min"]
RET --> MERGE["Client merges personal history"]
MERGE --> SHOWScoring phrases
Raw counts favour phrases that were popular for years and miss what people want today. Each event adds weight that decays with a half-life of about a week, so a phrase searched heavily in the last few days outranks one searched evenly over two months. We count distinct users, not raw events, so one script repeating a query cannot push it up. Clicks matter too: a suggestion that is shown often and rarely chosen gets demoted, which removes completions people find useless or embarrassing.
The client is half the latency story
The browser or app does more than draw a list. It waits for a short pause in typing before sending, and it cancels any request whose prefix the user has already typed past, so stale answers never flash on screen. It keeps a small in-memory cache of recent prefixes, so backspacing is instant. When a response arrives flagged as complete, meaning fewer than ten phrases exist under that prefix, the client can answer every longer prefix by filtering that list locally without calling the server at all. On mobile networks, where a round trip can take 200 ms on its own, the app may also prefetch the next likely prefix in the background while the user is still reading the list.
Normalisation has to match on both sides. The server lowercases, trims repeated spaces, folds accents and applies script-specific rules, and the client applies the same rules before checking its cache. For languages written without spaces, or typed through an input method that composes characters, the client sends the composed text rather than raw keystrokes, and the index is built over the same units.
Trending without rebuilding
Rebuilding a 19 GB index every few minutes is wasteful, and most of it would not change. Instead, the streaming detector keeps a sliding count per phrase over the last 10 minutes and compares it with the same phrase's usual rate for that hour. A phrase whose rate is many times its baseline, from enough distinct users, enters the overlay with a boost that fades over a few hours. The batch job eventually absorbs real trends into the main index, and the overlay entry expires. The risk is manipulation: coordinated accounts can try to fake a trend, which is why overlay entries need a distinct-user floor and pass the same safety filters as everything else.
06
Trade-offs
Trade-offs
Constant-time reads no matter how common the prefix. The price is a bigger index and a build step that must run before any change shows up.
Latency independent of prefixSmaller index and always current. For one-letter prefixes it means visiting millions of nodes per request, which no latency budget survives.
Too slow for short prefixesThe big index is immutable, compact and easy to validate before release. Fresh trends arrive through a tiny overlay that updates every minute. Two pipelines to run, but each is simple.
Fresh where it mattersEach write would have to update top-K lists on every ancestor node under concurrent reads, needing locks or copy-on-write across hundreds of replicas. It also lets abuse reach users with no filtering pass.
Complex and unsafeEach locale's index is a few gigabytes, so every server in a region holds all the locales that region serves. Requests never cross hosts and any replica can answer any request.
No fan-out, simple scalingNeeded only if the index outgrows a host's memory. It brings hot shards for popular first letters and a routing layer, so avoid it until the numbers force it.
Only if it stops fittingSend after a short pause, cancel requests that a newer keystroke makes obsolete, and if a response said it was complete, filter it locally for longer prefixes. Server load drops by about four times.
Cheapest scaling there isFeels slightly more responsive on fast networks, but most responses arrive after the user has typed past them and are thrown away.
Wasted work07
What can fail
What can fail
A host crashes or is drained. Its in-flight requests fail, and a replacement has to load several gigabytes before it can serve.
Every replica is identical, so the load balancer simply routes around it. New hosts pull index files from a nearby blob store and only join the pool after loading and a self-test. The client treats a failed call as an empty list.An upstream bug drops half the events from one day. The resulting index is missing common phrases, or a filter step silently failed and harmful completions slipped through.
Validate every build before release: size within a few percent of the previous one, a fixed set of golden prefixes return expected results, and blocklist checks pass. Roll out to one region first and keep the previous version on disk for instant rollback.Holding the old and new versions at once doubles memory during the swap. On a host sized tightly, the process is killed mid-load.
Size hosts for two copies, or memory-map index files so the operating system pages them in. Stagger swaps across the fleet so only a fraction of capacity is loading at any moment.A network of bots searches "brand X scam" thousands of times an hour to make it appear when people type the brand name.
Count distinct, trusted users rather than events, ignore traffic flagged by abuse systems, require trending phrases to come from varied networks and regions, and run entity-plus-negative-term patterns past a classifier. Keep a fast human suppression path.The trend detector falls behind during a traffic spike, which is exactly when trends happen. Breaking news takes half an hour to appear.
Scale the detector on consumer lag and, when behind, process only the newest events, since an old trend signal is worth little. Suggestions from the main index keep working throughout.08
Mistakes to avoid
Mistakes to avoid
Even with an index, it scans and sorts every matching row on each keystroke. It works for a thousand phrases and fails at a hundred million.
Raw logs contain email addresses, phone numbers and things people would never want shown to strangers.
Every response becomes private, the CDN cannot cache anything, and server traffic roughly doubles.
09
Interview advice
Interview advice
Start with the latency budget. Say that results must appear between two keystrokes and that leaves tens of milliseconds on the server. It justifies precomputation over query-time search immediately.
Draw the trie, then improve it. Show the plain trie, explain why short prefixes are slow, and add the top-K list per node. That progression is exactly what interviewers want to see.
Estimate the index size. Showing that it fits in memory on one host removes sharding from the discussion and lets you scale with replicas.
Separate the build from serving. Interviewers often ask how new queries get in. Answer with an offline pipeline plus a streaming trending overlay, not with live writes into the trie.
Mention debouncing and client caching. These cut load by several times for free, and candidates often forget the client entirely.
Bring up safety. Autocomplete has caused real legal and reputational trouble. Filters, distinct-user thresholds and a fast suppression path show judgement.
Keep personalisation light. A user's recent searches merged on top is usually enough. Explain how it stays out of the cached global response.
10
Evolving the design
Evolving the design
Load last month's queries, build a trie with top-K per node, serve from the same app. Restart to pick up a new build. Good for a few hundred requests a second.
Search events go to a log, a batch job scores phrases with time decay, and dedicated suggest servers load versioned index files with a hot swap. Debouncing goes into the client.
One index for everyone gives irrelevant suggestions abroad. Build per locale, cache global responses at the edge, and add privacy and policy filters with a suppression API.
A streaming detector feeds trending phrases within minutes, and a small model reorders each top-K list using click-through and context, while the lookup itself stays a single read.
Watch and read
Try next
Free to read · better with landed
Whiteboard this with landed
landed runs it as a live system design round on the whiteboard and grades your trade-offs.

