Read Path · Typeahead · Systems

Every Keystroke Is a Query

Search autocomplete looks like the cheapest feature on the page and behaves like one of the hardest read paths in the system: it fires on every character, has a latency budget measured in human perception, and publishes text derived from what other people typed. This is the architecture that keeps it fast, fresh and defensible.

autocomplete

Every keystroke is a query

Typing "autocomplete" into a search box and pressing enter looks like one request. Left alone, the client sends up to twelve: one per character, each racing the next, each needing an answer before the following key lands. Traffic to the suggest endpoint is roughly search traffic multiplied by the number of keystrokes per query, before debouncing and caching claw some of it back.

The latency target is set by people, not by the service. The long-standing usability rule of thumb is that around 100 ms feels instantaneous. A suggestion that arrives after the user has already typed past it is not slow, it is wrong, because it answers a prefix that no longer exists.

The payoff justifies the cost. Google's own figure is that autocomplete cuts typing by roughly a quarter on average, which is why nearly every search surface ships one. The engineering question is how to deliver that benefit without turning a text box into the busiest endpoint in the platform.

Real incident · Google Instant, 2010 – 2017

The expensive read path was retired. The cheap one stayed.

Google Instant rendered full results as the user typed. It launched in 2010 and was switched off in July 2017, with Google citing the shift to mobile input and screen constraints. What survived was the suggestion dropdown: a small, narrow payload that can be precomputed.

Google's stated reason was mobile, but the architectural takeaway holds regardless. When a feature runs on every keystroke, the response has to be small enough to compute, cache and ship inside the budget. Full result pages were not. A list of five strings is.

autocomplete

The latency budget is mostly not yours

Start from the 100 ms perception line and subtract everything the service does not control. Debounce, two network legs, an edge hop and the browser's render pass consume most of it, which leaves the suggest service with a single-digit to low-double-digit millisecond allowance. That number rules out a database query, a fan-out to other services, or anything that touches disk on the hot path.

Two consequences follow. First, the service has to be a pure in-memory lookup whose cost depends on the length of the prefix, not the size of the corpus. Second, the cheapest request is the one that never leaves the browser or the edge, so caching is not an optimisation to add later; it is half of the design.

autocomplete

Picking the data structure

Four approaches cover most real systems. They differ in what they make cheap: prefix lookups, ranking flexibility, mid-phrase matching, or operational simplicity.

ApproachHow a lookup worksRankingStrengthCost
Trie, top-k per nodecustom service Walk one node per character, then read the list stored on the final node. Anything you can score offline; baked into the snapshot. Cost depends on prefix length only. Total control over ranking and filtering. Memory: every node carries its own top-k. Rebuild to change rankings.
FST (Lucene / Elasticsearch completion suggester)in-memory graph Follow a compact finite state transducer from the left; Elasticsearch builds one per segment. Integer weights supplied at index time. Compact, fast, already inside the search engine; supports fuzzy matching. Prefix-oriented: it cannot find a word in the middle of a phrase unless extra inputs are indexed.
Edge n-grams / search_as_you_typeinverted index Index every leading fragment of each term, then use ordinary term lookups. Normal relevance scoring, so popularity and field boosts mix freely. Matches any word in a phrase; good for product and catalogue search. Larger index and slower than an FST for pure prefix cases.
Redis sorted set, lexicographic rangeprototype scale Store members at equal score and range-scan by lexicographic prefix. Results come back in lexical order, not popularity; ranking needs a second structure. Trivial to operate; instant writes. Becomes awkward the moment popularity, freshness or filtering matter.

The trie with a precomputed top-k is the clearest way to see why prefix lookups can be constant with respect to corpus size. Each node already knows its best completions, so a lookup is a short walk and a list read. Nothing is sorted at request time and nothing scans the corpus.

Real design note · Elasticsearch completion suggester

Build the graph at index time, not at query time

Elastic's engineers described the earlier Lucene suggesters as built in memory by loading completion values from the index, which was slow, resource-hungry and had to be repeated whenever the index changed. The completion suggester moved that work to index time: an FST is written per segment when the segment is written, then loaded quickly when needed. The same principle runs through the whole post: do the expensive work before the keystroke, never during it.

