Quick Answer

Autocomplete works in two stages: a fast lookup structure (a trie, edge n-grams, or a prebuilt index) finds every candidate that starts with what you've typed, then a ranking layer scores those candidates by frequency, recency, and closeness of match (including typo tolerance) before showing the top few. A debounce timer keeps the app from firing a request on every single keystroke.

Why You Can't Just Query on Every Keystroke

The obvious way to build a search box that reacts as you type is to send a request to your backend on every keystroke. Type “python” and you've fired six requests, one per letter, and most of them are wasted — the results for “p”, “py”, and “pyt” all get thrown away the instant a newer keystroke arrives. At real traffic volumes that's not a small inefficiency; it's most of your search traffic being requests nobody will ever actually see the results of.

The fix is debouncing: wait for a short pause in typing (commonly 150–300ms) before firing a request at all, and skip it entirely if another keystroke arrives first. This is different from throttling, which fires at a fixed cadence no matter what the user is doing. Throttling a search box still means querying on incomplete, meaningless prefixes on a timer; debouncing means you only query once the user has actually paused — cheaper, and closer to what they meant to search for.

function debounce(fn, delay) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

const onType = debounce((query) => fetchSuggestions(query), 200);
searchInput.addEventListener('input', (e) => onType(e.target.value));

There's a second problem debouncing alone doesn't fix: response ordering. If the request for “ap” is slow and the request for “app” is fast, the “ap” response can arrive after “app” was already rendered, silently overwriting correct results with stale ones. Production autocomplete clients guard against this by cancelling in-flight requests (an AbortController in the browser) or by tagging each request with an incrementing ID and ignoring any response that isn't for the most recent one.

Finding Candidates: Tries, Edge N-Grams, and FSTs

Once you know when to query, you need a way to find, out of possibly millions of stored strings, every one that starts with what the user typed — fast. A trie (a tree where each path from the root spells out a prefix) is the classic textbook structure for this, and we've covered exactly how a trie finds every word starting with a given prefix in a separate post on this site. This post is about what happens around that lookup — feeding it, ranking what it returns, and making it fast at scale — not the trie's internal mechanics.

In practice, most production search systems don't hand-roll a trie at all. Two approaches dominate real systems:

  • Edge n-grams: at index time, “search” gets broken into overlapping prefixes — s, se, sea, sear, searc, search — and each one is indexed as its own token. A prefix query then becomes an ordinary exact-match lookup, no special traversal logic needed. The cost is a bigger index (roughly one extra token per character of every string) and it's slower than a purpose-built structure, but it slots directly into a general-purpose search engine like Elasticsearch or OpenSearch with no custom code.
  • Finite State Transducers (FSTs): Elasticsearch's completion suggester holds the entire set of possible completions in memory as an FST — conceptually a heavily compressed trie. It's the fastest option for ordered, prefix-based lookups and is purpose-built for typeahead, but the whole structure lives in heap memory, so it costs RAM in proportion to your vocabulary size.

The practical rule of thumb, based on how these are actually used in production: reach for the FST/completion-suggester style structure when your set of completions is bounded and mostly static — product names, city names, previously-typed search queries — and reach for edge n-grams when you need more flexible matching, such as matching a word that appears anywhere inside a multi-word phrase rather than only at the very start of the whole string.

Ranking: Matching Is Necessary, Not Sufficient

A prefix index answers “which strings start with 'py'?” It has no opinion on which of those strings the user actually wants to see first, and that's a separate ranking problem — usually where most of the real engineering effort in a typeahead system goes. The most common baseline signal is historical frequency: how often people who typed this prefix have gone on to pick each candidate.

SEARCH_LOG = {
    "python tutorial": 8600,
    "python for loop": 5200,
    "python dictionary": 4300,
    "python list comprehension": 3100,
    "python download": 2200,
    # ...more entries
}

def prefix_search(prefix, log, limit=5):
    prefix = prefix.lower()
    matches = [(term, count) for term, count in log.items() if term.startswith(prefix)]
    matches.sort(key=lambda pair: pair[1], reverse=True)
    return matches[:limit]

The counts above are made up for this demo — a real system would derive them from actual query logs — but the ranking logic and the output below are both real, produced by actually running this code against the query “py”:

Query: "py"
  'python tutorial'                   count=8600
  'python for loop'                   count=5200
  'python dictionary'                 count=4300
  'python list comprehension'         count=3100
  'python download'                   count=2200

Real systems layer more signals on top of raw frequency: recency (a term trending in the last hour should outrank one that was popular last year but has gone cold), personalization (your own search history), and business rules (a retailer might boost in-stock products over out-of-stock ones with an identical prefix). But frequency-ranked prefix matching is the baseline every one of those sits on top of, and on its own it already beats “whatever arbitrary order the index happens to return.”

Handling Typos: When Nothing Matches the Prefix

Frequency ranking assumes the prefix itself is correct. It falls apart the moment someone types “pyhton” instead of “python” — none of your indexed strings start with “pyhton”, so a pure prefix match returns nothing, right when the user most needs help.

The standard fallback is edit distance: count the minimum number of single-character insertions, deletions, or substitutions needed to turn one string into another, and treat anything within a small distance (usually 1 or 2) of an indexed term as a fuzzy match. This is Levenshtein distance, and it's worth actually verifying an implementation against known cases before trusting its output — an off-by-one in the dynamic-programming table is an easy, silent mistake:

def levenshtein(a, b):
    n, m = len(a), len(b)
    if n == 0: return m
    if m == 0: return n
    prev = list(range(m + 1))
    for i in range(1, n + 1):
        curr = [i] + [0] * m
        for j in range(1, m + 1):
            cost = 0 if a[i - 1] == b[j - 1] else 1
            curr[j] = min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
        prev = curr
    return prev[m]

# sanity check against a hand-verifiable, textbook example
print(levenshtein("kitten", "sitting"))  # expected 3: k->s, e->i, insert g

Running that sanity check, plus several other hand-checkable cases, against the actual function:

levenshtein('kitten', 'sitting') = 3  expected=3  OK
levenshtein('flaw', 'lawn') = 2  expected=2  OK
levenshtein('', 'abc') = 3  expected=3  OK
levenshtein('abc', 'abc') = 0  expected=0  OK
levenshtein('pythom', 'python') = 1  expected=1  OK
All sanity checks passed: True

With the function trusted, using it as a fallback for the mistyped query “pyhton” — ranking every candidate by edit distance first, then by popularity — produces:

Fuzzy fallback demo: user typed "pyhton" (typo)
  'python tutorial'                   edit_distance=2  count=8600
  'python for loop'                   edit_distance=2  count=5200
  'python dictionary'                 edit_distance=2  count=4300

Plain Levenshtein distance has one well-known blind spot: transpositions. “teh” is one keystroke away from “the” in the way people actually make that mistake — two adjacent letters swapped — but Levenshtein charges 2 for it (a substitution plus another substitution), not 1, because it has no transposition operation. Running both a plain and a transposition-aware (Damerau–Levenshtein) implementation on the same pair confirms it:

plain Levenshtein('teh', 'the') = 2
Damerau-Levenshtein('teh', 'the') = 1

That's why typo-tolerant search in practice uses Damerau–Levenshtein (Levenshtein plus a transposition operation) rather than plain Levenshtein — it matches the actual error humans make when typing quickly. One important caveat either way: no real system computes edit distance against its entire vocabulary on every keystroke, since that scales with vocabulary size times string length squared per query — too slow at any real scale. Production fuzzy matching narrows the candidate set first (a BK-tree, an n-gram index, or simply restricting comparisons to terms of similar length) and only runs exact edit-distance calculations against that much smaller shortlist.

Putting It Together: The Full Request Path

Chain the pieces above together and a single keystroke's journey through a production typeahead system looks roughly like this:

  1. The client waits out the debounce window and cancels any older, still-pending request.
  2. The request hits an API endpoint, which queries a prefix-matching structure (an FST-based completion suggester or an edge-n-gram field) restricted to a small top-k, not the full vocabulary.
  3. If that returns zero or too few candidates, a fuzzy fallback based on edit distance runs against a pre-narrowed shortlist instead of the whole vocabulary.
  4. Candidates are merged with ranking signals — frequency, recency, and personalization — and sorted.
  5. The top 5–8 results come back to the client, which renders them and typically bolds the portion that matched what was typed.

This shape is why Elasticsearch's completion suggester is built the way it is: it's explicitly designed to sit at step 2 of exactly this pipeline, returning a small ranked candidate set fast enough that steps 3–5 still fit inside the user's latency budget.

Why the Latency Budget Drives Every Design Choice

Typeahead has an unusually strict UX requirement: it needs to feel instantaneous, which in practice means the whole round trip — network, ranking, rendering — needs to land well under 100ms. Every architectural choice described in this post is downstream of that budget.

It's why single- and double-letter prefixes (“a”, “an”, “co”) are usually cached rather than recomputed on every request — they're hit constantly across every user of the system, and their result set barely changes minute to minute, so a short-TTL cache in front of the prefix index absorbs the vast majority of traffic before it ever reaches the ranking layer. It's also why, at very large vocabulary sizes, some systems skip live FST traversal entirely for common prefixes and instead precompute the top-N suggestions per prefix offline, serving them as a flat key-value lookup — trading a bigger, occasionally stale index for a guaranteed-fast read. The specific data structure matters less than this constraint: whatever you build has to answer in single-digit milliseconds, because the user is still typing.

Frequently Asked Questions

Do I need a trie to build autocomplete? No. A trie is one valid way to do fast prefix lookups, but edge n-grams inside a general search engine, or an FST-based structure like Elasticsearch's completion suggester, work too and are what most production systems actually use instead of hand-rolling one.
What's the difference between debounce and throttle for a search box? Debounce waits for a pause in typing before firing a request, so a fast typist only triggers one request when they stop. Throttle fires at a fixed interval regardless of pauses, which still wastes requests on incomplete prefixes.
Why does autocomplete sometimes suggest something spelled differently than what I typed? That's the typo-tolerant fallback kicking in. When nothing matches your exact prefix, the system searches for indexed terms within a small edit distance of what you typed and shows those instead.
How fast does autocomplete need to respond to feel instant? Roughly under 100ms end to end is the common target. Beyond that the delay becomes noticeable, which is why hot prefixes are usually cached rather than recomputed on every request.
Is autocomplete the same thing as search-as-you-type? They're related but not identical. Autocomplete usually means completing a partial word or phrase from a known set of candidates, while search-as-you-type usually means re-running a full search query after every keystroke; autocomplete is often one ingredient feeding into the latter.
Does autocomplete use machine learning? Large-scale systems often layer an ML-ranked model on top of the frequency and recency signals described here, but the core prefix-matching plus frequency-ranking approach in this post is what nearly every implementation is built on, with or without ML on top.