Lesson 102 of 105 11 minFlagship

System Design: Search Autocomplete at Google Scale

Design a typeahead/autocomplete system that returns relevant suggestions in under 100ms for billions of queries. Covers trie vs inverted index, ranking algorithms, and distributed architecture.

Reading Mode

Hide the curriculum rail and keep the lesson centered for focused reading.

Key Takeaways

  • Search autocomplete is highly latency-sensitive, requiring a p99 < 100ms budget including network RTT.
  • Precomputing and storing the top 5 suggestions directly at each Trie node converts an expensive graph traversal into a fast O(L) lookup.
  • An offline MapReduce pipeline weekly updates and rebuilds the Trie partitions to prevent production serving degradation.
Recommended Prerequisites
System Design Interview Framework

Premium outcome

From vague architecture answers to staff-level trade-off thinking.

Backend engineers preparing for senior, staff, and architecture rounds.

What you unlock

  • A reusable system design answer framework for ambiguous prompts
  • Clear language for consistency, scaling, and reliability trade-offs
  • Case-study depth across feeds, payments, storage, and messaging systems

Case Study: Search Autocomplete (Typeahead Suggestion at Google Scale)

Search autocomplete—the dropdown menu that surfaces matching predictions as you type—is one of the most latency-critical features in modern web applications. The moment a user presses a key, the system has a budget of under 100ms to capture the keystroke, resolve matching prefixes, sort by historical popularity, and render the results without lagging the user's typing experience. In this case study, we will design a highly available, sub-10ms distributed autocomplete system capable of scaling to billions of queries daily.


1. Requirements & Core Constraints

Functional Requirements

  • Top-5 Typeahead Suggestions: As the user types, return the top 5 most relevant query suggestions matching the input prefix.
  • Real-Time Popularity Ranking: Rank suggestions based on historical search frequencies, recency, and trending profiles.
  • Typo Tolerance & Fuzzy Matching: Gracefully handle spelling mistakes (e.g., typing "jva" should still suggest "java").
  • Personalized Autocomplete: Incorporate the user's local search history to prioritize personalized suggestions over global queries.

Non-Functional Requirements (SLAs)

  • Ultra-Low Latency (TTFB): The autocomplete lookup must execute with a p99 latency of under 10ms at the server layer. Including network round-trip time (RTT), the user must see results in under 100ms.
  • Massive Ingestion Scale: Handle 10 Billion daily queries, which translates to an average of 115,000 queries per second (QPS) and peaks of 250,000 QPS.
  • Near Real-Time Updates: Trending events or breaking news queries must bubble up into autocomplete suggestions in under 10 minutes.
  • High Availability: Redirection and prediction endpoints must maintain a 99.99% availability SLA.

Back-of-the-Envelope Capacity Estimates

To size our in-memory index, we calculate capacity requirements:

  • Daily Search Queries: 10,000,000,000 queries.
  • Unique Cleansed Queries: Assume only 2% of daily queries are unique terms (e.g., 200 Million unique strings).
  • Average Query Length: 20 characters (letters and spaces).
  • Average Character Size: 1 Byte (ASCII standard).

Memory Footprint Calculations:

  • Storing 200 Million unique queries with their frequency scores: $$\text{Raw String Memory} = 200,000,000 \times 20 \text{ bytes} \approx 4 \text{ Gigabytes (GB).}$$
  • Trie Index Overhead: A standard prefix tree (Trie) structure introduces memory pointer overheads (node object, child pointers, and precomputed suggestion lists). In practice, a production Trie with 200 Million nodes requires approximately 15 to 20 times the raw string size in memory: $$\text{Trie Memory Footprint} = 4 \text{ GB} \times 20 \approx \mathbf{80 \text{ GB of RAM.}}$$ This fits comfortably inside a single high-memory Redis instance, but for reliability and high availability, we shard the Trie across multiple cache nodes.

2. API Design & Core Contracts

The autocomplete endpoint runs on every single keystroke. To minimize latency overhead, we keep payloads lightweight.

A. Fetch Autocomplete Suggestions API

GET /v1/autocomplete?prefix=java+v&locale=en&user_id=usr_83472
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)

Response:

{
  "prefix": "java v",
  "suggestions": [
    "java virtual threads",
    "java versions",
    "java volatile keyword",
    "java vector api",
    "java var keyword"
  ],
  "personalized_count": 1,
  "elapsed_ms": 2
}

3. High-Level Design (HLD)

Our design decouples the Real-Time Query Pathway (which must execute in sub-10ms) from the Asynchronous Log Aggregation Pathway (which processes raw telemetry and updates ranking scores).

