Vibe Engines
YouTube
System Design

Design Typeahead

Step 1 / 9

Learn system design by building a search typeahead / autocomplete step by step.

The numbers to beatO(L)lookup by prefixmillionsqueries indexed1shared path / prefix

The whole design, in writing

Learn system design by building a search typeahead / autocomplete step by step. An interactive guide covering prefix tries, top-k ranking, caching hot prefixes, building suggestions from query logs, personalization, and sharding.

Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.

The big idea

What is typeahead?

You start typing in a search box and a list of completions appears — and updates on every keystroke. It feels instant, but behind it you’re searching millions of possible queries, ranking them, and returning the best ten, in the time between two letters.

Usertypes 'sys…'
New in this step: User.

The trick is to do almost no work at request time. We precompute the best completions for every prefix and look them up by walking a tree — turning “search” into a near-instant lookup, not a scan.

What the new pieces do

Userclient
Someone in a search box. After each keystroke they expect a fresh list of completions in well under 100ms — faster than they can think.

Step 1 · The skeleton

Prefix in, matches out

At its core there’s one operation: given a prefix like sys, return the most likely full queries — system design, system of a down, systemctl. Where do those candidates even come from, and how do we find them fast?

Usertypes 'sys…'Suggest APIprefix → top 10
New in this step: Suggest API.

Stand up a Suggest API: the box calls GET /suggest?q=sys on every keystroke and gets back a ranked list. Everything hard lives behind this one endpoint, which exists to do exactly one thing as fast as possible.

What the new pieces do

Suggest APIbackend
Takes a prefix and returns the best handful of completions. The hot path: it runs on every keystroke for every user, so it must be brutally fast.

Step 2 · Find matches fast

The trie

Scanning every known query to find ones starting with sys is far too slow at millions of queries. A database LIKE 'sys%' index helps, but still does real work per request — multiplied by every keystroke of every user.

Trie Serviceprefix treeTrie Storeserialized nodes
New in this step: Trie Service, Trie Store.

Find every query starting with "sys" among millions — on every keystroke. How?

  1. An index helps, but it still does real per-request work and degrades for the short, high-traffic prefixes — multiplied by every keystroke of every user. Fine for a form, not for typeahead scale.

  2. A search engine ranks documents; it’s heavyweight for "walk to a prefix and read its subtree" under 50ms per letter. Wrong tool for the hot path.

  3. Each node is a character; following the prefix lands on the subtree of all completions. Finding candidates is O(prefix length), independent of how many queries exist.

Use a trie (prefix tree): each node is a character, and the path s→y→s leads to the subtree of all completions beginning with “sys”. Finding the candidate set becomes walking a few pointers — O(length of the prefix), independent of how many queries exist.

  • O(L)lookup by prefix
  • millionsqueries indexed
  • 1shared path / prefix

What the new pieces do

Trie Serviceservice
Walks a prefix tree where each node is a letter. Following the path for “sys” lands on the subtree of every completion that starts that way.
Trie Storestore
Durable storage of the serialized trie. Loaded into memory by the Trie Service; rebuilt offline as query popularity shifts.

Back of the envelope

lookup = O(prefix length)
independent of how many queries exist
"system", "systemd", "systematic"
share one "system" path — zero duplication
walk to node ⇒ subtree = matches
finding candidates is pointer-following, not scanning

Step 3 · The hottest prefixes

Cache the common case

Even an O(L) trie walk plus gathering and ranking the subtree adds up when it runs billions of times a day. And traffic is wildly skewed: a tiny set of short prefixes (a, fa, you) accounts for most requests.

Suggest APIprefix → top 10Suggestion Cacheprefix → listTrie Serviceprefix tree
New in this step: Suggestion Cache.

A tiny set of short prefixes ("a", "you") drives most traffic. How do you exploit that?

  1. Replicas add throughput but every hot request still redoes the same walk + rank. When the same few prefixes repeat constantly, recomputing the identical answer is wasted work.

  2. A small set of short prefixes drives most traffic, so a prefix-keyed cache turns the common case into a sub-ms memory lookup. Misses fall through to the trie and warm the cache.

  3. Per-session caching has terrible hit rates — the value is in prefixes shared across millions of users, not one person’s history. Cache by prefix, the thing everyone collides on.

Put a cache in front keyed by prefix → its precomputed top-10. A request for a hot prefix never touches the trie; it returns the cached list in under a millisecond. Misses fall through to the Trie Service, then warm the cache.

  • <1mscache hit
  • ~90%hit rate
  • top-10per prefix

