You type one letter into Google's search box, and before your finger has left the key, ten suggestions appear. Do it a hundred times and it feels free, instant, obvious. It is none of those things. Behind that dropdown sits one of the tightest latency budgets in consumer software, a data structure held in memory across thousands of machines, and an offline pipeline chewing through a firehose of query logs to keep it all fresh. This is a walk through the whole thing, from the keystroke to the machines and back, with the parts that usually get hand-waved drawn out in full.

The mental model to hold the entire time: the fast online path only ever reads precomputed structures, and it does so on a budget measured in single-digit milliseconds. Everything expensive (counting, ranking, model training, index building) happens on a slow offline path and gets shipped to the serving fleet on a schedule. Almost every design decision below falls out of that one split.

The whole system, one picture
Client / edge Online serving Candidate + rank Offline pipeline
ONLINE READ PATH · latency budget ~50-100ms round trip Browser debounce · cancel · cache Edge / GFE anycast · TLS hot-prefix cache Suggest service normalize · route fuzzy fallback Global trie shards Trending index Personal / geo Rank + filter re-score · dedup safety gate top-k JSON back to the dropdown OFFLINE BUILD PATH · batch every few hours, streaming every few minutes Query logs sampled privacy-scrubbed k-anonymized Batch aggregate count + time-decay Streaming detect spike vs baseline Build sharded trie top-k per node Train ranker CTR model + bias fix Blue/green deploy to serving fleet ↑
The top half runs on every keystroke. The bottom half runs on a clock. They meet only at the blue/green deploy, where fresh indexes replace the ones the serving fleet is reading.

01The client is doing more than you think

The suggestion box is not a dumb text field that forwards keystrokes. It is a small state machine optimizing for one thing: perceived latency. Three tricks carry most of the weight.

Firing and cancelling. A naive implementation fires one request per keystroke and renders whatever comes back. That breaks the moment responses arrive out of order: you type sea then seat, but the slower response for sea lands last and clobbers the dropdown with stale content. The fix is to tie every request to a monotonic sequence number, or better, to actively cancel the in-flight request when a newer keystroke supersedes it.

autosuggest.jsjavascript
let controller = null;

async function onKeystroke(prefix) {
  controller?.abort();                     // cancel the previous in-flight request
  controller = new AbortController();
  try {
    const res = await fetch(`/complete/search?q=${encodeURIComponent(prefix)}`,
                            { signal: controller.signal });
    render(await res.json());
  } catch (err) {
    if (err.name === 'AbortError') return;  // expected: a newer keystroke won
    throw err;
  }
}

The decision to cancel and the act of cancelling both live in your JavaScript. The browser does not infer that request #2 supersedes request #1, it has no concept of that relationship. What the browser does handle is translating .abort() into the right wire behavior, and that behavior is worth understanding because it explains why cancelling is cheap.

Connection reuse. Every request goes to the same origin over a connection that was opened once, at page load, and kept alive. The expensive parts of setting up a secure connection (the TCP handshake, then the TLS handshake) are a one-time cost, not a per-keystroke cost.

One connection, many requests
NAÏVE: new handshake per keystroke TCP+TLS · "s" TCP+TLS · "se" TCP+TLS · "sea" +50-150ms each · unusable REAL: one connection, HTTP/2 streams TCP + TLS handshake ONCE (at page load) ALPN picks h2 / h3 stream 1 · GET ?q=s stream 3 · GET ?q=se stream 5 · GET ?q=sea stream 3 · RST_STREAM (cancelled) Each keystroke is a new stream id on the same socket. Cancelling sends one RST_STREAM, the connection stays open.
This is the mechanism behind "no new handshake per keystroke." Note: these are plain HTTP requests multiplexed over one connection, not a WebSocket. There is no Upgrade handshake and no persistent bidirectional message channel.

Local caching. Backspacing from seattle to seattl is common (you are reconsidering), so the client keeps a small in-memory map of prefix → last response and serves the shorter prefix from cache instead of round-tripping. This cache is per page load, never persisted, because suggestions are time-sensitive.

