Medium

Design Search Autocomplete (Typeahead)

Design a service that suggests the top search queries as a user types each character, like Google Search or Amazon's search box. The core challenge is returning ranked suggestions for any prefix in well under 100ms while keeping the suggestion corpus fresh as query popularity shifts.

1Requirements

Functional

  • As the user types each character, return the top 5-10 most popular query completions for the current prefix.
  • Suggestions are ranked by historical query frequency, optionally boosted by recency and personalization signals.
  • The suggestion corpus updates as new queries trend (e.g., breaking news terms appear within hours, not weeks).
  • Support case-insensitive matching and basic normalization (trim whitespace, lowercase, strip accents).
  • Filter out blocked or inappropriate terms before they are ever suggested.

Non-functional

  • P99 end-to-end latency under 100ms; ideally the backend responds in under 20ms since network eats the rest.
  • Extremely high read throughput: every keystroke from every active searcher is a request.
  • High availability: autocomplete failing should degrade gracefully (search still works without suggestions).
  • Eventual consistency is fine; a trending query appearing 30 minutes late is acceptable.
  • Scale to billions of queries per day across a corpus of hundreds of millions of distinct queries.

2Back-of-envelope estimation

Search queries per day5 billion
Autocomplete QPS~350K average, ~700K peak
Distinct queries stored~200 million
Trie storage~100 GB
Log ingestion~2.5 TB/day

3API design

GET /v1/suggest?q={prefix}&limit=10&locale=en-US

Returns ranked suggestions for the prefix. Response is a small JSON array of {query, score}; served with aggressive edge caching for hot prefixes.

POST /v1/queries (internal, async)

Search service logs completed queries to the analytics pipeline (typically via Kafka, not a synchronous call) so frequencies can be aggregated.

PUT /v1/admin/blocklist

Admin endpoint to add or remove blocked terms; propagated to serving nodes so filtered suggestions disappear within minutes.

4High-level design

The client debounces keystrokes (e.g., 50-100ms) and issues a suggest request per settled prefix. Requests hit a CDN or edge cache first: prefixes follow a steep Zipfian distribution, so the top few thousand prefixes (single letters, common words) absorb a huge share of traffic and can be served straight from the edge with a short TTL.

Cache misses go through a load balancer to stateless API gateways, which route the prefix to the correct trie serving shard. Sharding is by prefix range (e.g., 'a'-'aq' on shard 1) with weights adjusted so hot letters do not overload one shard; a lookup table maintained by a coordinator maps prefix ranges to shards.

Each serving node holds its shard of the trie entirely in memory. Critically, each trie node stores a precomputed list of its top-k completions, so a lookup is O(len(prefix)) to walk to the node plus O(1) to read the cached top-k list, rather than a DFS over the subtree at query time.

On the write path, search logs flow through Kafka into an aggregation job (Flink for streaming or Spark for batch) that computes query frequencies over sliding windows. A builder service constructs a new trie snapshot every 30-60 minutes, applies the blocklist, and ships it to serving nodes, which swap the new snapshot in atomically and warm it before taking traffic.

Snapshots are also persisted to blob storage so a restarted node can bootstrap in minutes instead of rebuilding from raw logs. Weekly full rebuilds reconcile any drift from incremental updates.

5Data model

query_frequency (aggregated)

query VARCHAR PK, frequency BIGINT, decayed_score DOUBLE, last_seen TIMESTAMP, locale CHAR(5)

Output of the aggregation pipeline; input to the trie builder. Decayed score applies exponential time decay so stale queries fade.

trie_node (in-memory)

children MAP<char, ptr>, top_k ARRAY<{query, score}>[10], is_terminal BOOLEAN

top_k is precomputed at build time; this trades build cost and memory for O(1) reads.

shard_map

prefix_range_start VARCHAR, prefix_range_end VARCHAR, shard_id INT, replica_hosts ARRAY<VARCHAR>

Maintained by the coordinator; consulted by gateways for routing.

6Deep dives

Trie with precomputed top-k vs. alternatives

A naive trie answers 'top completions of prefix P' by walking to P's node and running a DFS over the entire subtree, collecting terminal nodes and sorting by frequency. For a short prefix like 'a' that subtree contains millions of queries, making the query path far too slow. The standard fix is to precompute and store the top-k completions at every node during the build, so reads become a pointer walk plus a memory read.

The cost is build time and memory: every query updates the top-k lists of all its ancestor nodes, and lists are duplicated down the tree. This is why the trie is rebuilt offline as a snapshot rather than mutated in place under live traffic. An alternative for smaller corpora is a sorted array of queries with binary search on prefix boundaries plus a precomputed sparse index; some teams also use finite state transducers (as in Lucene) which compress shared prefixes and suffixes dramatically.

Updating frequencies in real time inside the serving trie is usually not worth the complexity. Instead, treat the trie as immutable and rebuild frequently. If sub-minute trend detection matters (breaking news), layer a small secondary 'trending' index built from the last few minutes of stream data and merge its results with the main trie at query time.

Sharding and hotspot management

Sharding purely by first letter is tempting but badly skewed: prefixes starting with 's' or 'c' vastly outnumber 'x' or 'z'. A better approach is weighted range partitioning informed by historical prefix traffic: the coordinator analyzes the frequency distribution and cuts ranges so each shard serves a comparable QPS and memory footprint, e.g., shard 1 = 'a'-'ap', shard 2 = 'aq'-'b'.