What the new pieces do

Suggestion Cachecache
Redis holding precomputed top-k lists for hot prefixes. Most traffic is short, common prefixes, so a cache hit answers in under a millisecond.

Back of the envelope

short prefixes ≈ most traffic
a handful of prefixes drives the bulk of requests
~90% hit ⇒ <1ms answer
the common case is a memory lookup, never the trie
miss ⇒ trie walk, then warm
the long tail still resolves correctly

Step 4 · Which ten?

Rank the candidates

A prefix like a matches millions of queries. Returning them alphabetically is useless — the user wants the ones people actually search, with the best on top. We need an order, not just a set.

Suggestion Cacheprefix → listTrie Serviceprefix treeRankertop-k by scoreTrie Storeserialized nodesQuery Countspopularity
New in this step: Ranker, Query Counts.

The prefix "a" matches millions of queries. How do you return the best 10 in time?

  1. Alphabetical ignores what people actually search — "aardvark" over "amazon". Order must come from real signal, not the dictionary.

  2. Sorting millions of matches on every keystroke is exactly the live work typeahead can’t afford. The ranking must already be done before the request arrives.

  3. Rank offline when the trie is built and cache each node’s best 10. At request time you read a ready-made list — O(1) ranking on the hot path.

Score candidates with a Ranker using Query Counts (popularity), plus recency and length. Crucially, store only the top-k at each trie node so the answer is precomputed — at request time you read a ready-made list, you don’t sort millions of things live.

  • top-10stored / node
  • popularityprimary signal
  • offlineranking cost

What the new pieces do

Rankerservice
Of the thousands of matches under a prefix, keeps only the best ~10 — scored by popularity, recency and personalization — so the list is useful, not exhaustive.
Query Countsstore
How often each query has been searched. The raw signal the Ranker uses to decide which completions are worth showing.

Back of the envelope

rank offline at build time
the expensive sort happens once, not per request
store top-10 per trie node
each node caches its best children
request = read ready-made list
O(1) ranking on the hot path

Step 5 · Stay current

Build it from what people search

Suggestions go stale. A query that trended yesterday should appear today; a dead one should fade. But you can’t mutate a giant shared trie on every search without wrecking read performance.

RankerQuery LogTrie Builder
New in this step: Query Log, Trie Builder. · swipe to pan the diagram

Yesterday’s trending query should surface today — but you can’t mutate a giant shared trie live. How?

  1. Writing to the shared trie on every read wrecks read performance and risks serving a half-updated tree. The serve path must stay immutable and fast.

  2. Aggregate the query log into fresh counts, rebuild the trie with new top-k lists, and flip to it atomically. Readers always see a complete version N, then N+1 — never a partial tree.

  3. Rebuilding per request is absurdly expensive and defeats precomputation entirely. Build is heavy and rare; serve is light and constant — keep them apart.

Log every search to a Query Log (Kafka). A periodic Trie Builder aggregates those logs into fresh counts, rebuilds the trie with new top-k lists, and atomically swaps the new version in. Reads always hit a complete, immutable trie.

What the new pieces do

Query Logbus
A stream of everything users actually search. The fuel for keeping suggestions current — what was trendy yesterday should surface today.
Trie Builderworker
A batch job that folds the query log into fresh counts and rebuilds the trie with updated top-k lists, then swaps it in atomically.

Step 6 · Make it personal

Your suggestions, not everyone’s

Global popularity is a decent default, but doc should probably suggest the user’s documents or recent searches before the world’s most popular “doc”. One-size-fits-all ranking leaves relevance on the table.

Rankertop-k by scoreUser Historypersonalize
New in this step: User History.

"doc" should suggest the user’s own docs before the world’s most popular "doc". How?

  1. A full trie per user is enormous and impossible to keep fresh for hundreds of millions of people. Personalization should re-weight a small candidate set, not duplicate the index.

  2. Pure personal history is too sparse — most prefixes a user types they’ve never typed before. You need the global base for coverage and personal signal for relevance.

  3. Two stages: the global precomputed top-k narrows millions to a handful, then a light per-user re-weight reorders just those few. Expensive work stays global; per-user work stays tiny.

Blend in User History: mix each person’s recent and frequent queries into the score at request time. The global top-k from the trie is the base; personalization re-weights it for the individual.

What the new pieces do

User Historystore
A user’s own recent searches. Blended into ranking so “doc” suggests their docs, not just the globally popular ones.

