Blueprint
Fundamentals intermediate

Design search autocomplete

Every keystroke is a query, so a modest search product becomes a 20×-amplified read system — and the naive top-k computation is a hundred times too slow.

asked at Google · Amazon · Meta Sometimes asked ~35 min walkthrough
01

Scope the requirements

The trap here is treating it as a search problem. It's a precomputation problem: the ranking work happens offline, hours before the keystroke, and the online path is a single key-value lookup. Interviewers score whether you find that inversion early.

Functional — what it must do

  • As the user types, return the top 5-10 suggestions for the current prefix on every keystroke.
  • Suggestions ranked by historical query popularity — frequency of past searches.
  • Suggestions refresh from search logs on a schedule; hourly-to-daily staleness is acceptable, real-time trending is a stretch goal I'll flag.
  • Filter offensive and blocked terms before anything reaches a screen.
  • Explicitly cut: spell correction, personalisation, and multi-language — I'll assume lowercase alphabetic queries for v1 and offer these back as extensions.

Non-functional — the numbers that shape the design

  • Latency under 100 ms end-to-end per keystroke, ideally under 50 ms server-side — anything slower and suggestions arrive after the user has typed past them.
  • Read-heavy to an extreme: every search generates ~20 autocomplete requests, one per keystroke.
  • High availability on the read path; eventual consistency is fine — a suggestion list a day stale is invisible to users.
  • Scale target: 10M DAU, ~20,000 QPS sustained, double at peak.
  • Suggestion data survives node restarts — the in-memory structure needs a durable source to rebuild from.
Say this out loud

The latency budget is the whole question. Under 100 ms per keystroke means no ranking, no scoring, no database scan at query time — the top-k list for every prefix has to already exist before the user types it. Everything interesting happens in the offline pipeline.

02

Back-of-envelope estimates

The numbers make two decisions for me: the keystroke amplification says the read path must be a cache hit, and the tiny daily data growth says the trie rebuild is an easy batch job, not a streaming problem.

QuantityEstimateHow you got there
Searches~1,200/s10M DAU × 10 searches/day ÷ 86,400 s.
Autocomplete QPS~24,000/s~20 keystrokes per search × 1,200 searches/s. This is the number that matters.
Peak QPS~48,000/s2× average; suggestion service is stateless so this is a horizontal-scale problem, not a redesign.
New query data~0.4 GB/day20% of 100M daily queries are new × ~20 bytes each. Trivial — the analytics store yawns.
Trie size~tens of GBMillions of distinct queries × prefix nodes × cached top-10 lists. Fits in memory on a few shards.
Client debounce saving~30-50%A 50 ms debounce plus browser caching cuts QPS before it ever reaches me — say this early, it's free capacity.
Say this out loud

48k QPS at peak with a 100 ms budget means every request is one memory lookup. And 0.4 GB of new data a day means I can rebuild the whole structure offline daily — the write path and read path never meet.

03

Sketch the API

One public endpoint. The interesting decisions are on the client, which most candidates forget owns a third of the performance story.

GET/v1/suggestions?q={prefix}&limit=10→ ordered list of suggestions. No auth needed, aggressively cacheable, the entire hot path.
GET/v1/suggestions?q={prefix}&locale=en-GBSame endpoint with a locale hint — each locale maps to its own trie, so this is a routing key, not a filter.
POST/internal/search-logSearch services log { query, timestamp } into the analytics pipeline. Internal, async, never blocks a search.
  • The client is part of the design: debounce keystrokes by ~50 ms, cache prefix results locally (typing then backspacing re-hits the same prefixes), and cancel in-flight requests when a new keystroke lands — otherwise stale responses race and overwrite fresher ones. Saying this unprompted signals you've built one.
  • Responses carry Cache-Control: max-age=3600 — suggestions barely change hour to hour, so let browsers and the CDN absorb repeat prefixes. The cost is up to an hour of staleness, which the requirements already accepted.
04

Data model

Two worlds: an analytics table that accumulates truth slowly, and a serving structure built from it. The serving structure is the famous part — a trie with the answers precomputed into it.

query_frequencies
query PK · frequency · last_updated
Lives in the analytics warehouse, fed by aggregated search logs. Source of truth for every trie build; never queried online.
trie nodes (serving)
prefix → [top-10 suggestions, precomputed]
Each node caches the top-k for its entire subtree. A lookup is O(prefix length) pointer walks then one read — no traversal, no sorting.
trie snapshots
shard_id · version · blob · built_at
Serialized trie shards in a KV store (or object storage). Serving nodes load these on startup — the durability story for an in-memory structure.
The database call