Even within a balanced scheme, single-character prefixes are extreme hotspots. These are best handled outside the trie fleet entirely: there are only ~36 single-character prefixes per locale, so their top-k lists can be pushed to every edge cache and refreshed on each snapshot. The same applies to the top few thousand multi-character prefixes.

Each shard runs multiple replicas behind the router for both throughput and availability. Because snapshots are immutable, replicas are trivially consistent: they all load the same snapshot version, and the router can drain a replica, let it load the next snapshot, and re-add it with zero coordination.

Freshness, decay, and the ingestion pipeline

Raw query logs land in Kafka. A streaming aggregator maintains per-query counts over windows (e.g., hourly tumbling windows rolled into daily aggregates). Pure lifetime frequency is a poor ranking signal because it never lets new queries surface, so scores use exponential time decay: score = sum(count_in_window x decay^age). A decay half-life of a few days balances stability against trend responsiveness.

Counting hundreds of millions of distinct strings exactly in a streaming job is memory-heavy. Many systems use approximate counting: a count-min sketch for frequencies combined with a heavy-hitters structure to track the top candidates per prefix bucket, accepting small overcounts in exchange for bounded memory.

The builder consumes the aggregated scores, filters the blocklist, drops queries below a minimum score, and emits a versioned snapshot to blob storage. Serving nodes poll for new versions and hot-swap. If a snapshot is bad (e.g., a pipeline bug zeroes scores), nodes can roll back to the previous version, which is why keeping the last N snapshots in blob storage is standard practice.

7Rapid implementation: build the MVP

Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.

Stack

Node.js + Fastify, one Redis instance for prefix buckets, and a nightly cron for score decay, all on a single $12/mo VPS.

  1. 01Scaffold a Fastify server with GET /suggest?q= and an internal POST /queries hook that fires whenever a search is actually submitted.
  2. 02Write the ingester: for each prefix (up to 20 chars) of a submitted query, ZINCRBY the query in that prefix's Redis sorted set, then trim the set to its top 50 members to bound memory.
  3. 03Implement /suggest as a single ZREVRANGE on the prefix's sorted set, returning the top 10 as JSON.
  4. 04Seed the corpus with a bulk import script from a public query log (e.g., AOL dataset) or your own site's search history.
  5. 05Add a frontend input with a 75ms debounce that calls /suggest per settled keystroke and renders the dropdown.
  6. 06Add a Redis SET blocklist checked at ingest time so banned terms never enter a bucket.
  7. 07Add a nightly cron that walks all buckets and multiplies scores by 0.9 (ZUNIONSTORE with a weight) so stale queries decay.
  8. 08Load test hot prefixes with autocannon and confirm p99 stays under 20ms.

Redis ZSET prefix buckets: ingest and lookup

typescript
import Redis from "ioredis";
const redis = new Redis();
const MAX_PREFIX = 20;
const BUCKET_SIZE = 50;

export async function recordQuery(raw: string) {
  const q = raw.trim().toLowerCase();
  if (!q || (await redis.sismember("blocklist", q))) return;
  for (let i = 1; i <= Math.min(q.length, MAX_PREFIX); i++) {
    const key = "sug:" + q.slice(0, i);
    await redis.zincrby(key, 1, q);
    // keep only the best N per bucket so memory stays bounded
    await redis.zremrangebyrank(key, 0, -(BUCKET_SIZE + 1));
  }
}

export async function suggest(prefix: string, limit = 10) {
  const key = "sug:" + prefix.trim().toLowerCase();
  return redis.zrevrange(key, 0, limit - 1); // O(log n + limit)
}

In-memory trie with precomputed top-k per node

python
class TrieNode:
    def __init__(self):
        self.children = {}
        self.top_k = []  # (score, query) pairs, best first, max 10

def build_trie(query_scores):
    root = TrieNode()
    for query, score in query_scores.items():
        node = root
        for ch in query:
            node = node.children.setdefault(ch, TrieNode())
            # maintain the top-k list at every ancestor node
            node.top_k.append((score, query))
            node.top_k.sort(reverse=True)
            del node.top_k[10:]
    return root

def suggest(root, prefix):
    node = root
    for ch in prefix.strip().lower():
        if ch not in node.children:
            return []
        node = node.children[ch]
    return [q for _, q in node.top_k]  # O(len(prefix)) total

Bottlenecks & failure modes

  • Hot prefixes (single letters, trending terms) can overwhelm a single shard; mitigate with edge caching of hot prefixes and weighted shard ranges.
  • Trie rebuild time grows with corpus size; a full rebuild taking hours limits freshness, pushing you toward incremental builds or a separate trending layer.
  • Memory footprint: the full trie with top-k lists must fit in RAM across shards; uncontrolled corpus growth forces threshold pruning or compression (radix trie / FST).
  • Client keystroke storms without debouncing multiply QPS several-fold for no user benefit.
  • Snapshot rollout thundering herd: all replicas loading a 100 GB snapshot from blob storage simultaneously can saturate the network; stagger rollouts.

Key takeaways

  • Precompute top-k at every trie node so read latency is O(prefix length), independent of subtree size.
  • Treat the serving index as an immutable, versioned snapshot rebuilt offline; do not mutate it under live reads.
  • Exploit the Zipfian prefix distribution: cache hot prefixes at the edge and weight your shards by traffic, not alphabet.
  • Use time-decayed scores (and optionally a small real-time trending layer) so suggestions stay fresh without rebuilding constantly.
  • Autocomplete is an optional enhancement; design every failure mode to degrade to 'no suggestions' rather than blocking search.

Brush up on the underlying topics