Step 7 · Scale & the sharp edges

A trie too big for one box

Multi-language, multi-region suggestions don’t fit in one machine’s memory. And firing a request on every keystroke can melt your backend if a user types fast.

UserSuggest APISuggestion CacheTrie ServiceRankerTrie StoreQuery CountsQuery LogTrie BuilderUser History
The system as it stands at this step. · swipe to pan the diagram

The multi-language trie won’t fit in one box, and fast typists fire a request per letter. Fix?

  1. No single machine holds a multi-language trie in memory, and caching doesn’t solve the per-keystroke request storm. You need to split the index and cut the request rate.

  2. The first character is a natural shard key — route a–f, g–m, … to different replicated servers. Debouncing (~50ms) and cancelling stale requests means only meaningful prefixes hit the backend.

  3. Shipping a multi-gigabyte, constantly-changing index to every browser is impossible. The trie stays server-side and sharded; the client only debounces and renders.

Shard the trie by prefix (route a–f, g–m, … to different servers) behind a load balancer, and replicate each shard for read throughput. On the client, debounce keystrokes (~50ms) and cancel stale requests so only meaningful prefixes hit the server.

You did it

You just designed typeahead.

UserSuggest APISuggestion CacheTrie ServiceRankerTrie StoreQuery CountsQuery LogTrie BuilderUser History
The finished design, end to end. · swipe to pan the diagram

Everything you assembled, in order

  • A single Suggest API on the hot path, called every keystroke.
  • A trie turns “find completions of a prefix” into an O(L) walk.
  • A prefix-keyed cache answers the skewed, common prefixes in <1ms.
  • Precomputed top-k at each node — ranking happens offline, not live.
  • A query log + offline builder keep suggestions fresh, swapped atomically.
  • User history blended in for two-stage, personalized ranking.
  • Shard the trie by prefix and debounce keystrokes to scale.

Where an interviewer pokes next

Getting the boxes right is the easy half. These are the questions that separate a candidate who drew the diagram from one who has run the thing. Answer each one out loud before you open it.

  1. How fresh can suggestions be — is the trie real-time?

    No, and it doesn’t need to be. The builder rebuilds and swaps on a cadence (minutes to hours), so a brand-new trending query appears after the next build, not instantly. For true real-time trends you layer a small, fast-updating "recent/trending" overlay on top of the stable trie.

  2. How do you handle typos and fuzzy matches?

    A pure prefix trie is exact-prefix only. For typo tolerance you add edit-distance search (a BK-tree or n-gram index) as a fallback when the prefix yields too few results, or precompute common misspellings into the trie. It’s a separate layer so the hot exact path stays fast.

  3. How big is the trie and how does it fit in RAM?

    Shared prefixes compress well, but multi-language pushes it past one box. You shard by first character(s) across servers, each holding its slice in memory and replicated for read throughput — the trie is a read fleet, not a single instance.

  4. What stops offensive or unsafe suggestions?

    A blocklist/safety filter runs at build time (and as a last-mile check at serve time) so banned completions never enter a node’s top-k. Because ranking is precomputed, most filtering is a build-time concern with a cheap final check on the way out.

  5. Why debounce on the client instead of just scaling the server?

    Debouncing removes load that never needed to exist — a request per keystroke is mostly intermediate prefixes the user blew past. Cancelling stale in-flight requests and waiting ~50ms cuts backend traffic dramatically for free, before any server-side scaling.

Check yourself — the answers, and why

Eight steps in, these are the calls you should be able to make cold. Pick one, then read why.

  1. A trie turns "find completions of a prefix" into…

    • A full-text search
    • An O(prefix-length) walk to a subtree
    • A SQL LIKE scan

    Follow the prefix path; everything below that node is a match — independent of corpus size.

  2. A prefix-keyed cache works so well because traffic is…

    • Evenly spread
    • Heavily skewed toward short common prefixes
    • Mostly unique per user

    A tiny set of hot prefixes drives most requests, so caching them turns the common case into a memory hit.

  3. Ranking the top-10 is fast at request time because it’s…

    • Done with a faster sort
    • Precomputed and stored per trie node (offline)
    • Skipped entirely

    Each node caches its best 10; the request just reads the ready-made list.

  4. Suggestions stay fresh via…

    • Mutating the trie on every search
    • An offline builder that rebuilds and atomically swaps the trie
    • Rebuilding per request

    Build heavy/offline, serve light/immutable — readers flip from version N to N+1.

  5. The cheapest way to cut typeahead load is…

    • More trie replicas
    • Client-side debouncing of keystrokes
    • A bigger cache

    Debounce + cancel stale requests so only meaningful prefixes ever reach the backend.