graph TD
    %% Real-Time Serving Path
    User((User Client)) -->|1. Type 'java v'| CDN[Global Anycast Edge CDN]
    CDN -->|2. Cache Miss| LB[Load Balancer]
    LB -->|3. Route Query| AutocompleteSrv[Query Suggestion Service]
    
    AutocompleteSrv -->|4. Read Cache| Redis[(Redis Cluster)]
    Redis -->|Cache Miss| TrieShard[(Trie Cache Node Shards)]
    
    %% Async Log Processing Pipeline
    User -->|5. Click Log Event| IngestGW[API Gateway]
    IngestGW -->|6. Raw Telemetry Event| Kafka[Kafka Click Log Topic]
    
    Kafka -->|7. Stream Consumption| Flink[Apache Flink Analytics Aggregator]
    Flink -->|8. Structured Query Frequency| DB[(NoSQL Frequency Table Cassandra)]
    
    %% Offline MapReduce Trie Builder
    DB -->|9. MapReduce Trie Batch Rebuild| MapReduce[MapReduce Offline Scheduler]
    MapReduce -->|10. Build & Serialize Trie| ObjectStore[(S3 Object Storage)]
    ObjectStore -->|11. Push Rebuilt Trie| TrieShard

Prefix Trie with Precomputed Top-5 Suggestions

If we traverse child branches dynamically at query time to find the most popular queries, a prefix lookup becomes extremely expensive ($O(V)$ where $V$ is all child vertices).

To achieve sub-10ms lookups, the offline builder precomputes the top 5 suggestions and stores them directly at each Trie node.

graph TD
    %% Root node mapping
    root((Root)) --> j((j))
    j --> ja((ja))
    ja --> jav((jav))
    
    %% Precomputed node contents
    javNode[Node 'jav' Content: <br/> - top_5: 'java', 'javascript', 'java virtual']
    jav -.-> javNode
    
    jav --> java((java))
    javaNode2[Node 'java' Content: <br/> - top_5: 'java', 'java virtual', 'java compiler']
    java -.-> javaNode2

4. Low-Level Design (LLD) & Data Models

Database Selection Rationale

  • Persistent Log Store (ScyllaDB / Cassandra): Raw search terms are collected in write-heavy tables. A Cassandra-like NoSQL store aggregates search occurrences seamlessly without transaction overhead.
  • Trie Shard Cache (In-Memory Redis Nodes): Autocomplete lookups cannot afford disk I/O. We shard the precomputed Trie index across an in-memory Redis cluster. The keys are organized by prefix ranges (e.g., prefix:ja, prefix:ko).

Java Compilable Trie Construction & Insert Code

The following structure shows how the Trie handles insertion while updating the precomputed top-5 suggestions at every parent node using a min-heap structure.

import java.util.*;

public class PrefixTrie {
    private static final int K = 5;

    public static class Suggestion implements Comparable<Suggestion> {
        String query;
        long score;

        public Suggestion(String query, long score) {
            this.query = query;
            this.score = score;
        }

        @Override
        public int compareTo(Suggestion other) {
            return Long.compare(this.score, other.score); // Min-Heap ordering
        }
    }

    public static class TrieNode {
        Map<Character, TrieNode> children = new HashMap<>();
        PriorityQueue<Suggestion> topSuggestions = new PriorityQueue<>(K);
        boolean isEndOfWord = false;
    }

    private final TrieNode root = new TrieNode();

    /**
     * Inserts a query and its historical score into the Trie.
     * Propagates and maintains the top-5 precomputed suggestions at all prefix levels.
     */
    public void insert(String query, long score) {
        TrieNode current = root;
        Suggestion newSuggestion = new Suggestion(query, score);

        for (char c : query.toCharArray()) {
            current.children.putIfAbsent(c, new TrieNode());
            current = current.children.get(c);
            updateTopK(current, newSuggestion);
        }
        current.isEndOfWord = true;
    }

    private void updateTopK(TrieNode node, Suggestion newSuggestion) {
        // Prevent duplicate suggestions inside the top-K list
        node.topSuggestions.removeIf(s -> s.query.equals(newSuggestion.query));
        
        node.topSuggestions.offer(newSuggestion);
        if (node.topSuggestions.size() > K) {
            node.topSuggestions.poll(); // Evict the lowest scoring suggestion
        }
    }

    /**
     * Resolves the prefix and returns precomputed top-5 suggestions in O(L) time.
     */
    public List<String> getSuggestions(String prefix) {
        TrieNode current = root;
        for (char c : prefix.toCharArray()) {
            current = current.children.get(c);
            if (current == null) {
                return Collections.emptyList(); // Prefix not found
            }
        }

        // Extract and sort results from the precomputed priority queue
        List<Suggestion> sortedList = new ArrayList<>(current.topSuggestions);
        sortedList.sort(Collections.reverseOrder()); // Highest score first

        List<String> results = new ArrayList<>();
        for (Suggestion s : sortedList) {
            results.add(s.query);
        }
        return results;
    }
}

5. Scaling Challenges & Bottlenecks

A. Trie Partitioning & Sharding (Alphabetical vs. Query Hash)

