Design Search Autocomplete
Search autocomplete (typeahead) displays ranked query suggestions as a user types. Every keystroke triggers a prefix lookup that must return the top-K most popular matching queries in under 100ms. The system must handle massive query volumes, keep suggestion frequencies fresh, and remain highly available — all while serving a latency-sensitive, user-visible feature.
Design it yourself
Don't just read it — drag components onto a canvas and get Aria's interviewer review.
Requirements
Functional
- Given a typed prefix, return the top-5 most popular matching query suggestions
- Suggestions must reflect actual search popularity, updated at least daily
- Support all alphanumeric characters and spaces in queries
- Return results within 100ms at p99 for a good user experience
- Real-time or near-real-time frequency updates when query volume shifts dramatically
Non-Functional
- Google scale: ~10B searches/day → ~115,000 QPS peak
- Autocomplete fires on each keystroke — roughly 5 keystrokes per search → ~575,000 autocomplete requests/sec at peak
- 99.9% availability — degraded suggestions are acceptable; zero suggestions are not
- Read-heavy workload: reads vastly outnumber frequency updates
- Data freshness: suggestions updated within 24 hours for batch, within minutes for trending queries
Capacity Estimation
| Autocomplete QPS (peak) | ~575,000 req/sec (5 keystrokes × 115K search QPS) |
| Trie nodes (top 50B queries × avg 20 chars) | ~50B nodes → ~1 TB RAM (naïve), pruned to ~10 GB |
| Unique prefixes cached in Redis | ~100M prefixes × 200 bytes/entry ≈ 20 GB per Redis shard |
| Query log ingestion rate | 115,000 queries/sec → ~1 TB of raw logs per day |
| Top-K list per prefix (K=5) | ~1 KB per prefix → 100M prefixes ≈ 100 GB total |
High-Level Components
Browser / Client
Debounces keystrokes (300ms) before firing the autocomplete API request. Caches results per prefix in a local JS Map for the session to avoid redundant network calls. Cancels in-flight requests when a newer keystroke arrives.
API Gateway
Terminates TLS, applies rate limiting per IP (protect against scraping), and routes GET /autocomplete?q={prefix} to the Autocomplete Service. Returns cached responses from CDN for popular prefixes.
Autocomplete Service
Stateless application tier. Checks Redis Sorted Set cache for the prefix first. On a miss, queries the Trie Service. Assembles the top-K list and returns it. Also asynchronously publishes the raw query to the Query Logger.
Redis Sorted Set (Cache)
Stores prefix → top-K suggestions as a sorted set scored by frequency. ZRANGEBYLEX for prefix scanning, ZINCRBY for frequency bumps. Acts as the primary fast-path — handles the majority of reads without touching the Trie.
Trie Service
In-memory Trie augmented with a top-K min-heap at every node. Serves cache-miss requests. Rebuilt or incrementally updated from the frequency store. Partitioned by first character (or consistent hash of prefix) across multiple nodes.
Query Logger
Receives every submitted search query (not every keystroke — only the final submitted query). Writes to Kafka topics partitioned by query term. Buffers locally to avoid blocking the search path.
Kafka
Durable, high-throughput event bus for raw query events. Decouples the latency-sensitive search path from the analytics/aggregation pipeline. Retains events for 7 days to support replay.
Aggregation Job (Spark / Flink)
Consumes Kafka query events, aggregates query counts over a rolling window (hourly or daily), and writes updated frequencies to the Frequency Store. Batch jobs run every 24 hours; streaming jobs (Flink) for near-real-time trending.
Frequency Store (Key-Value DB)
Stores { query → count } for all known queries. Source of truth for building and refreshing the Trie and Redis cache. Backed by RocksDB or DynamoDB for fast point lookups by the Trie builder.
Architecture Diagram
Deep Dives
Trie Data Structure — Node Design and O(L) Operations
A Trie (prefix tree) is the canonical data structure for autocomplete. Each node represents one character. A path from root to a node spells out a prefix, and each node stores its children and an optional count if it is a complete query.
Insert: O(L) — Walk the trie one character at a time (L = query length). Create nodes as needed. Increment the frequency at the terminal node.
Search: O(L + K) — Traverse the trie to the node representing the full prefix (O(L)), then collect all complete queries in its subtree (O(subtree size)). Without further optimisation this subtree traversal is expensive.
The key problem: collecting top-K from an unbounded subtree is O(subtree) which can be enormous. The solution is to augment each node with a pre-computed sorted top-K list — see the next section.
Space: A naïve trie storing all 50B Google queries would require hundreds of gigabytes. In practice, prune nodes with frequency below a threshold (e.g. queries seen fewer than 10 times total are dropped). This reduces the working set dramatically.
Java — TrieNode with HashMap children and frequency counter
import java.util.HashMap;
import java.util.Map;
public class TrieNode {
// Character children — HashMap chosen over array[26] to
// support spaces, digits, and non-ASCII characters.
final Map<Character, TrieNode> children = new HashMap<>();
// Frequency of this exact query string ending at this node.
// 0 means this node is only an intermediate prefix, not a complete query.
int frequency = 0;
// Pre-computed top-K suggestions rooted at this node.
// Populated during the offline build phase (see TopK section).
// Null until build is complete.
TopKList topK = null;
}
public class Trie {
private final TrieNode root = new TrieNode();
/** Insert or increment a query. O(L) where L = query.length(). */
public void insert(String query, int count) {
TrieNode node = root;
for (char c : query.toCharArray()) {
node.children.putIfAbsent(c, new TrieNode());
node = node.children.get(c);
}
node.frequency += count;
}
/**
* Traverse to the node representing the given prefix. O(L).
* Returns null if the prefix does not exist in the trie.
*/
public TrieNode searchPrefix(String prefix) {
TrieNode node = root;
for (char c : prefix.toCharArray()) {
node = node.children.get(c);
if (node == null) return null;
}
return node;
}
}Top-K Suggestions Per Prefix — Augmented Trie Nodes
Naively collecting the top-K results requires a full DFS over potentially millions of subtree nodes. Instead, pre-compute and store the top-K list at every node during an offline build pass.
Build phase (offline): 1. Do a post-order DFS over the entire trie. 2. At each leaf (complete query), the top-K list is just that single query + frequency. 3. At each internal node, merge the top-K lists of all children plus the node's own frequency (if it is also a complete query). 4. Keep only the top-K by frequency using a min-heap of size K.
Query phase: Given a prefix, traverse to its node in O(L), then return `node.topK` in O(1).
Trade-off — Space vs Speed: - Space: Storing a top-K list at every node costs O(nodes × K). For 10M nodes with K=5, this is ~50M entries, roughly 1–2 GB — acceptable. - Speed: Query latency drops from O(subtree) to O(L). At K=5 and average query length 8, this is a massive win. - Update cost: When a query frequency changes, every ancestor node's top-K list may need updating. This is why top-K lists are rebuilt offline (batch) rather than updated in-place on every query.
Alternative — Min-Heap per node (real-time): Keep a `PriorityQueue<Suggestion>` of size K at each node. On each insert, walk the path and update heaps. Too expensive for write-heavy workloads at Google scale, but viable for smaller systems.
Java — Post-order DFS building top-K list at every Trie node using a min-heap
import java.util.*;
public class Suggestion implements Comparable<Suggestion> {
final String query;
final int frequency;
Suggestion(String query, int frequency) {
this.query = query;
this.frequency = frequency;
}
// Natural order: ascending frequency (min at top of min-heap)
@Override
public int compareTo(Suggestion other) {
return Integer.compare(this.frequency, other.frequency);
}
}
public class TrieBuilder {
private static final int TOP_K = 5;
/**
* Post-order DFS: builds top-K list for every node.
* Returns the top-K list for the subtree rooted at this node.
*/
public List<Suggestion> buildTopK(TrieNode node, String currentPrefix) {
// Min-heap of size K — evicts lowest-frequency entries
PriorityQueue<Suggestion> heap = new PriorityQueue<>(TOP_K + 1);
// Include this node itself if it is a complete query
if (node.frequency > 0) {
heap.offer(new Suggestion(currentPrefix, node.frequency));
}
// Recurse into children and merge their top-K lists
for (Map.Entry<Character, TrieNode> entry : node.children.entrySet()) {
List<Suggestion> childTopK =
buildTopK(entry.getValue(), currentPrefix + entry.getKey());
for (Suggestion s : childTopK) {
heap.offer(s);
if (heap.size() > TOP_K) {
heap.poll(); // evict minimum-frequency entry
}
}
}
// Convert heap to sorted descending list
List<Suggestion> result = new ArrayList<>(heap);
result.sort((a, b) -> Integer.compare(b.frequency, a.frequency));
// Cache the result on the node for O(1) lookup later
node.topK = result;
return result;
}
}Redis Sorted Sets for Prefix Matching
Rebuilding the Trie is expensive and cannot happen on every frequency change. Redis Sorted Sets provide a fast, mutable cache layer for prefix-to-top-K lookups with O(log N + M) retrieval.
Data model: - One sorted set per prefix: key = `ac:{prefix}`, members = query strings, scores = frequency counts. - Example: `ZADD ac:ap 98000 "apple" 72000 "app store" 60000 "application"`
Lookup: `ZREVRANGEBYSCORE ac:{prefix} +inf -inf WITHSCORES LIMIT 0 5` returns the top-5 by score in O(log N + K).
Frequency update (real-time path): When the aggregation job processes a query, for every prefix of that query (1 to L characters), issue `ZINCRBY ac:{prefix} {delta} {query}`. This is O(L × log N) per query update.
Alternative — ZRANGEBYLEX: Store all queries in a single sorted set with lexicographic ordering (all scores = 0). `ZRANGEBYLEX queries "[ap" "[ap\xff"` returns all queries beginning with "ap". Then fetch frequencies separately. Simpler to populate but slower for top-K extraction since scores carry no frequency information.
Memory pressure: 100M unique prefixes × ~200 bytes = 20 GB per Redis instance. Shard by the first 2 characters of the prefix across a Redis cluster (26² = 676 possible buckets). Apply a TTL to rarely-accessed prefixes to bound memory growth.
Cache invalidation: When the offline Trie rebuild completes, the Aggregation Job re-populates Redis for the top-N most frequent prefixes. Less popular prefixes stay stale until their TTL expires and are re-fetched from the Trie on next access.
Java — Redis Sorted Set cache: top-K lookup, populate, and frequency increment
import redis.clients.jedis.Jedis;
import redis.clients.jedis.resps.Tuple;
import java.util.List;
import java.util.Set;
public class RedisAutocompleteCache {
private static final int TOP_K = 5;
private static final long TTL_SECONDS = 3600; // 1 hour for low-traffic prefixes
private final Jedis jedis;
public RedisAutocompleteCache(Jedis jedis) {
this.jedis = jedis;
}
/**
* Retrieve top-K suggestions for a prefix.
* Returns an empty list on cache miss (caller must fall back to Trie).
* O(log N + K) where N = members in the sorted set.
*/
public List<String> getTopK(String prefix) {
String key = "ac:" + prefix.toLowerCase();
// ZREVRANGE returns members ordered highest score first
List<String> results = jedis.zrevrange(key, 0, TOP_K - 1);
return results; // empty list = cache miss
}
/**
* Populate (or refresh) the cache for a prefix with a full top-K list.
* Called by the offline aggregation job after each rebuild cycle.
*/
public void populate(String prefix, List<Suggestion> topK) {
String key = "ac:" + prefix.toLowerCase();
jedis.del(key);
for (Suggestion s : topK) {
jedis.zadd(key, s.frequency, s.query);
}
jedis.expire(key, TTL_SECONDS);
}
/**
* Increment the frequency of a query for all its prefixes.
* Called by the near-real-time Flink pipeline for trending queries.
* O(L × log N) where L = query length.
*/
public void incrementFrequency(String query, long delta) {
for (int i = 1; i <= query.length(); i++) {
String prefix = query.substring(0, i).toLowerCase();
String key = "ac:" + prefix;
jedis.zincrby(key, delta, query);
}
}
}Data Collection Pipeline — From Keystrokes to Frequency Store
The frequency data that powers suggestions comes from actual user searches. Collecting this accurately and efficiently requires a dedicated pipeline.
What to log: Log only *submitted* queries (when a user presses Enter or clicks a result), NOT every autocomplete keystroke. Logging keystrokes would inflate counts for partial prefixes and is wasteful at 575,000 req/sec.
Step 1 — Query Logger (inline, non-blocking): The Autocomplete Service writes each submitted query to a local in-memory queue (bounded, ~10K entries). A background thread flushes the queue to Kafka every 100ms. The main request thread never blocks on Kafka availability.
Step 2 — Kafka: Query events land in a `search-queries` topic partitioned by the hash of the normalized query term. This ensures all events for a given query go to the same partition, simplifying count aggregation. Retention: 7 days.
Step 3 — Batch Aggregation (Spark, runs every 24 hours): Reads the last 24 hours of Kafka events, groups by normalized query, sums counts, and upserts into the Frequency Store (e.g. DynamoDB or RocksDB). Also filters out personal data — queries that appear fewer than a threshold (e.g. 10 times) are dropped to protect privacy.
Step 4 — Trie + Redis Rebuild: After the Spark job completes, a builder job reads the top-N queries from the Frequency Store, rebuilds the Trie in memory, computes top-K at every node (post-order DFS), then atomically swaps the live Trie pointer. Redis is simultaneously re-populated for the most frequent prefixes.
Step 5 — Near-Real-Time Path (Flink, optional): For trending queries (sudden spike in last 1 hour), a Flink job consumes the same Kafka stream with a 5-minute tumbling window. When a query's windowed count exceeds a threshold (e.g. 10,000 queries in 5 minutes), it directly `ZINCRBY`s the Redis sorted sets for its prefixes, bypassing the daily Spark cycle. This surfaces viral events (breaking news, sports scores) without waiting 24 hours.
Java — Non-blocking query logger with in-memory buffer and async Kafka flush
import org.apache.kafka.clients.producer.*;
import java.util.Properties;
import java.util.concurrent.*;
public class QueryLogger {
private static final int BUFFER_SIZE = 10_000;
private static final long FLUSH_INTERVAL_MS = 100;
private final KafkaProducer<String, String> producer;
private final BlockingQueue<String> buffer = new ArrayBlockingQueue<>(BUFFER_SIZE);
public QueryLogger(Properties kafkaProps) {
this.producer = new KafkaProducer<>(kafkaProps);
startFlushThread();
}
/**
* Non-blocking log of a submitted query.
* Drops silently if the buffer is full — acceptable: we prefer low latency
* over 100% capture accuracy for autocomplete frequency data.
*/
public void log(String query) {
buffer.offer(normalize(query)); // offer = non-blocking, returns false if full
}
/** Background flush: drains the buffer to Kafka every FLUSH_INTERVAL_MS. */
private void startFlushThread() {
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
String query;
while ((query = buffer.poll()) != null) {
producer.send(
new ProducerRecord<>("search-queries", query, query),
(meta, ex) -> { if (ex != null) ex.printStackTrace(); }
);
}
}, 0, FLUSH_INTERVAL_MS, TimeUnit.MILLISECONDS);
}
/** Normalize: lowercase, trim whitespace, collapse multiple spaces. */
private String normalize(String query) {
return query.toLowerCase().trim().replaceAll("\\s+", " ");
}
}Scaling and Caching — Sharding, CDN, and Client-Side Debounce
At 575,000 autocomplete req/sec, a single Autocomplete Service instance and a single Redis node are far from sufficient. The system must scale horizontally at every tier.
Prefix Sharding (Redis and Trie): Shard by the first character (or first 2 characters) of the normalized prefix. With 2-character sharding (26×26 = 676 buckets), each shard handles ~850 req/sec on average — trivially manageable. Route requests at the API Gateway using a consistent hash of the first two characters. This keeps all keys for a given prefix on the same shard, avoiding cross-shard scatter.
CDN Caching for Popular Prefixes: The 200 most common 1–3 character prefixes ("th", "wh", "ho", "how to"...) account for a disproportionate share of traffic. Cache these responses at the CDN edge with a short TTL (5–60 seconds). A 60-second stale window is imperceptible to users but eliminates millions of origin hits per minute. Use `Cache-Control: public, max-age=60` on responses where the prefix length ≤ 3.
Client-Side Debounce + Local Cache: A 300ms debounce means a user typing "hello" at normal speed fires at most 2–3 requests instead of 5. After the debounce, before hitting the network, the browser checks a `Map<string, string[]>` session cache. If the user typed "he" → gets results, then types "hel" → cache miss → fetches, then backspaces to "he" → cache hit, no network call. Prefix the cache key with a short timestamp bucket (rounded to the nearest 5 minutes) to ensure stale data is not served indefinitely.
Request Cancellation: Use `AbortController` (browser) or Axios cancellation to cancel in-flight HTTP requests when a new keystroke arrives. Prevents a slow response for "ap" from overwriting a fast response for "app".
Read Replicas for the Trie Service: The Trie is rebuilt offline. During the build, the live read-only Trie clone serves traffic. On completion, the new Trie is loaded into standby instances, then a load balancer health-check cutover flips traffic. Zero downtime Trie refresh.
Monitoring SLOs: Alert on p99 latency > 100ms, cache hit rate < 90%, and Redis memory utilisation > 70%. A drop in cache hit rate often precedes a latency spike as more requests fall through to the Trie service.
Java — LRU prefix result cache and shard router for horizontal scaling
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* Thread-safe in-memory prefix cache for a single Trie shard.
* Used inside the Trie Service to avoid re-traversing the trie for
* hot prefixes within a short time window.
*/
public class PrefixResultCache {
private static final int MAX_ENTRIES = 50_000;
private static final long TTL_MS = 300_000; // 5 minutes
// LRU cache: prefix → (result list, expiry timestamp)
private final Map<String, CacheEntry> store =
Collections.synchronizedMap(new LinkedHashMap<>(MAX_ENTRIES, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, CacheEntry> eldest) {
return size() > MAX_ENTRIES;
}
});
public Optional<List<String>> get(String prefix) {
CacheEntry entry = store.get(prefix);
if (entry == null || System.currentTimeMillis() > entry.expiryMs) {
store.remove(prefix);
return Optional.empty();
}
return Optional.of(entry.results);
}
public void put(String prefix, List<String> results) {
store.put(prefix, new CacheEntry(results, System.currentTimeMillis() + TTL_MS));
}
private record CacheEntry(List<String> results, long expiryMs) {}
}
/**
* Shard router: maps prefix to the correct Redis shard node.
* Shards by first two characters of the prefix.
*/
public class PrefixShardRouter {
private final Map<String, RedisAutocompleteCache> shards;
public PrefixShardRouter(Map<String, RedisAutocompleteCache> shards) {
this.shards = shards;
}
public RedisAutocompleteCache route(String prefix) {
String key = prefix.length() >= 2
? prefix.substring(0, 2).toLowerCase()
: prefix.substring(0, 1).toLowerCase() + "_";
// Default to a catch-all shard if the exact bucket isn't present
return shards.getOrDefault(key, shards.get("default"));
}
}Key Trade-offs
Trie (in-memory) vs Redis Sorted Sets for autocomplete
Redis Sorted Sets are mutable (frequencies can be incremented without a rebuild), horizontally scalable, and operationally simpler. The Trie handles cache misses and provides a complete ranked view rebuilt from the full frequency corpus daily. Using only the Trie requires a full rebuild on every update; using only Redis requires storing every possible prefix upfront.
Real-time (Flink streaming) vs batch (Spark daily) frequency updates
Batch is operationally simple and consistent — the full frequency corpus is recomputed on a known schedule. Pure streaming is complex and can amplify transient spikes (a bot query). The hybrid approach uses streaming only to surface genuinely trending queries above a high threshold, keeping the batch job as the authoritative source of truth.
Store top-K at every Trie node vs compute on query
On-the-fly DFS over a subtree can visit millions of nodes for short prefixes like "a". Pre-computing drops query latency from O(subtree) to O(L). The trade-off is higher memory (O(nodes × K)) and the need for offline rebuilds, but both are manageable at Google scale with pruning.
Exact prefix match vs fuzzy / typo-tolerant matching
Fuzzy matching (edit distance, n-gram index) dramatically increases complexity and latency. Normalisation (lowercase, trim) catches the most common user inconsistencies. Full typo tolerance is better implemented as a separate spell-check layer (like Google's "Did you mean?") which runs in parallel with the exact autocomplete — not inside the hot path.
Interview Tips
- 1Always open by clarifying scale. "How many DAU? How many keystrokes per search?" changes your design from a single Redis node to a sharded cluster with a CDN layer.
- 2Explain why you log submitted queries only (not keystrokes) — it shows you understand data quality and avoiding artificial frequency inflation for partial queries.
- 3Interviewers frequently ask "how do you keep suggestions fresh?" Have a crisp answer: daily Spark batch for baseline, optional Flink streaming for trending, with Redis ZINCRBY as the update primitive.
- 4When asked about the Trie, proactively mention the subtree-traversal problem and explain the pre-computed top-K solution using post-order DFS and a min-heap — this is what separates strong candidates from average ones.
- 5Mention client-side debounce (300ms) and request cancellation early — it demonstrates awareness that autocomplete performance is a full-stack problem, not just backend.
- 6If pushed on edge cases, discuss: what happens when a new trending topic appears in the last hour (streaming pipeline), how you handle offensive/spam query suppression (a blocklist checked before returning results), and how you protect user privacy (minimum frequency threshold before a query enters suggestions).
Discussion
Discussion
Sign in to join the discussion.