Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

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

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.

Why this piece earns its place

The thing worth pushing back on is treating this like any other read endpoint. Suggest has a property the main search API doesn’t: nobody asked for it. A search request that fails needs an error page; a suggest request that fails shows no dropdown and the user keeps typing. That asymmetry is an asset, so spend it. Give the endpoint its own fleet and its own deploy, so a slow rollout on search can’t eat a budget measured in tens of milliseconds. Give it a hard timeout well under the 100ms target that fails open to an empty list rather than retrying — a retry lands after the next keystroke, by which time its answer is for a prefix the user has already left. There is nothing worth waiting past the deadline for — late is indistinguishable from never — so the box cuts the request off instead of holding it open. Degrading the content of an on-time answer is a separate question, and one that only arrives once there is something optional left to drop.

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.

Why this piece earns its place

Be precise about what that O(L) actually buys, because it’s only half the request. Reaching the node for sys is a few pointer hops, but the node for a has millions of completions hanging beneath it, and reading them out costs the size of the subtree, not the length of the prefix. So the cheap part is finding the candidate set and the expensive part is everything after it — which is precisely the work step 4 deletes by storing the answer on the node instead of gathering it from below. Until that exists, the shortest and hottest prefixes are the slowest requests on the site, which is the opposite of what the traffic wants. The other quiet cost is memory: a node per character with a child pointer table is far bigger than the query text it indexes, and the overwhelming majority of those nodes have exactly one child. Collapsing single-child chains into one labelled edge — a radix tree — is what keeps millions of queries indexed as a structure you can hold in RAM instead of a diagram.

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

Why this piece earns its place

The knob this adds isn’t cache size, it’s how an entry dies — and the default answer bites. These lists are already precomputed, so a long TTL looks free. Then the builder swaps in a new trie and the cache doesn’t know: it keeps handing out lists computed from the old one, and the fresher suggestions the entire log pipeline exists to produce sit invisible behind a hot key until it expires. With most traffic concentrated on a handful of prefixes, those are exactly the entries that never fall out on their own, so the freshest part of the system is hidden by the fastest part of it. Fix it structurally rather than by tuning: put the trie version in the cache key, so a swap orphans the whole previous generation and the hot prefixes refill on their first miss. The TTL then only has to bound drift within a version, and can be generous. Go the other way — a short TTL to stay fresh — and you pay twice, because the hit rate falls away from ~90% and every one of those extra misses is the most expensive possible trie walk.

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

Why this piece earns its place

The uncomfortable property of precomputed popularity is that it’s a closed loop. What a node shows is what gets clicked, a click is a search, and searches are what the counts are rebuilt from — so today’s top-10 becomes tomorrow’s evidence for itself. A genuinely better completion that never made a list never gets typed, never accumulates count, and is never promoted; the ranking converges on whatever it already believed. Offline evaluation won’t catch it either, because the log you’d score against was produced by the model you’re scoring. Two cheap defenses. Lean on recency, not just raw volume, so a query climbing fast can beat one that has merely been popular for years — rank by velocity as well as height. And hold a slot or two in each node’s list for candidates that would otherwise never surface, then read the counts back on those slots specifically: an impression that earns searches has proven itself and stays, one that doesn’t drops out at the next build. Without that, the index is a very fast record of what was popular the day it was first assembled.

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

Why this piece earns its place

Cadence is the knob, and what bounds it is the build: a full pass over the log, a re-rank of every node’s top-k, then shipping the serialized trie out and loading it on every serving box. The swap adds a price of its own, but a fixed one — version N keeps answering while N+1 loads, so each box must hold two tries at the instant of the flip, and that cost is identical whether you flip hourly or by the minute. It decides whether an in-process swap is affordable at all, not how often you get to do it. Between the two, the honest answer is a cadence of minutes to hours instead of something continuous. Run it too slowly and the freshness requirement is simply gone — this morning’s trending query appears tomorrow, and the query log becomes an expensive description of the past. The second property of an all-or-nothing build is worth saying out loud, because it only helps if you plan for it: one bad input window — a bot hammering a single query, a spike caused by an outage — bakes itself into every node’s top-k at once, and there is no per-entry patch for that. Keep version N−1 loadable on every box so the recovery is a flip backwards, taking seconds, rather than an emergency rebuild run while the wrong suggestions are on screen.

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.

Why this piece earns its place

Notice what this quietly reintroduces: per-request ranking work, on the one path the previous steps spent their whole time emptying out. It also collides with the cache, and that collision is the thing to design around. A cached entry is keyed by prefix alone, which is only sound while the answer is identical for everybody. The moment history is blended in before the cache, a shared entry either hands one person’s suggestions to the next or stops being shareable at all — and the hit rate carrying most of the traffic evaporates. So the ordering is load-bearing: the cache keeps storing the global top-10, and the re-weight happens after the read, on the ten candidates that came back. Personal signal never enters the cache or the trie, which is also the privacy answer. Then bound it. Give the history lookup a tight timeout and let it fail open to the global order, because a slow history store should cost relevance and not the dropdown, and cap how far it can reorder — otherwise someone who types the same query all day is shown nothing else ever again.

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.

Why this piece earns its place