The layer you don't control

You call fetch(). The browser's network stack keeps a connection pool keyed by (scheme, host, port) and silently reuses an existing HTTP/2 (or HTTP/3 / QUIC) connection for every same-origin call. You can verify it: in Chrome DevTools' Network tab, enable the "Connection ID" column and watch the same ID repeat across keystrokes.

02Edge routing and the latency budget

The request leaves the browser and hits the nearest Google Front End through anycast: the same IP address is advertised from many locations, and the network delivers your packets to whichever one is closest. TLS terminates there, close to you, so the handshake round trips are short. Only then does the request travel (over Google's internal backbone, not the public internet) to a regional suggestion service.

Why go to this trouble? Because autosuggest sits on the critical path of typing. The whole thing has to feel like it is keeping up with your fingers, which means the perceived round trip needs to stay under roughly 100ms. Subtract network time and you are left with a brutally small server-side budget.

Where ~100ms goes (illustrative)
Network RTT to edge~30ms
Edge → region~15ms
Normalize + route~2ms
Candidate lookup~5ms
Rank + filter~4ms
Return + render~25ms

The lesson buried in those numbers: the server-side work (normalize, look up, rank, filter) has maybe 10-15ms total. That single constraint is why the suggestion service can't run heavy NLP, can't call a large model per keystroke, and can't scan anything at request time. Everything it returns must already have been computed. It only reads.

Graceful degradation

Because suggestions are a convenience, not a correctness guarantee, the service is free to fail soft. If a shard is slow or a signal source times out, the response returns with whatever came back in time (or an empty list) rather than blocking. A missing dropdown is a non-event. A 400ms dropdown is a visible bug.

03Query understanding, without the heavy machinery

Before any lookup, the raw prefix is normalized: lowercased, trimmed, accents folded (café → cafe), Unicode canonicalized, and language / script detected. This is deliberately cheap. There is no room in the budget for a real parser on a two-character prefix.

One thing that gets deeper as you type more: understanding is progressive. A one-letter prefix gets almost no interpretation. A long, complete-looking query gets more (entity recognition, "did you mean" correction), because by then it looks finished and the extra work is justified.

And a subtlety worth its own section: spelling.

04Typos: why "correct then look up" is the wrong model

The intuitive design is a pipeline: take the typed string, autocorrect it, feed the corrected string to the index. This breaks on the defining property of autosuggest: the input is incomplete by definition. Is seattl a typo of seattle, or a correct prefix of it that you haven't finished typing? They are identical. Aggressively "fixing" every prefix would mean constantly second-guessing a user mid-thought.

So tolerance is not a separate stage. It is fused into the matching itself. Two mechanisms do the work.

Mechanism 1: fuzzy matching baked into retrieval

Start with what actually happens on a real typo. Suppose you type explian. A literal, character-by-character trie walk does not wander off to a wrong-but-complete word. Trie nodes only have children for letters that continue a real indexed word, so the walk simply dies at the first character with no matching child.

Naïve walk on a typo → dead end
e x p l i ✗ node "expl" has child a (explain), not i → no suggestions at all
The failure mode is "zero results," not "results for the wrong word." That is exactly the gap fuzzy matching fills.

The trick (the SymSpell approach) is to precompute, offline, every string reachable by deleting up to N characters from each dictionary word, and store a reverse index from those delete-variants back to the real words. At query time you generate the delete-variants of the typed string too, and look for overlap. Overlap means the two strings are a small edit apart.

deletes of dictionary word "explain"
−e xplain
−x eplain
−p exlain
−l expain
−a explin ●
−i explan ●
−n explai
deletes of typed "explian"
−e xplian
−x eplian
−p exlian
−l expian
−i explan ●
−a explin ●
−n explia

Two variants (explan and explin) collide. That overlap flags explain as a candidate. A cheap verification step then runs real Damerau-Levenshtein distance only on this tiny shortlist, confirms the true edit is a single transposition of a and i, and pulls completions from explain's subtree (explain this, explain this to me, and so on). No rewrite-and-rewalk, no separate correction service, no extra network hop.

symspell.pypython
# OFFLINE: build reverse index {delete_variant -> [(word, freq), ...]}
def generate_deletes(word, max_edits=2):
    seen, frontier = {word}, [word]
    for _ in range(max_edits):
        nxt = []
        for w in frontier:
            for i in range(len(w)):
                v = w[:i] + w[i+1:]
                if v not in seen: seen.add(v); nxt.append(v)
        frontier = nxt
    return seen

# ONLINE (hot path): same deletes on the typo, then intersect + verify
def fuzzy_candidates(typed):
    hits = []
    for v in generate_deletes(typed, max_edits=1):   # tighter budget online
        hits += delete_index.get(v, [])
    return sorted(set(hits), key=lambda x: -x[1])   # by frequency

Mechanism 2: correction that falls out of popularity

A lot of what looks like autocorrect is not explicit logic at all. Type recieve email and the index space around that prefix has vastly more historical traffic for the correctly-spelled receive email…, so the correct completions simply outrank the misspelled ones. It is fuzzy retrieval plus frequency ranking, not a "did you mean" rule.

The genuine, heavier spell-correction model (a noisy-channel or small neural model) is reserved for complete queries, post-submit, where the budget is looser. All of it is trained on the single most valuable signal Google has here: query reformulation logs, the millions of sessions where someone typed recieve, paused, and retyped receive. That pairing captures phonetic and keyboard-adjacency errors that raw edit distance never could.

05Candidate generation: the trie, and why it must be sharded

Now the core data-structure problem: given a prefix, return the top-k completions, fast. The classic answer is a trie (prefix tree) where each node stores its own precomputed top-k completions. Walking to the node for a prefix is O(length of prefix), and the answer is sitting right there. No scan.

trie_node.hc++
struct TrieNode {
  std::unordered_map<char, TrieNode*> children;
  std::vector<Completion>             top_k;  // precomputed OFFLINE, e.g. top 10
};

std::vector<Completion> Lookup(TrieNode* root, const std::string& prefix) {
  TrieNode* node = root;
  for (char c : prefix) {
    auto it = node->children.find(c);
    if (it == node->children.end()) return FuzzyFallback(prefix);
    node = it->second;
  }
  return node->top_k;   // O(prefix length), no runtime scan of the subtree
}

That precomputed top_k is the whole game. It is what lets a lookup touch exactly one node and stop. Without it you would have to explore the entire subtree under a prefix at request time and merge, which no latency budget allows.

The problem: this index (hundreds of millions of prefixes across every language, each node carrying its top-k) does not fit on one machine, and one machine could never serve global keystroke QPS anyway. So it has to be partitioned. The real design question is how to split a tree without breaking the "one lookup touches one place" property.

Why the obvious split fails

Shard by first letter (a-m here, n-z there) and you get wildly uneven load. English query traffic is enormously skewed by starting letter: prefixes under s, t, c, w dwarf x, q, z. One shard melts while another idles.

Two-tier routing, balanced by measured traffic

The fix is a small, fully-replicated router holding just the first few characters of prefix space, with shard boundaries assigned by observed query frequency (a greedy bin-packing) rather than by the alphabet. A shard might own the unrelated set {th, qz, vk} purely because that combination balances load. This is much closer to weighted consistent hashing than to an alphabetical cut.

Router → shard → replica set
Router (Tier 1) replicated everywhere "se" → shard 7 "th" → shard 3 "qz" → shard 3 "am" → shard 5 SHARD 7 · owns {se, …} replica A replica B replica C identical copies SHARD 3 · owns {th, qz, …} · HOT → more replicas rep A rep B rep C rep D rep E SHARD 5 · owns {am, …} replica A replica B Invariant: one keystroke → one shard → one replica. The router lookup is a hash-map hit on 2-3 chars; the deep trie walk happens entirely on that one machine. No cross-shard fan-out, ever.
A "shard" is a logical partition. It lives on a replica set of several identical machines for fault tolerance and QPS. Hot shards simply get more replicas behind the load balancer.

Each shard's machines hold the full deep subtree for whatever shallow prefixes they own, with the precomputed top-k on every node. A request for seattle routes on se to shard 7, lands on any one of shard 7's replicas, walks the local trie, and returns. That the answer is pre-aggregated per node is what keeps this to a single hop.

When one prefix is too big: hierarchical routing

Sometimes a single shallow prefix (th for the, this, that; ho for how to…) is so large or so hot that its subtree alone won't fit on one machine. The fix is to make the routing key length variable: go deeper only where needed.

routing.ccc++
// Most prefixes stop at 2 chars. Hot ones recurse to 3+.
routing_table["qz"] = 3;               // small: bundled onto shard 3
routing_table["th"] = ROUTE_TO_SUBROUTER;   // too hot: don't stop here
    subrouting_table["the"] = 12;
    subrouting_table["tha"] = 13;
    subrouting_table["thi"] = 14;

The invariant survives (still one request, one shard). Only the cut depth adapts per branch.

Absorbing spikes and skew

  • Replica count tracks live QPS. A prefix that explodes on breaking news doesn't trigger a re-shard. The load balancer just spins up more replicas of that shard; the router still points at "shard 3."
  • A separate, tiny, globally-replicated trending index sits alongside the main lookup to catch spikes that the multi-hour rebuild can't. It is small enough to copy everywhere because "what's trending right now" is a minuscule slice of all prefixes.
  • Edge caching for the head of the distribution. Traffic is Zipfian: a handful of prefixes (facebook, weather, youtube) dominate. An LRU cache at the edge serves those without ever hitting a backend.

Merging multiple candidate sources

The global trie is only one source. The real response fans out to a small, fixed number of sources in parallel and merges them at ranking. Fixed matters: the fan-out scales with the number of signal types (about four), not with the size of the dictionary.

candidates.pypython
async def get_candidates(prefix, ctx):
    results = await asyncio.gather(
        global_trie_lookup(prefix),                 # sharded, as above
        trending_lookup(prefix),                    # small, replicated everywhere
        personalization_lookup(ctx.user, prefix),  # keyed by user
        geo_lookup(prefix, ctx.location),           # local places / businesses
    )
    return rank_and_merge(results)

06Ranking: the cheap first pass and the sharp second pass

The top_k stored in each trie node is really a coarse first-pass ranking, mostly raw frequency, frozen at index-build time. Once candidate generation has merged ~50-200 candidates from all four sources, a second pass re-scores them with richer signals that weren't available when the trie was built.

SignalWhat it captures
CTR given prefixNot global popularity, but P(click | this candidate shown, for this exact prefix). wea → weather vs wear is decided by what people who typed wea actually pick. The single most important signal.
Global frequencyLog-scaled, time-decayed search volume. Cheap and stable, already baked into top-k.
Trending / recencyZ-score of recent volume vs a rolling baseline. Lets breaking news jump the ranking with a thin history.
PersonalizationMatch against the user's own recent / session searches. Lightly weighted, privacy-constrained.
GeoDistance-decayed boost for local entities when a location signal exists.
Safety / policyA hard gate, not a feature. Failing candidates are removed entirely, never merely down-weighted.
rank.ccc++
double Score(const Candidate& c) {
  if (!c.passes_safety) return -INFINITY;   // hard exclude
  // Illustrative blend. Production trains a small GBDT / LambdaMART over
  // these features — affordable because the candidate set is already tiny.
  return 0.35*c.ctr_given_prefix + 0.20*c.global_freq
       + 0.15*c.trending    + 0.15*c.personalization
       + 0.10*c.geo         + 0.05*c.language_match;
}

std::vector<Candidate> RankAndDiversify(std::vector<Candidate> cands, int k) {
  for (auto& c : cands) c.score = Score(c);
  std::sort(cands.begin(), cands.end(), by_score_desc);
  std::vector<Candidate> out;
  for (auto& c : cands) {                     // MMR-style dedup
    if (out.size() >= k) break;
    if (!TooSimilarToAny(c, out)) out.push_back(c);
  }
  return out;
}

That final diversity pass matters more than it looks. Without it, one hot entity crowds the list with near-duplicates (weather, weather today, weather tomorrow, weather now) and starves you of distinct options. A greedy maximal-marginal-relevance selection trades a little raw score for variety in what's actually shown.

The trap in the training data: position bias

CTR is learned from logged impressions, but a candidate shown in slot 1 gets clicked more just for being first, not for being better. Train on that naively and you learn "position 1 is good," which is circular. Two standard fixes: run a small slice of traffic with randomized order to collect unbiased labels, and apply inverse propensity weighting (reweight each logged click by 1 / P(examined | position)) so top-slot clicks stop dominating.

07The filtering layer nobody sees

Before anything is returned, candidates pass a policy gate: safety classes (violence, self-harm, adult, harassment), spam and scraper-generated suggestion removal, legal takedowns (right-to-be-forgotten style suppression), and near-duplicate dedup. This is a real, heavily-staffed engineering surface. Autosuggest has been publicly criticized for surfacing offensive completions, so an entire moderation pipeline lives here, with both offline review and fast online suppression.

08The offline pipeline: a two-speed machine

Everything above only reads. Building what it reads is the larger system, and it runs at two speeds: a thorough batch pipeline that rebuilds the main index every few hours, and a fast, approximate streaming pipeline that exists solely to catch what batch is too slow for.

Two speeds, two artifacts
Scrubbed logs (prefix, completion) BATCH · hours Aggregate + decay counts, time-weighted Safety filter heavy review Build top-k trie + train ranker Blue/green deploy shards STREAMING · minutes Rolling counters vs seasonal baseline Fast classifier lightweight safety Trending index push everywhere
Batch produces two artifacts (the sharded trie and the retrained ranking model). Streaming produces one (the trending index). They serve different parts of the online path.

The batch side: aggregate with decay

Count every (prefix → completion) occurrence, but weight recent activity more heavily so a completion that was hot six months ago doesn't outrank what's current. This is a distributed reduce over the full log corpus, sharded by prefix hash.

aggregate.ccc++
// One pass over a window of scrubbed, sampled logs.
std::unordered_map<AggKey, double> Aggregate(const Logs& logs, Time now) {
  std::unordered_map<AggKey, double> score;
  for (const auto& e : logs) {
    double age_days = DaysBetween(e.ts, now);
    double decay = std::exp(-kDecayRate * age_days);  // exponential time decay
    score[{e.prefix, e.completion}] += decay;
  }
  return score;   // → feeds top-k selection per trie node
}

The streaming side: spikes against a seasonal baseline

The batch cadence is fundamentally too slow for a term that goes from zero to huge in twenty minutes. Streaming maintains a rolling count per candidate and compares it to an expected baseline. The critical detail: the baseline must be seasonality-aware. A flat average would constantly misfire, because dinner recipes spikes every evening and nfl scores spikes every Sunday. Those cycles are expected, not trends. So the baseline is learned per time-of-day / day-of-week bucket, and only deviation from that counts.

trending.ccc++
bool IsTrending(const TrendState& s) {
  if (s.total_volume < kMinVolume) return false;   // 2→10 searches isn't a trend
  double z = (s.current - s.baseline) / std::sqrt(s.baseline + 1.0);
  return z > kZThreshold;
}
// Baseline updates per time-of-day / day-of-week slot, so "Sunday 4pm"
// is learned separately from "Tuesday 4pm".
void UpdateBaseline(TrendState& s, double observed) {
  s.baseline = kAlpha * observed + (1 - kAlpha) * s.baseline;   // EWMA
}
Safety under time pressure

Batch has hours to run heavy moderation. Streaming has minutes. So trending candidates pass a cheap, fast classifier before entering the trending index, with the ability to suppress within seconds if something slips through. This is a genuine tradeoff: faster surfacing of useful breaking-news signal against a higher risk of a bad term appearing briefly before suppression catches it.

09The parts that usually get skipped

The eight sections above are the spine. These are the pieces that separate a textbook answer from someone who has actually thought about running this.

Privacy is a first-class constraint, not an afterthought

Suggestions are built from other people's queries, which means a careless pipeline could surface someone's private search as a public suggestion. The guardrails: aggregate only, never raw logs; a k-anonymity threshold so a completion is only eligible if enough distinct users searched it (rare, potentially-identifying queries are dropped); and differential-privacy-style noise on the counts. This is also why you sometimes see fewer suggestions than you'd expect for an unusual prefix. The candidates existed but didn't clear the anonymity bar.

Zero-prefix suggestions

Before you type anything, the box often already shows suggestions: recent searches, trending topics, sometimes context from the page. This is a different code path (no prefix to match) driven almost entirely by personalization and trending, and it is a deliberate product surface, not a side effect.

Multi-language, IME, and transliteration

A huge share of the world types one language using another's alphabet: Hindi or Arabic or Japanese entered in Latin characters, then transliterated. Autosuggest has to complete and transliterate at once (namas → नमस्ते), which means script detection and a transliteration model sit in the query-understanding path for those locales, and the trie effectively indexes across scripts.

Entity awareness

Many suggestions aren't raw historical query strings, they are entities from the Knowledge Graph: people, places, movies, products. That is why suggestions can carry a little thumbnail or descriptor, and why a brand-new movie can be suggested well before enough people have searched its exact title, the entity exists in the graph even if the query log is thin.

Everything here is an experiment

Ranking weights, the diversity threshold, how aggressively to trend, how many suggestions to show, all of it is continuously A/B tested. The metrics are not "did they click a suggestion" in isolation but downstream: did using a suggestion lead to a successful search session, or did the user have to reformulate anyway? A suggestion that gets clicked but leads nowhere is a bad suggestion.

Compressing the index

A plain pointer-based trie is memory-hungry, and memory is the binding constraint on how much you can serve per machine. Production systems lean on succinct structures (FSTs / minimal-acyclic finite-state automata, LOUDS-encoded tries) that store the same prefix set in a fraction of the space by sharing suffixes and encoding structure as bit vectors. Less memory per machine means fewer machines, means lower latency and cost. It is not a detail. It is often the difference between fitting in RAM and not.

10The tensions, in one place

TensionHow autosuggest resolves it
Latency vs freshnessPrecompute tries for O(prefix) reads, accept that freshness is bounded by rebuild cadence, and bolt on a separate real-time trending path for the cases that can't wait.
Memory vs coverageCap each node at top-N, use succinct encodings, and let long-tail prefixes fall back to fuzzy matching or simply return fewer results.
Personalization vs scaleKeep the global trie shared and stateless; merge per-user signals only at the ranking stage, never bake them into the shared index.
Consistency vs availabilitySharded replicas can be briefly stale right after a rebuild push. Fine here. Suggestions aren't safety-critical the way a payment is.
Useful vs safeA hard safety gate on the online path, heavy moderation offline, and a fast classifier plus seconds-level suppression on the trending path.
The dropdown feels free because someone spent enormous effort making sure the online path never does anything but read.

That is the whole loop: a client optimizing perceived latency, an edge that terminates close to you, a suggestion service that only reads precomputed structures on a 10-millisecond budget, a sharded in-memory trie with fuzzy matching fused into it, a light second-pass ranker, a safety gate, and behind all of it a two-speed offline pipeline keeping the structures fresh without ever slowing down the read. Every choice traces back to one line drawn on day one: the fast path reads, the slow path writes, and the two only meet at deploy. Thanks for reading ✦