Edge n-grams and search_as_you_type (available since Elasticsearch 7.2) are the better fit when users type the middle of a phrase, for example a product name where the second word is the distinctive one. The price is a bigger index and a slower lookup than the FST path, which is an acceptable trade only when mid-phrase matching earns it.

autocomplete

Build one and watch it work

The lab below constructs a real trie in the browser from a small synthetic query log. The scores are invented for the demo. Type a prefix, then flip the lookup strategy and compare what each approach has to touch. Toggle the policy filter to see why takedowns are a build-time concern.

Lab 1 · prefix lookupbuilding…
    try
    0nodes visited
    0entries examined
    0queries under prefix

    Lookup strategy

    What to notice.

    • With the trie, "nodes visited" tracks the length of the prefix you typed. Add queries to the log and it does not move. The naive scan examines every entry on every keystroke; at ten million logged queries, that column is the outage.
    • The whole trick is the list stored on each node. It costs memory, because every node holds its own top five, and it buys a lookup with no sorting. That is the same trade as the FST's index-time weights.
    • Toggling the policy filter rebuilds the structure, because the cached lists are baked into the snapshot. A takedown that only exists in the next build is too slow, which is why section 8 adds a read-time overlay.
    autocomplete

    The write path is a batch pipeline

    Reads are fast because writes are slow, deliberate and offline. The system never updates a live structure per search. It aggregates logs into counts, filters them, builds an immutable snapshot, and swaps that snapshot in atomically.

    Fig. 2 · From search logs to a served snapshotsolid: main path · dashed: trending fast path
    Search logsquery · ts · locale Event streamdurable, replayable Windowed countsdecay · min users Policy filterdedupe · denylist Snapshot buildertop-k trie / FST Object storeversioned snapshots Serving fleetload · atomic swap Edge cacheprefix + version key Clientdebounce · abort · cache trending deltaevery few min keystrokes +accepted picks
    The serving fleet only ever sees finished snapshots. Nothing on the read path writes.
    • Windowed counts with decay. Raw all-time counts freeze the past in place. Weight recent activity more heavily so a query that is rising overtakes one that is merely old.
    • Eligibility thresholds. Require a minimum number of distinct users before a query can be suggested. That one rule filters most manipulation attempts and keeps rare, possibly personal strings (a name, an email address) out of a public dropdown.
    • Immutable, versioned snapshots. Each build is a new object. Servers load it beside the live one and flip a pointer, so a rollback is repointing to the previous version.
    • A small trending overlay. A full rebuild suits stable catalogues; news-like domains need a fast path that merges a short list of rising queries on top. Rebuild cadence is a product decision, not a technical constant: hours for a catalogue, minutes for anything event-driven.
    autocomplete

    Serving: cache the short prefixes

    Short prefixes are the hottest keys in the system and the easiest to cache. Millions of users typing "k" are asking an identical question. The request should be a cacheable GET whose URL fully describes the answer, including which snapshot produced it.

    HTTP contractcacheable · version in the key
    GET /suggest?q=kub&loc=en-IN&v=2026-10-29T06 HTTP/2
    
    200 OK
    Cache-Control: public, max-age=60, stale-while-revalidate=300
    Content-Type: application/json
    
    {"q":"kub","v":"2026-10-29T06","items":["kubernetes","kubectl cheat sheet","kubernetes pod crashloopbackoff"]}
    • Put the snapshot version in the cache key. A new snapshot then invalidates itself, and a half-swapped fleet cannot poison the edge with mixed answers.
    • Split shared from personal. The base list is identical for everyone in a locale, so it caches at the edge. Personalised or recent-history suggestions cannot be shared; merge those in the client or in a thin layer after the cache.
    • Replicate before you shard. After pruning, an autocomplete index is often small enough to hold whole on every node. Sharding by leading characters creates hot shards, because letter frequency is wildly uneven. Adding identical replicas scales reads without that skew.
    • Coalesce misses. After a deploy or a cache flush, thousands of clients ask for the same cold prefix at once. Collapse concurrent identical requests into one origin fetch.
    autocomplete

    The client is half the architecture

    Network responses do not arrive in the order requests were sent. Type "kube" and four requests leave; if the third takes longest, a naive client renders it last and the dropdown shows answers for "kub" under an input that says "kube". Nothing errors and nothing pages. The suggestions are simply wrong. The lab replays that sequence with fixed latencies.

    Lab 2 · out-of-order responses4 keystrokes · fixed latencies
    0 ms260520 ms

    The fix is small and has to exist from day one: tag each request with a sequence number, render only the response that matches the latest keystroke, and abort superseded requests so the network does not carry work nobody will read. The controller below also covers three details that cause most production oddities.

    client / typeahead.tsseq guard · abort · IME aware
    type Suggest = { q: string; items: string[]; v: string };
    
    export function createTypeahead(
      fetchSuggest: (q: string, signal: AbortSignal) => Promise<Suggest>,
      render: (items: string[]) => void,
      { debounceMs = 60, minChars = 1, timeoutMs = 250 } = {}
    ) {
      const cache = new Map<string, string[]>();  // prefix -> items (clear when the snapshot version changes)
      let timer: ReturnType<typeof setTimeout> | undefined;
      let inflight: AbortController | undefined;
      let seq = 0;
      let composing = false;                        // IME: never query mid-composition
    
      async function run(q: string, mySeq: number) {
        inflight?.abort();                          // supersede the previous request
        const ctl = (inflight = new AbortController());
        const deadline = setTimeout(() => ctl.abort(), timeoutMs);
        try {
          const res = await fetchSuggest(q, ctl.signal);
          if (mySeq !== seq) return;                // stale: a newer keystroke exists
          cache.set(q, res.items);
          render(res.items);
        } catch {
          // best-effort: fail silent so the search box itself stays usable
        } finally {
          clearTimeout(deadline);
        }
      }
    
      return {
        onInput(raw: string) {
          if (composing) return;
          const q = raw.trim().toLowerCase().normalize("NFKC");  // must match build-time normalisation
          const mySeq = ++seq;
          clearTimeout(timer);
          if (q.length < minChars) return render([]);
          const hit = cache.get(q);
          if (hit) return render(hit);
          timer = setTimeout(() => run(q, mySeq), debounceMs);
        },
        compositionStart() { composing = true; },
        compositionEnd(raw: string) { composing = false; this.onInput(raw); },
      };
    }
    autocomplete

    Suggestions are a publishing decision

    The ranking signal is other people's typing, and the output is shown to everyone. That makes the suggestion list a published statement, whether or not anyone intended it to be. Courts, regulators and platform policy have all treated it that way, which turns takedown from a policy topic into an engineering requirement.

    Real incident · Germany, 14 May 2013

    BGH VI ZR 269/12: liability starts at notice

    Germany's Federal Court of Justice ruled on a case where typing a businessman's name produced the completions "Scientology" and "fraud". The court held that such suggestions can infringe personality rights, that the operator is not obliged to vet every generated suggestion in advance, and that it can be liable once it has been notified and fails to act.

    The engineering reading: a notice-and-removal path for individual suggestions is not optional. It needs an intake, an audit trail and a defined time to take effect.

    Real policy · Google autocomplete, ongoing

    Remove the prediction and its relatives

    Google publishes autocomplete policies and says its automated systems try to prevent violating predictions, with enforcement teams removing those that slip through. Removals can also follow valid legal requests, spam, or close association with piracy. When Google acts on a reported prediction it removes the specific one and closely related variations, and in September 2020 it expanded the election-related rules to cover predictions that could be read as claims for or against a candidate or party.

    Removing one exact string is therefore not enough. Variants differ by spelling, spacing, word order and language.

    Turning that into a design gives a layered filter, none of which is the ranking model:

    • Eligibility rules at build time: minimum distinct users, spam and duplicate suppression, category policy checks. This is the filter the lab's checkbox demonstrates.
    • A read-time denylist overlay applied after the snapshot lookup, so an approved takedown takes effect in minutes rather than at the next build. Over-fetch a few extra candidates (k plus a margin) so removal does not leave a short list.
    • Normalised matching that expands a removal to case, spacing, punctuation and transliteration variants before the denylist is consulted.
    • Edge purge on takedown. A removed suggestion cached for sixty seconds is still a published suggestion. Tie the purge to the same workflow that updates the overlay.
    • An audit log recording who requested the removal, who approved it and when it reached the edge.
    autocomplete

    What to measure

    Averages hide this system's failures. A healthy median can coexist with a broken snapshot in one locale or a cache that quietly stopped hitting for two-letter prefixes. These eight indicators cover latency, freshness, quality and the policy path. Targets are illustrative; calibrate them against your own traffic.

    Latency

    Service p99

    Time inside the suggest service. It should be flat, because the work is a memory lookup.

    Example target: single-digit ms. Pages on: sustained p99 burn.
    Latency · real users

    Keystroke-to-render

    Measured in the browser, so debounce, network and paint are all included.

    Example target: p95 near the 100 ms line. Pages on: regression after a deploy.
    Caching

    Edge hit ratio by prefix length

    Sliced by length because short prefixes should be near-perfect hits. A drop signals a fragmented cache key.

    Example target: very high for 1–3 chars. Pages on: sudden fall after a param change.
    Correctness

    Zero-suggestion rate

    By locale and prefix length. A spike usually means a broken snapshot or a normalisation mismatch between build and query.

    Example target: stable baseline per locale. Pages on: step change.
    Freshness

    Snapshot age and swap lag

    How old the served snapshot is and whether every replica runs the same version.

    Example target: fleet on one version. Pages on: age beyond twice the build interval.
    Quality

    Acceptance rate

    Selected suggestions as a share of sessions that saw any. Ranking changes show up here before anyone complains.

    Example target: trend, not a constant. Pages on: sharp drop after a ranking release.
    Policy

    Takedown propagation

    Time from approved removal to absence at the edge, measured end to end with a probe.

    Example target: minutes, never the next build. Pages on: any breach.
    Client health

    Stale-drop rate

    Responses discarded by the sequence guard. A rise means slow tails on the network or origin.

    Example target: low and steady. Pages on: climb alongside p99.
    autocomplete

    How it fails

    AC-01

    Half-swapped snapshot

    Replicas load a new snapshot at different moments. A client hitting two of them sees one list for "k" and a different list for "ku", and a shared cache stores both.

    Version every snapshot, swap one atomic pointer per replica, and put the version in the cache key so mixed answers can never share an entry.

    AC-02

    Cold-start stampede

    A deploy or edge flush empties every cache. The first minute of traffic arrives as identical misses for the same short prefixes and lands on origin together.

    Pre-warm from the previous snapshot's hottest prefixes, use stale-while-revalidate, and coalesce concurrent identical misses.

    AC-03

    Poisoned or private query log

    Coordinated repeat searches can manufacture a suggestion, and rare queries can contain names or contact details that should never appear in a public dropdown.

    Count distinct users, not raw events; set a minimum eligibility threshold; and run policy filtering before the builder, not after.

    AC-04

    Suggest outage blocks search

    The dropdown depends on an endpoint that is down, and a client that waits on it freezes the input or disables submit.

    Treat suggestions as best-effort: a short timeout, silent failure, and a submit path that shares nothing with the suggest path.

    AC-05

    Normalisation drift

    The builder folds case and Unicode one way and the client another, so some real prefixes miss entirely. IME composition makes it worse: querying halfway through a composed character produces garbage prefixes.

    Share one normalisation spec between build and query, and suppress requests while composition is in progress, as the controller above does.

    autocomplete⏎

    The shape of the whole design

    Summary

    Autocomplete is a read path fed by a firehose of user text, so the architecture is mostly about moving work to the right side of the keystroke. Precompute ranked lists offline. Ship immutable, versioned snapshots. Cache short prefixes at the edge with the version in the key. Make the client defensive about order, timing and input methods. Treat every suggestion as something the organisation has published, with a filter that works at build time and a removal that works in minutes. Then instrument the places where it fails quietly, because this feature never throws an error when it is wrong.

    Next in this series Schema migrations at scale — running them without locking the table your traffic depends on.
    Sources & further reading
    1. Google, "How Google autocomplete works in Search": blog.google
    2. Google Search Help, "How Google autocomplete predictions work": support.google.com
    3. Google Instant retirement, July 2017: BetaNews report
    4. Bundesgerichtshof, 14 May 2013, VI ZR 269/12: IRIS Merlin summary, Stanford WILMap
    5. Google election-related autocomplete changes, September 2020: TechCrunch
    6. Elastic, "You Complete Me" (completion suggester design): elastic.co
    7. Elasticsearch reference, search-as-you-type field type: elastic.co