Sharding on the first character is the natural key and also the unbalanced one, for exactly the reason the cache works: traffic is heavily skewed toward a few short prefixes. An a–f shard and an x–z shard do not see comparable load, and the single hottest first letter can’t be split any further under that rule — so you’ve bought capacity precisely where it wasn’t needed. Cut the ranges by measured volume instead, using the same counts the builder already computes, and let the boundaries move at each rebuild; replication then adds throughput where the traffic actually lands. The client has its own sharp edge. Once you debounce and cancel, responses can return out of order, and the reply for sy arriving after the reply for sys will happily paint the shorter, staler list over the better one — the box appears to type backwards. Every response has to carry the prefix it answered and be discarded unless that still matches the input. And the ~50ms wait is a real charge against the sub-100ms budget, spent before the request is even sent.

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…

    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…

    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…

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

  4. Suggestions stay fresh via…

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

  5. The cheapest way to cut typeahead load is…

    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.

The answer, out loud

What a strong answer to “Design Typeahead (Autocomplete)” sounds like, first question to last trade-off. It is about 6 minutes of talking; the whiteboard and the interviewer fill the rest of the 45. Read it aloud once, then close the page and give it yourself.

  1. 0–4 min

    Scope it before drawing

    Let me scope this before I draw anything. It’s a suggest box: the user types a character, and we return the best ten completions before they type the next one. So the budget is well under a hundred milliseconds, and it’s per keystroke, not per search — this endpoint runs far more often than search itself does. It’s read-only, the corpus is millions of past queries, and the order has to come from what people actually search rather than from the dictionary. That last requirement is what makes this a design problem instead of a lookup.

  2. 4–8 min

    One endpoint, almost no work in it

    The shape I’d commit to up front is that almost nothing happens at request time. Anything I can compute before the user types, I will. So there’s one endpoint — prefix in, ranked list out — and everything hard lives behind it. I’d say out loud that I’m going to trade storage and offline compute for read latency at every step after this, because that’s the through line of the whole design. If I ever catch myself sorting or scanning inside the request, I’ve made a mistake somewhere earlier.

    Built in step 1: Prefix in, matches out
  3. 8–14 min

    Finding the candidates

    First real piece: how do I find candidates. Scanning millions of queries is out, and a database prefix index still does per-request work that grows with the number of matches, on every keystroke of every user. What I want is a prefix tree — a trie. Each node is a character, so following s, y, s lands me on the node whose entire subtree is the completions starting with sys. Finding candidates becomes following a few pointers, and the cost depends on how long the prefix is, not on how many queries I’ve ever seen.

    Built in step 2: The trie
  4. 14–20 min

    Candidates are not an answer

    Candidates aren’t an answer, though. The prefix a matches millions of things and I have to return ten of them, in order, right now. I’m not going to sort that live — that’s exactly the live work I just said I can’t afford. So when the tree is built, I score each node’s subtree by how often those queries get searched, with recency and length as secondary signals, and I store the best ten on the node itself. At request time the ranking has already happened; I walk to the node and read a list off it.

    Built in step 4: Rank the candidates
  5. 20–26 min

    Exploit how skewed the traffic is

    Now the cheap win. Typeahead traffic is wildly skewed — a small set of short prefixes accounts for most requests, because everybody passes through a and fa on the way to somewhere else. So I put a cache in front, keyed by prefix, holding that precomputed top ten. On a hot prefix I never touch the tree at all and the answer comes back in under a millisecond; I’d expect something like ninety percent of traffic to land there. A miss just falls through to the tree and warms the entry on the way back.

    Built in step 3: Cache the common case
  6. 26–32 min

    Keeping suggestions current

    Then freshness, which is where the tempting answer is the wrong one. Suggestions have to move — what trended yesterday should surface today. The obvious fix is to bump counts on the tree as searches arrive, and that ruins the thing I just built: the read path stops being immutable, and readers can see a half-updated tree. Instead every search goes to a log, an offline job folds that log into fresh counts, rebuilds the tree with new top-ten lists, and swaps the finished version in atomically. Build is heavy and rare; serving stays light and constant.

    Built in step 5: Build it from what people search
  7. 32–37 min

    Two-stage ranking for personalization

    Personalization I’d frame as two stages, not a second system. I’m not building a tree per user — that’s an enormous index to keep fresh for every person, and most prefixes someone types they’ve never typed before, so their own history is far too sparse to stand on its own. The global list gives me coverage, their recent searches give me relevance. So the global stage narrows millions down to ten, and then a light per-user pass reorders just those ten.

    Built in step 6: Your suggestions, not everyone’s
  8. 37–42 min

    Splitting the fleet, and typing fast

    For scale, two things. The index doesn’t fit on one machine once you’re multi-language, so I split it by prefix across servers and replicate each slice for read throughput — the tree is a read fleet, not an instance. And the cheapest scaling lever isn’t on the server at all: debounce on the client, around fifty milliseconds, and cancel requests that are already stale. Most keystrokes are prefixes the user blew straight past, and those requests never needed to exist in the first place.

    Built in step 7: A trie too big for one box
  9. 42–45 min

    Close on the trade-off

    So: one hot endpoint, a trie for candidates, top ten precomputed per node, a prefix cache for the skew, an offline rebuild for freshness, a light personal pass, then sharding and debouncing. The trade I’ve made everywhere is staleness for speed — everything good here is computed before the request arrives, so the price is that nothing changes instantly. With more time I’d go into typo tolerance, which a pure prefix tree structurally cannot do, and into the safety filter that has to run at build time so a banned completion never reaches a node’s list.

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
NextDesign a Rate Limiter Closest system design to this one

Explore the topic

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

More System Designs