An 80GB Trie cannot reside on a single physical Redis node without exposing a Single Point of Failure (SPOF) and saturating CPU limits. We must partition the Trie:

  • Alphabetical Partitioning: Shard the index based on the first letter of the prefix (e.g., Shard 1 handles a-e, Shard 2 handles f-j).
    • The Bottleneck: Causes Hot Shards. The letter 's' or 'c' contains substantially more unique queries and traffic than the letter 'q' or 'x', leading to server imbalances.
  • Hash-Based Partitioning: Apply a consistent hashing ring to the query string prefix (e.g., hash(prefix) % Shards).
    • The Solution: This distributes prefix allocations uniformly across all shards, eliminating hot spots and balancing RAM usage across our cache instances.

B. High-Throughput Debouncing & Client-Side Caching

If a user types at 80 words per minute, sending an HTTP request on every single keystroke creates massive, redundant network load.

  • Debouncing: We apply a 150ms Debounce Delay inside the client browser. If the user types "java" quickly within 150ms, the client cancels the transient autocomplete triggers for 'j', 'ja', and 'jav', issuing exactly one request for the completed prefix.
  • Keystroke Cache: The client-side application maintains a local memory cache. If a user types "java", backspaces to "jav", and types "java" again, the client displays the results instantly from local memory, saving round-trip latency.

6. Real-World Trade-offs

A. Trie Memory Precomputation vs. Live Traversal Latency

  • Trie Option 1: Live Graph Traversal (Dynamic BFS): Nodes store only their raw character pointers. At query time, the system traverses all sub-branches to collect matching queries, then sorts them.
    • Trade-off: Ultra-low memory usage, but lookup latencies scale with the size of the sub-tree, taking up to 50ms under heavy loads—unacceptable for typeahead SLAs.
  • Trie Option 2: Full Precomputation (Top-5 stored at all nodes):
    • Trade-off: Memory consumption expands by up to $20\times$. However, lookups execute in $O(L)$ where $L$ is the prefix length, guaranteeing sub-millisecond retrieval speeds at the cache layer.
  • Our Decision: We select Full Precomputation because latency is our primary non-functional constraint. Memory is cheap compared to the operational cost of laggy user experiences.

7. Failure Scenarios & Fault Tolerance

A. Processing Malicious & Inappropriate Queries

If a botnet spams a garbage query like "xyz123abc" millions of times, it would contaminate the autocomplete index and displace genuine suggestions.

  • Mitigation: We place a Cleansing Pipeline inside the offline MapReduce scheduler:
    1. We filter queries against an active blacklist (containing profanity, spam markers, and private user identifiers).
    2. Queries are cross-referenced with a minimum click-through rate (CTR). A query must demonstrate an acceptance CTR of $> 1.5%$ to remain in the precomputed Trie, preventing automated bot spams from surfacing.

B. MapReduce Trie Push Failures (Rolling Deployments)

Updating the in-memory Trie sharding nodes with a new MapReduce build can cause temporary unavailability if we overwrite the index directly.

  • Solution: We employ a Double-Buffering (Blue-Green) Index Update Strategy. Cache nodes maintain two active Trie partitions: trie_active and trie_standby. The MapReduce builder pushes the serialized new index to trie_standby in the background. Once the load is verified, the API routers receive a toggle signal, swapping reads to trie_standby in under a microsecond, ensuring zero-downtime updates.

8. Staff Engineer Perspective (Operational Deep Dive)


9. Candidate Verbal Script (Mock Interview Guide)

Below is a first-person mock interview response displaying elite technical depth:

Candidate: "To design search autocomplete at Google scale, my absolute focus is optimizing read pathway latencies. Keystroke typeaheads have a hard server latency budget of under 10ms. If we attempt to query databases or execute dynamic breath-first searches (BFS) across sub-branches to find popular queries on every keystroke, the system will collapse under high traffic. To prevent this, my primary architectural decision is implementing a Precomputed Prefix Trie sharded using consistent hashing across an in-memory Redis cluster.

To guarantee O(L) lookup times, my offline MapReduce builders will precompute and store the top-5 highest-scoring suggestions directly at every prefix node. When a user types 'java v', the router traverses the tree in exactly as many steps as characters in the prefix, reading the precomputed predictions directly without child-branch exploration.

To handle the write path without thrashing the cluster, I will decouple ingestion. While a daily MapReduce job cleanses, filters, and builds the base Trie sharded structures, I will deploy a parallel, high-speed Apache Flink streaming pipeline. Flink will capture raw telemetry logs from a Kafka queue and dynamically track surging trending terms inside a dedicated, lightweight Redis trending cluster. The API Gateway will merge results from our stable precomputed Trie and our real-time trending cache, giving users instantaneous access to breaking news without exposing our primary databases to lock contention."


Want to track your progress?

Sign in to save your progress, track completed lessons, and pick up where you left off.