← System design

Search & Discovery

Design Search Autocomplete

Autocomplete feels like search, but it is closer to a lookup table. By the time someone types "how to b", the answer has to be on the screen before the next key lands, which leaves no time to search anything. The work happens hours earlier, in a pipeline that counts what people search for and stores the best handful of completions next to every prefix, so the request path is one memory read.
Core: precomputed top-K per prefix3B searches a day520,000 suggestion calls a second at peakp99 under 60 ms server side

01

What it must do

02

What it must do

Functional
  • 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.
Non-Functional
  • 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.
Agree on the boundary

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

03

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.

QuantityWorkingResult
Searches per dayassumption3B
Suggestion requests per day3B x 6 after debouncing18B
Request rate18B / 86,400 s, peak 2.5xabout 208,000 / s, peak 520,000 / s
Reaching our servershalf absorbed by browser and edge cachesabout 260,000 / s at peak
Distinct queries in 60 daysfrom logs, most seen onceabout 1.4B
Phrases eligible to suggestseen by at least 25 distinct users, passes filtersabout 120M across all locales
Prefixes with a stored top-10 listprefixes covering enough traffic, about 2.5 per phraseabout 300M
Serving index size300M x (12 B key + 10 x 4 B ids) + 120M phrases x 30 Babout 19 GB in total
Largest single localeEnglish, USabout 5 GB
Serving hosts260,000 / s at about 25,000 / s per host, x 2 for zonesabout 21 per region, more for headroom
Log ingest3B searches x about 200 B per eventabout 600 GB a day
Debouncing is a capacity decision

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.

The whole index fits in memory

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

04

API design

GET/v1/complete?q=how+to+b&hl=en&gl=GB&n=10Suggestions for a prefix
// Cache-Control: public, max-age=300 (only when no personal results are mixed in) { "prefix": "how to b", "index_version": "2026-10-01T06", "suggestions": [ { "text": "how to boil an egg", "kind": "popular" }, { "text": "how to block a number", "kind": "popular" }, { "text": "how to bake sourdough", "kind": "trending" } ], "complete": true // fewer than n matches exist: client can filter locally }
GET/v1/complete/personal?q=how+to+bMatching items from the user's own history
// Authenticated, never cached by shared caches { "recent": ["how to bleed a radiator"] }
POST/v1/search-eventsLog what was typed and what was chosen
{ "final_query": "how to bleed a radiator", "typed_prefix": "how to b", "picked_rank": 0, "locale": "en-GB" } // Batched by the client, sent after the search; fuels both pipelines
POST/internal/v1/suppressionsRemove a suggestion everywhere
{ "pattern": "exact:some harmful phrase", "locales": ["*"], "reason": "legal" }
Why split personal from global

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

05

System overview

Offline build, streaming overlay and the request pathDiagram
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 --> TR

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

Suggest servers

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.

Prefix index

Maps each stored prefix to its precomputed top ten. Built offline, read-only at serve time, which makes it compact and lock-free.

Trending overlay

A few thousand fast-rising phrases per locale with short-lived boosts. Small enough to push to every server every minute.

Batch aggregation

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.

Filters

Minimum distinct users, blocklists, PII detectors, and a classifier for hateful or defamatory completions. Suppressions apply both here and live on servers.

Personal history

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

06

Deep dive: making the lookup free

The trick in one sentence

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.

# Build step: bottom-up merge of top-K lists def build(node): cands = [(node.score, node.phrase_id)] if node.is_phrase else [] for child in node.children.values(): cands.extend(build(child)) node.top = heapq.nlargest(10, cands) return node.top # Serve step: one lookup, nothing else def suggest(prefix, locale): key = normalise(prefix) return merge(index[locale].get(key, []), trending[locale].get(key, []))
What happens on one keystrokeDiagram
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 --> SHOW

Scoring 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

08

Trade-offs

Chosen
Top ten precomputed for each stored prefix

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 prefix
Rejected
Walk the subtree and sort on each request

Smaller 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 prefixes
Chosen
Batch rebuild plus a streaming overlay

The 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 matters
Rejected
Update the live index on every search

Each 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 unsafe
Chosen
Index per locale, fully replicated

Each 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 scaling
Alternative
Shard one global index by prefix range

Needed 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 fitting
Chosen
Client debouncing and local reuse

Send 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 is
Rejected
One request per keystroke

Feels slightly more responsive on fast networks, but most responses arrive after the user has typed past them and are thrown away.

Wasted work

07

What can fail

09

What can fail

💀
A suggest server dies

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.
🧱
A bad index build ships

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.
🔁
Loading the new index exhausts memory

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.
🤖
Someone tries to plant a suggestion

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 streaming pipeline lags

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

🚫
Running a LIKE 'prefix%' query on the search log table

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.

Instead: precompute the ranked answer for each prefix offline and serve it from memory.
🚫
Suggesting anything that has been searched

Raw logs contain email addresses, phone numbers and things people would never want shown to strangers.

Instead: require a minimum number of distinct users and run privacy and safety filters before a phrase can be suggested.
🚫
Mixing personal history into the cacheable response

Every response becomes private, the CDN cannot cache anything, and server traffic roughly doubles.

Instead: serve global and personal suggestions from separate calls and merge them on the client.

09

Interview advice

10

Interview advice

1

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.

2

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.

3

Estimate the index size. Showing that it fits in memory on one host removes sharding from the discussion and lets you scale with replicas.

4

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.

5

Mention debouncing and client caching. These cut load by several times for free, and candidates often forget the client entirely.

6

Bring up safety. Autocomplete has caused real legal and reputational trouble. Filters, distinct-user thresholds and a fast suppression path show judgement.

7

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

12

Evolving the design

Stage 1: a site search box
A trie built nightly in one process

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.

Stage 2: real traffic
Event log, batch job and a separate serving tier

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.

Stage 3: many countries
Locale indexes, edge caching and safety filters

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.

Stage 4: freshness and quality
Trending overlay and learned ranking

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.