The serving store is really a key-value problem — key is the prefix, value is a precomputed list — so Redis (or any KV store holding serialized trie shards) fits exactly, with the warehouse behind it for rebuilds. I would not put this in Postgres: there's no relational query here, and a LIKE 'prefix%' scan with an ORDER BY frequency is precisely the query-time work this design exists to avoid. If the team already runs Elasticsearch, its completion suggester is the pragmatic alternative — I'd take it for operational simplicity and give up fine control over ranking and latency.

05

High-level design

Two decoupled paths. The query path is boring by design: load balancer, stateless suggestion service, trie cache. The data path is a batch pipeline that turns raw search logs into a fresh trie snapshot on a schedule.

client edge service data pipeline Browser debounce + cache Load balancer Suggestion service stateless + filter Search service logs queries Trie cache Redis, sharded Trie DB snapshots Shard map ZooKeeper Query logs Kafka Trie builder aggregate + build GET ?q=pre prefix lookup new snapshot on miss full search warm route query log
the query path (solid) never touches the pipeline (dashed). notice the suggestion service reads a structure the trie builder finished writing hours earlier — the two sides only share the trie DB.
  1. User types a character → client debounces ~50 ms, checks its local cache, then sends GET /v1/suggestions?q=pre.
  2. The suggestion service consults the shard map to find which trie shard owns this prefix, reads the precomputed top-10 from the Redis trie cache (~1 ms), passes it through the blocked-terms filter, and returns.
  3. On a cache miss (rare — the cache is warmed from snapshots), it reads the trie DB and back-fills Redis.
  4. Meanwhile, completed searches flow into Kafka. Aggregators sample and count them into the frequency table.
  5. On schedule (daily, hourly if the product wants fresher), the trie builder constructs a new trie from the frequency table, writes versioned snapshots to the trie DB, and warms the cache shard by shard — an atomic version flip, never an in-place mutation.
06

Deep dives — where the interview is won

Phase 01

Precompute top-k at every node — query-time traversal is the classic fail

The naive trie answers a prefix query by walking to the prefix node, then doing a DFS over the entire subtree to collect candidates and sorting them by frequency. For a two-letter prefix like be that subtree holds hundreds of thousands of queries — the traversal alone blows the 100 ms budget by orders of magnitude, and it does so on the shortest prefixes, which are the most common requests. This is the single most common way candidates fail this question.

The fix is to move all of that work to build time: every node stores its own top-10 list, computed once by the trie builder in a bottom-up pass (a node's top-10 is a merge of its children's top-10s plus itself). A query is now O(prefix length) pointer hops and one list read — effectively constant time. I'd also cap indexed prefix length at ~50 characters; nobody needs autocomplete on character 51, and it bounds node count.

query trie "pi" prefix Node 'pi' top-5 precomputed O(2)
each trie node stores its top-k completions. query is O(prefix length), not O(descendants).
Trade-offStoring top-10 at every node multiplies storage several-fold and makes updates expensive — one query's frequency bump touches every ancestor's cached list — which is exactly why updates happen in offline rebuilds, never online.
Phase 02

Shard by prefix range, not by first letter

Tens of GB of trie doesn't fit one node once you add replicas and headroom, so it shards. The reflex answer — 26 shards, one per first letter — is badly skewed: prefixes starting with 's' or 'c' carry orders of magnitude more queries than 'x' or 'z', so one shard melts while others idle.

The standard fix is a shard-map manager: analyse the historical prefix distribution and split so each shard carries roughly equal query volume — perhaps one shard owns all of 's', another owns 'u' through 'z' combined. The suggestion service consults the (tiny, heavily cached) shard map to route each prefix. The map only changes when a rebuild rebalances it, so it's a static lookup in practice, not a coordination bottleneck.

req shards Query a-c d-l m-s
prefix ranges keep hot letters balanced. sharding by first letter puts 's' 4× larger than 'q'.
Trade-offA shard map adds one indirection and a component to operate versus dumb hashing — but hashing a prefix is a non-starter anyway, since be and bes must land on the same shard for the trie to make sense. Range-based sharding is forced; the map just makes the ranges fair.
Phase 03

Freshness: a daily rebuild plus a small trending patch