How you’d open this design in an interview

Before any boxes: agree what it must do, pin the qualities that shape everything, then build — naming each trade-off as you make it. The walkthrough above is that exact order.

What it must do

Agree on these before drawing a single box.

  • Suggest: GET /suggest?q=sys returns the best completions on every keystroke.
  • Rank: order matches by real search popularity — the best ten, not just any ten.
  • Stay current: yesterday’s trending query surfaces today; dead ones fade.
  • Personalize: bias suggestions toward the user’s own recent searches.
  • Be instant: answer in well under 100ms — faster than the next keystroke.

The qualities that shape everything

Each one names the mechanism that buys it.

Find candidates independent of corpus size
A trie walk to the prefix’s subtree is O(prefix length) — following a few pointers, not scanning millions of queries.
Sub-millisecond on the common case
A prefix-keyed cache of precomputed top-10 lists for hot prefixes answers ~90% of traffic from memory; misses fall through and warm the cache.
O(1) ranking on the hot path
Store the ranked top-k precomputed at each trie node, so a request reads a ready-made list instead of sorting millions of matches live.
Fresh suggestions without wrecking reads
An offline Trie Builder folds the query log into fresh counts, rebuilds the trie, and swaps the new version in atomically.
Relevant per user
Blend User History over the global top-k — a light per-user re-weight of a small candidate set, not a trie per person.
Fit the index and survive fast typists
Shard the trie by prefix and replicate each shard; debounce keystrokes (~50ms) on the client so only meaningful prefixes hit the server.

The trade-offs you say out loud

Senior signal isn’t the boxes — it’s naming what you gave up and why it was the right price.

A prefix trie over a SQL LIKE 'sys%' scan

An index still does real per-request work and degrades for the short, high-traffic prefixes — multiplied by every keystroke of every user. A trie walk is O(prefix length), independent of how many queries exist.

A prefix-keyed cache over more trie replicas

Replicas add throughput but every hot request still redoes the same walk + rank. Traffic is wildly skewed toward a few short prefixes, so caching them turns the common case into a sub-ms memory hit — recomputing the identical answer is wasted work.

Precomputed top-k per node over sorting matches at request time

The prefix "a" matches millions of queries; sorting them on every keystroke is exactly the live work typeahead can’t afford. Rank offline once at build time and store each node’s best ten — O(1) ranking on the hot path.

Offline rebuild + atomic swap over mutating the trie in place on every search

Writing to the shared trie on every read wrecks read performance and risks serving a half-updated tree. Separate the heavy build from the light serve: readers use version N until N+1 is fully ready, then flip.

What this teaches

Learn system design by building a search typeahead / autocomplete step by step. An interactive guide covering prefix tries, top-k ranking, caching hot prefixes, building suggestions from query logs, personalization, and sharding.

Key takeaways

  • A single Suggest API on the hot path, called every keystroke.
  • A trie turns “find completions of a prefix” into an O(L) walk.
  • A prefix-keyed cache answers the skewed, common prefixes in <1ms.
  • Precomputed top-k at each node — ranking happens offline, not live.
  • A query log + offline builder keep suggestions fresh, swapped atomically.
  • User history blended in for two-stage, personalized ranking.
  • Shard the trie by prefix and debounce keystrokes to scale.

Concepts covered

  • What is typeahead?
  • Prefix in, matches out
  • The trie
  • Cache the common case
  • Rank the candidates
  • Build it from what people search
  • Your suggestions, not everyone’s
  • A trie too big for one box
RUN IT YOURSELF

The autocomplete core: a trie

Typeahead lives or dies on prefix lookups. Here is its core — a trie — in Python and TypeScript, running live in your browser. Switch tabs, read the comments, edit, and hit Run.

HOW TO READ THE CODE — 4 IDEAS
  1. A trie stores words as a tree of characters; shared prefixes share a path.
  2. Insert walks the tree creating one node per char, then marks the end (steps 1–2).
  3. To autocomplete, walk down to the prefix's node (step 3)…
  4. …then DFS-collect every word hanging below it (step 4).
CPython · WebAssembly
built to be typed, not memorized — make the calls, drop the cache, run the gauntlet.
Finished this one? 0 / 65 System Designs done

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More System Designs