A daily rebuild is right for the base ranking — query popularity moves slowly, and rebuilding offline keeps the serving path read-only. But it fails visibly on breaking news: a story erupts at 9am and autocomplete doesn't know until tomorrow. If the interviewer pushes on this, do not offer to update the trie per-query online — one search updates every prefix ancestor's top-10, a write amplification of ~20× at 24k QPS against the structure you're serving reads from.

The layered answer: keep the big trie on its daily rebuild, and run a stream job (Kafka into Flink) that computes a small time-decayed trending set — the last hour's fast risers, a few thousand entries. The suggestion service merges the trending patch into results at query time. Two structures, two update cadences, and the hot path stays a lookup plus a tiny merge.

batch live serve Nightly rebuild Trending patch 10 min updates Merged view overlay base
nightly rebuild sets the baseline; trending delta merged in memory at read time.
Trade-offThe merge adds a little query-time work and a second pipeline to operate; the alternative — rebuilding the full trie hourly — burns compute re-ranking millions of queries whose frequencies didn't move.
Phase 04

Durability and deploys for an in-memory structure

The trie lives in memory, so I need answers for "a node restarts" and "a new version ships". Both come from the same mechanism: the trie builder writes versioned, serialized shard snapshots to durable storage. A restarting server loads its shard's latest snapshot — no replaying logs, no rebuilding from the warehouse on the critical path. Losing a cache node costs seconds of degraded latency, never data, because the source of truth is the frequency table upstream.

Version flips are how updates deploy: serve version N while loading N+1 alongside, then atomically switch pointers and drop N. Readers never see a half-built trie. The blocked-terms filter deliberately sits outside the trie, applied at response time — so removing a newly offensive suggestion is a config push, not an emergency rebuild.

servers source Node 1 RAM Node 2 RAM S3 snapshot warm warm
each server holds trie in RAM. deploys are rolling; snapshot in s3 for cold start.
Trade-offDouble-buffering versions briefly doubles memory per shard during the flip; the alternative — mutating the live trie — risks serving torn reads and makes rollback a rebuild instead of a pointer swap.
07

Follow-ups you should expect

How would you support personalised suggestions?
Keep the shared trie as the base and blend a small per-user layer at the edge: the user's own recent searches live client-side or in a per-user KV entry, and the client merges them ahead of global results. Personalising the trie itself explodes the precomputation — you'd need a trie per user cohort — so the answer is layering, same pattern as trending.
What about multi-language support?
Unicode tries work, but the practical answer is a trie per locale, routed by the locale parameter. Ranking data is naturally per-locale anyway — top queries in Japan aren't top queries in Brazil. CJK languages need extra care since input methods produce composition candidates, which argues for the per-locale split rather than one global structure.
Why not Elasticsearch's completion suggester instead of building all this?
It's a legitimate answer and I'd say so: ES ships an FST-based suggester that handles most of this out of the box. I'd choose it if the org already operates ES. Building the trie service buys tighter tail latency, full control over the ranking pipeline, and cheaper serving at very high QPS — infrastructure-scale companies build; product teams should probably buy.
How do you handle a trending offensive query — something hateful suddenly spiking?
The trending pipeline makes this urgent since it surfaces within minutes. The blocked-terms filter runs at response time between cache and client, so a config push suppresses a term in seconds without touching any trie. Trending candidates additionally pass a stricter classifier before entering the patch set — new queries are riskier than established ones.
What breaks first at 10× traffic?
Nothing structural — that's the point of this shape. The suggestion tier is stateless and the trie shards replicate for read throughput, so 480k QPS is more replicas and more shards, plus the CDN absorbing repeat prefixes. The build pipeline scales separately since it's offline. The design changes capacity, not shape; I'd say that sentence to the interviewer explicitly.

Where candidates lose the room

  • Computing top-k at query time by traversing the subtree under the prefix — the number-one fail; it's a build-time job, and the interviewer is waiting for you to see that.
  • No client-side debounce, caching, or request cancellation — you multiply your own QPS by 2-3× and let stale responses race fresh ones.
  • Updating the trie synchronously on every search — one query bumps every prefix ancestor, a ~20× write amplification aimed at your own read path.
  • Sharding alphabetically without mentioning skew — 's' is not 'x', and the interviewer knows it.
  • No persistence story for the in-memory trie — a restart shouldn't mean rebuilding from raw logs while users get no suggestions.

Concepts this leans on

Found a bug or want more?

Tell me what's broken, missing, or wrong. Every message lands in my inbox.