๐Ÿ“š Study Notes / Home / HLD / Session 12
Session 12 ยท Case Study โ€” Search Typeahead

How Google finishes your search before you do

You start typing "how to tiโ€ฆ" and a list pops up โ€” "how to tie a tie," "how to tie shoes" โ€” before you've finished the word. That little dropdown is one of the most demanding systems on the internet: it has to answer in a blink, for millions of people at once. This is a design session: we'll take everything from earlier sessions (caching, sharding, load balancing) and assemble a real system from scratch. No prior systems knowledge assumed โ€” every idea starts with a tiny story.

โฑ 34 min read๐Ÿ“– 8 topics

1 Approaching a design problem


Explain like I'm 5

Imagine your teacher says "build something cool" and walks away. No instructions! A bad student builds a random toy. A great student first asks lots of little questions โ€” "Cool for who? How big? How fast?" โ€” until they know exactly what to build, then builds it. A design interview is exactly that: the question is fuzzy on purpose, and your real job is to turn the fog into a clear plan before you build.

Before we touch a single trie or cache, we need to know how to attack an open-ended design question. This is a skill of its own โ€” and it's very different from a coding (DSA) interview.

DSA interviews vs design interviews

DSA interviewDesign interview
Problem statementConcrete, well-definedExtremely vague โ€” e.g. just "Design Typeahead" (you might not even know what typeahead is)
Inputs / outputsExample input-output pairs givenYou have to figure them out
DirectionYou know the kind of solution that worksOpen-ended; discussion can go anywhere, depends on your and the interviewer's expertise
ConstraintsGiven (e.g. array of 10โถ ints โ‡’ can't use O(nยฒ), max O(n log n))You must discover the constraints by asking
TimeTime constrainedStill time constrained โ€” typically 45 mins
How to solve a DSA problem (for contrast)

Problem statement โ†’ work examples by hand โ†’ brute force โ†’ draw observations โ†’ optimize. A design interview borrows the spirit ("understand before you build") but the structure is different.

In a design interview your job is to (1) figure out what you're supposed to build โ€” i.e. what the interviewer actually wants, (2) build that solution, and (3) deliberately take on sufficiently complex things so you can showcase your skills and impress the interviewer.

The 5-step design approach

A reliable script for the 45 minutes. Roughly the first half is about understanding, the second half about building.

1
Problem Statement
~20โ€“25 min for steps 1โ€“4
โ†’
2
Functional Reqs
the features
โ†’
3
Non-functional Reqs
the qualities
โ†’
4
Scale Estimation
how big?
โ†’
5
API + System Design
~20โ€“25 min, the build

Step 1 โ€” Problem statement: reason by analogy

When the statement is vague, reason by analogy: find existing companies or systems that offer a similar product/feature. This gives you an overview of the umbrella of different scopes you could consider.

  • Typeahead is a specific form of autocomplete โ€” literally trying to type ahead of the user.
  • Examples: typeahead in Google, typeahead in Amazon.
  • Typeahead & Search are different products, but typeahead is always followed by some form of search.
Typeahead in Google
Typeahead in Google โ€” as you type, a dropdown of popular completions appears.
Typeahead in Amazon
Typeahead in Amazon โ€” the same idea, applied to product search.
Typeahead is followed by search
Typeahead and Search are different products, but typeahead is always followed by some form of search.
What NOT to build โ€” autocomplete โ‰  typeahead

Autocomplete shows up in many settings, and most of them are not what we're designing:

  • Smartphone keyboards: word completion / contacts.
  • Text / code editors: intellisense (complete next token), GitHub Copilot (whole functions).
  • Grammarly: sentence completion while writing prose.
  • Gmail / Outlook: sentence completion for mail; to/cc/bcc email completion from contacts.

The key difference: autocomplete is typically local to your machine and standalone โ€” suggestions are specific to you. Typeahead is always associated with search, and its suggestions are the most popular queries searched globally in the past that match the given prefix.

Functional requirements & the MVP mindset

Functional requirements (FR) start from a Minimal Viable Product (MVP):

  • Minimal: the minimal set of features to achieve viability.
  • Viable: should demonstrate the core features & usability.
  • Product: solve a genuine problem.
Minimal Viable Product diagram
The MVP idea: the smallest slice that is Minimal, Viable, and a real Product โ€” not a full feature wishlist.

Always keep "minimal" in mind โ€” MVP features is not a feature-suggestion competition. Features fall into three buckets:

BucketMeaningVerdict
MVP (v0)Core features โ€” perfect for discussionโœ… build & discuss
Future scope (v1+)Good to have, not absolutely necessary๐ŸŸก mention, don't build
Bad (v-never)Gets you rejected in an interviewโŒ avoid
What to avoid

Don't propose anything that doesn't affect our backend infra / system design:

  • Frontend / purely cosmetic features that don't affect backend design.
  • Common dependencies that aren't the core of the system. E.g. designing Uber needs authentication and payments โ€” but auth isn't what you're asked to design, it's just a dependency. Assume auth & payments already exist.

Also: any suggested feature should come as a complete sentence from the user's perspective.

Finding "hidden" features

For every feature you discuss, think about its API โ€” is it user-facing or internal? That question surfaces features you'd otherwise miss. For typeahead:

typeahead(partial_query) => suggestions_list   # user-facing
log_search(search_query)  => void              # internal
Recap Design interviews are vague and open-ended โ€” your job is to discover what to build, then build it (in ~45 min). Use the 5-step approach (problem โ†’ FR โ†’ NFR โ†’ scale โ†’ API/system). Reason by analogy to scope the problem; remember typeahead โ‰  general autocomplete (it's global, popularity-based, tied to search). Stick to the MVP, park future scope, and avoid cosmetic/dependency features.

2 Requirements & scale


Explain like I'm 5

Imagine a super-helpful librarian who has heard every question anyone has ever asked. The moment you open your mouth and say "how do I tieโ€ฆ", she instantly blurts out the most common ways people finish that sentence โ€” "โ€ฆtie a tie?", "โ€ฆtie my shoes?" She's so fast it feels like she's reading your mind, and she only suggests things lots of people actually ask. That librarian is autocomplete.

Before designing anything, a good engineer writes down what the system must do. We split this into two buckets. Functional requirements are the features โ€” what the user can see and do. Non-functional requirements are the qualities โ€” how fast, how reliable, how big. Autocomplete is a fascinating case study because its features are tiny but its quality bar is brutal.

There's exactly one actor to keep in mind here: the end user โ€” the person doing the Google search.

Functional requirements (what it does)

Stated as complete user-perspective sentences: "As the user is typing in the search box, we should auto-complete their search query โ€” we should show typeahead suggestions."

  • Prefix suggestions. Given a prefix / partial_query (the letters typed so far, like "how to ti"), return a list of complete queries that start with those letters โ€” the user-facing API typeahead(partial_query) => suggestions_list.
  • Suggestions are popular past searches. The list comes from searches other people made before, captured via the internal API log_search(search_query) => void.
  • Ranking by popularity (top 5, relevant). We show only the top 5 relevant suggestions. Relevant = the suggestion is a prefix match with the partial_query. Top 5 = we track how many times each search_query has been searched globally, and among prefix matches we show those with the highest count.
  • Update on every keystroke. With every letter the user types, the suggestions change โ€” we send a new API request with every letter typed.
  • Clicking a suggestion triggers a search (frontend).

Scope boundaries we picked

  • Start at โ‰ฅ 3 letters. We only start showing suggestions after the user has typed at least 3 letters โ€” fewer than that gives irrelevant results.
  • Cap at 50 letters. If a query is longer than ~50 letters, the user has most likely fallen asleep on the keyboard.
Future scope (v1+) โ€” mention, don't build

Nice-to-haves that we deliberately defer: personalization (by geolocation, the user's own browsing/search history, even age โ€” "why is sโ€ฆ" โ†’ "why is sky blue" for a 5-year-old vs "why is scotch smooth" for a 30-year-old); recency / trending news factor; showing trending or recent searches even before 3 characters; spell-correction and grammar-correction of the partial query; Tab to copy a suggestion into the box; highlighting the already-typed part; and "provide suggestions as per the law of the land" (whatever that means).

The qualities (latency, consistency, availability) and the numbers (QPS, storage, sharding) are big enough to deserve their own sections โ€” see ยง4 Non-functional requirements and ยง5 Scale estimation below. First, though, we sketch the API and where the data comes from.

Recap One actor (the end user). Functional core: return the top-5 prefix-matching, most-popular past queries for a typed partial query, re-fetched on every keystroke, starting at โ‰ฅ3 letters and capped at ~50. Personalization, spell/grammar-correction and trending are future scope.

3 API & data flow


Explain like I'm 5

Two friends pass notes. You hand the librarian a slip with the start of your question and she hands back a list โ€” that's one note ("give me suggestions"). Separately, every time anyone asks a full question, a helper quietly tells the librarian "someone asked this" so she can keep her tallies โ€” that's a second, behind-the-scenes note. Same idea, two different notes.

For each feature, decide its API โ€” its input & output (the interface) โ€” and its protocol โ€” how the communication happens (REST / RPC / โ€ฆ). Those are two different things.

The two APIs
typeahead(partial_query) => suggestions_list      # user-facing
log_search(search_query)  => void                 # internal

GET /typeahead?partialQuery={...}                 # the HTTP shape

Design questions worth raising aloud in the interview: why REST (not SOAP / RPC / โ€ฆ)? and why GET (not POST)? โ€” naming conventions and idempotent reads point to a cacheable GET.

Where do the suggestions come from?

Users are constantly searching on Google. The data flow that feeds typeahead:

Data source and flow diagram
Data source & flow: every search hits the /search service, which asynchronously informs the /typeahead service so it can populate its suggestions.
๐Ÿ”Ž
1. User searches
a search_query is issued
โ†’
๐Ÿงฉ
2. /search service
returns web results
โ†’
๐Ÿ“จ
3. async ping
/search tells /typeahead a search happened
โ†’
๐Ÿ—„๏ธ
4. /typeahead stores it
to populate its suggestions list

Crucially, step 3 is asynchronous โ€” the user's search isn't slowed down by logging. The /typeahead microservice is fed by the /search microservice rather than living on the search path.

Recap Two APIs: user-facing typeahead(partial_query) exposed as a cacheable GET /typeahead, and internal log_search(...). API โ‰  protocol. Suggestions are populated when /search asynchronously informs /typeahead of each search.

4 Non-functional requirements


Explain like I'm 5

Two questions about the librarian, besides what she does: Is it okay if she's sometimes a tiny bit out of date with her tallies? (Yes โ€” nobody knows or cares about the exact counts.) And how fast must she be? (Faster than you can type the next letter!) Those two answers โ€” "stale is fine" and "be lightning fast" โ€” decide almost everything about how we build her.

Non-functional requirements (NFR) are our design goals. The right tool to reason about them is PACELC: in a Partition, choose Availability or Consistency; Else (normal operation), choose Latency or Consistency. Do NOT jump to an answer โ€” reason it out.

Consistency vs Availability

Q: What is the data? Past search queries and their corresponding counts.

Search QueryCount
why is the sky blue?49,999
why is water wet?50,000
what is the color of the ocean5,000

Q: What does eventual consistency mean here? When is the data changing? Every time someone searches, some entry's count changes, and the top-5 for some partial_query may change. It's possible for a new count to reflect in the top-5 with a delay โ€” for some time the true count of Query1 may exceed Query2, yet we still show Query2 first.

Q: Can we afford stale reads? โ€” Yes.

The user doesn't know (or care about) the true search counts. We don't need a strict order in the suggestions, and it's fine for a few good suggestions to be missing as long as the ones shown are also good enough. So we lean toward availability over strict consistency.

Q: What does data loss mean here? If someone searched but, for whatever reason, we failed to increment that query's count (it didn't get logged), the counts will be off by a small amount:

Search QueryTrue CountDB Count
why is suryakumar dropped?49,99949,999
why is water wet?50,00049,998 (lost a few)
what is the color of the ocean5,0005,000
Q: Can we afford data loss? โ€” Yes.

Same reasons as stale reads: being off by a small amount in the counts doesn't hurt the user experience.

Consistency vs Latency

Ultra-low latency

We're literally competing with the user's typing speed. Target: each typeahead query takes < 10 ms. Note this excludes the network round trip, which depends on the user's distance from the servers โ€” we don't control that, and the only fix is geolocated servers.

Other NFRs (briefly)

  • Security: do NOT roll your own unless you're a security expert โ€” "99% of security issues happen because non-experts try to do security."
  • Observability, rate limits, idempotency, and delivery guarantees are worth a mention.
"Read-heavy" is the key insight

People read suggestions far more than the popular-query set changes. So this is an overwhelmingly read-heavy system โ€” which (combined with "stale is fine") is why we precompute answers and cache aggressively rather than calculate live.

Recap Reason with PACELC, don't guess. Here we can tolerate stale reads and even small data loss (users don't know the true counts) โ†’ favor availability/eventual consistency. But latency is brutal: <10 ms per query (excluding round trip; geolocate servers for that). Don't DIY security; remember observability, rate limits, idempotency, delivery guarantees.

5 Scale estimation


Explain like I'm 5

Before building a bridge you guess how many cars will cross it โ€” not the exact number, just "thousands? millions?" If millions, you build a very different bridge. We do the same: rough math to learn whether one librarian can cope, or whether we need a whole building full of them.

The scale dictates our design choices. We want to know: the amount of data (is sharding needed?), and the amount of load (requests/second per API, peak vs average, read- vs write-heavy). Those guide the choice of database, cache and sharding.

Does accuracy matter? No.

These are back-of-the-envelope estimates. Being off by a factor of 2 or 3 is fine (true 100 โ†’ you say 50 or 200, okay; you say 10, not okay โ€” off by 10ร—). But every estimate must be justified โ€” don't pull numbers from thin air, and state every assumption as an assumption.

Step 1 โ€” Daily Active Users

Handy reference numbers: world population ~8 billion; internet users ~5 billion; India ~1.4 billion; USA ~350 million. And the Pareto principle (80/20): only ~20% of users are active. (For social media it extends to 80-20-1: 80% passively browse, 20% interact, 1% create.)

Pareto principle illustration
The Pareto (80/20) principle โ€” only about 20% of users are active on any given day.
80-20-1 principle for social media
Extended to social media: 80% passively browse, 20% interact (likes/comments), 1% create content.
Worked estimate โ€” load
# Assumption: Google has 5 billion users.
DAU            = 20% of 5 billion        = 1 billion users

# Assumption: 20 searches / active user / day
searches/day   = 20 * 1 billion          = 20 billion searches/day

# 1 day = 86,400 s ~= 10^5 s
searches/sec   = 20*10^9 / 10^5          = 200,000 searches/sec
# note: these go to the SEARCH api, not typeahead

# Assumption: ~10 typeahead calls per search
# (avg query ~10 letters, start at letter 3 => 10-3=7 ~ 10)
typeahead/sec  = 200,000 * 10            = 2,000,000 typeaheads/sec

log_search/sec = 1 per search            = 200,000 req/sec

# Peak load ~= 5x average
peak typeahead = 5 * 2,000,000           = 10,000,000 typeaheads/sec

Why ~10 typeaheads per search: typing "why is the sky blue" fires typeahead("why"), typeahead("why i"), typeahead("why is"), โ€ฆ one per keystroke after the third letter.

Load estimation diagram
Load estimation: how DAU and searches-per-user roll up into searches and typeaheads per second.
Peak load estimation diagram
Peak load โ‰ˆ 5ร— the average load, taking typeahead requests from ~2M/sec to ~10M/sec at peak.

Step 2 โ€” Amount of data

The data is past search queries & their counts (avg query size 10 letters, assumed earlier). How many entries? Not one per search โ€” repeated searches just update a count; only new (never-seen-before) unique queries add rows.

Worked estimate โ€” storage
# Assumption: ~10% of searches are brand-new queries
new entries/day = 10% of 20 billion      = 2 billion entries/day

# ~10 bytes per entry
data/day        = 10 bytes * 2 billion   = 20 GB / day

# over 20 years (365 ~= 400 days)
total           = 20 GB/day * 20*400 days
                = 16 * 10^4 GB           = 160 TB
Key conclusion โ€” we need sharding

Can 160 TB fit on a single server? Probably yes with modern storage. But can a single server handle 10 million requests/second? Absolutely not. So sharding is required โ€” driven by load, not storage.

Step 3 โ€” Read-heavy or write-heavy?

APITypeRate
typeaheadread2,000,000 req/sec
log_searchwrite200,000 req/sec

Reads are 10ร— writes, so we call this read-heavy โ€” even though the writes are themselves significant.

The design rule this unlocks

When a system is read-heavy and the writes are also significant: absorb the reads in a cache, and optimize the database for writes. No database is optimized for both reads and writes โ€” that's impossible.

Recap Justified back-of-envelope (off by 2โ€“3ร— is fine): ~1B DAU โ†’ ~200k searches/sec โ†’ ~2M typeaheads/sec (~10M at peak), ~200k log_search/sec. Storage ~160 TB over 20 years. The 10M QPS (not the storage) forces sharding. Reads are 10ร— writes โ‡’ read-heavy โ‡’ cache the reads, optimize the DB for writes.

6 The Trie data structure


Explain like I'm 5

Think of a giant family tree, but for words. At the very top is "nothing." Below it are 26 branches, one for each first letter. Follow the "c" branch, then "a," then "t," and you've spelled "cat." Every word that starts with "ca" โ€” cat, car, cake โ€” lives down the same "c โ†’ a" hallway. So if you only know the start of a word, you just walk down that hallway and collect everything hanging below. That word-tree is called a trie.

A trie (pronounced "try," from retrieval โ€” also called a prefix tree) is a tree where each edge represents one character and each path from the root spells out a prefix. It's the natural fit for autocomplete because finding "all words starting with P-R-E" is just "walk to the node at the end of PRE, then look below."

How terms are stored

Every node in the trie holds a map from a character to a child node. To store the word "cat," we walk/create children c โ†’ a โ†’ t and mark the final node as the end of a real word. To store "car" too, it shares the c โ†’ a path and only branches at the third letter. Shared prefixes = saved space.

โ—
root
empty start
โ†’
c
"c"
prefix c
โ†’
a
"ca"
prefix ca
โ†’
t
"cat" โœ“
word ends
โ†’
r
"car" โœ“
branches off "ca"

Insertion and lookup

Insertion: walk the word letter by letter, creating any missing child nodes, and mark the last node as a word-end. Lookup of a prefix: walk the prefix letter by letter from the root; if you ever can't find the next child, there are no matches. If you reach the end of the prefix, the node you land on is the root of a subtree containing every word with that prefix.

# Python โ€” trie with per-word frequency + top-k prefix query
import heapq

class TrieNode:
    def __init__(self):
        self.children = {}        # char -> TrieNode
        self.is_word  = False
        self.word     = None      # full word, set at the end node
        self.freq     = 0         # search frequency of this word

class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word, freq=1):
        node = self.root
        for ch in word:                 # walk/create the path
            if ch not in node.children:
                node.children[ch] = TrieNode()
            node = node.children[ch]
        node.is_word = True
        node.word    = word
        node.freq   += freq               # aggregate repeated inserts

    def _find(self, prefix):              # walk to the prefix node
        node = self.root
        for ch in prefix:
            if ch not in node.children:
                return None          # no words with this prefix
            node = node.children[ch]
        return node

    def suggest(self, prefix, k=5):
        node = self._find(prefix)
        if node is None:
            return []
        matches = []
        stack = [node]
        while stack:                       # gather every word under the prefix
            cur = stack.pop()
            if cur.is_word:
                matches.append((cur.freq, cur.word))
            stack.extend(cur.children.values())
        # top-k by frequency (descending)
        best = heapq.nlargest(k, matches)
        return [(w, f) for f, w in best]

# --- demo ---
t = Trie()
for w, f in [("cat", 8), ("car", 5), ("card", 3),
              ("care", 9), ("dog", 4)]:
    t.insert(w, f)
print(t.suggest("ca", 3))    # [('care', 9), ('cat', 8), ('car', 5)]
// C++ โ€” trie with per-word frequency + top-k prefix query
#include <bits/stdc++.h>
using namespace std;

struct TrieNode {
    unordered_map<char, TrieNode*> children;  // char -> child
    bool   isWord = false;
    string word;                              // set at the end node
    long   freq  = 0;                          // search frequency
};

struct Trie {
    TrieNode* root = new TrieNode();

    void insert(const string& word, long freq = 1) {
        TrieNode* node = root;
        for (char ch : word) {              // walk/create the path
            if (!node->children.count(ch))
                node->children[ch] = new TrieNode();
            node = node->children[ch];
        }
        node->isWord = true;
        node->word   = word;
        node->freq  += freq;                  // aggregate repeated inserts
    }

    TrieNode* find(const string& prefix) {  // walk to the prefix node
        TrieNode* node = root;
        for (char ch : prefix) {
            auto it = node->children.find(ch);
            if (it == node->children.end()) return nullptr;
            node = it->second;
        }
        return node;
    }

    vector<pair<string, long>> suggest(const string& prefix, int k = 5) {
        vector<pair<string, long>> matches;
        TrieNode* start = find(prefix);
        if (!start) return matches;
        vector<TrieNode*> stack{start};
        while (!stack.empty()) {              // gather every word under the prefix
            TrieNode* cur = stack.back(); stack.pop_back();
            if (cur->isWord) matches.push_back({cur->word, cur->freq});
            for (auto& [c, child] : cur->children) stack.push_back(child);
        }
        // top-k by frequency (descending)
        sort(matches.begin(), matches.end(),
             [](const auto& a, const auto& b){ return a.second > b.second; });
        if ((int)matches.size() > k) matches.resize(k);
        return matches;
    }
};

int main() {
    Trie t;
    t.insert("cat", 8); t.insert("car", 5); t.insert("card", 3);
    t.insert("care", 9); t.insert("dog", 4);
    for (auto& [w, f] : t.suggest("ca", 3))
        cout << w << " " << f << "\n";  // care 9 / cat 8 / car 5
}
// Java โ€” trie with per-word frequency + top-k prefix query
import java.util.*;

class TrieNode {
    Map<Character, TrieNode> children = new HashMap<>();  // char -> child
    boolean isWord = false;
    String  word;                                       // set at the end node
    long    freq = 0;                                    // search frequency
}

class Trie {
    private final TrieNode root = new TrieNode();

    void insert(String word, long freq) {
        TrieNode node = root;
        for (char ch : word.toCharArray())            // walk/create the path
            node = node.children.computeIfAbsent(ch, c -> new TrieNode());
        node.isWord = true;
        node.word   = word;
        node.freq  += freq;                            // aggregate repeated inserts
    }

    private TrieNode find(String prefix) {            // walk to the prefix node
        TrieNode node = root;
        for (char ch : prefix.toCharArray()) {
            node = node.children.get(ch);
            if (node == null) return null;       // no words with this prefix
        }
        return node;
    }

    List<Map.Entry<String, Long>> suggest(String prefix, int k) {
        List<Map.Entry<String, Long>> matches = new ArrayList<>();
        TrieNode start = find(prefix);
        if (start == null) return matches;
        Deque<TrieNode> stack = new ArrayDeque<>();
        stack.push(start);
        while (!stack.isEmpty()) {                      // gather every word under the prefix
            TrieNode cur = stack.pop();
            if (cur.isWord)
                matches.add(Map.entry(cur.word, cur.freq));
            for (TrieNode child : cur.children.values()) stack.push(child);
        }
        // top-k by frequency (descending)
        matches.sort((a, b) -> Long.compare(b.getValue(), a.getValue()));
        return matches.subList(0, Math.min(k, matches.size()));
    }
}

public class Demo {
    public static void main(String[] args) {
        Trie t = new Trie();
        t.insert("cat", 8); t.insert("car", 5); t.insert("card", 3);
        t.insert("care", 9); t.insert("dog", 4);
        System.out.println(t.suggest("ca", 3));
        // [care=9, cat=8, car=5]
    }
}

The clever bit: store top-k at each node

Here's the trick that makes autocomplete fast. The naive way is: walk to the prefix node, then explore the whole subtree below it to gather all matching words and sort them by popularity. For a popular prefix like "a," that subtree is enormous โ€” far too slow per keystroke.

Instead, we precompute and cache the top-k suggestions directly on every node (the top_k list above). When we build the trie offline, each node already carries a ready-made answer: the k most popular complete queries that pass through it. Now a lookup is just "walk to the node, read its list." No subtree exploration at request time.

What a node's top_k looks like

The node for prefix "how to ti" might store, already sorted by popularity:

node["how to ti"].top_k = [
    ("how to tie a tie",   980000),
    ("how to tie shoes",   540000),
    ("how to title a paper",120000),
    ("how to time a roast", 30000),
]

A request for that prefix returns this list instantly โ€” the ranking work was done ahead of time.

Complexity (the Big-O)

OperationCostWhy
Insert a word of length LO(L)One step per character.
Look up a prefix of length PO(P)Walk P characters from the root.
Get top-k (with precomputed lists)O(P + k)Walk to the node, copy its k items.

The beautiful part: lookup cost depends only on how long the prefix is, not on how many millions of queries are stored. Typing 9 characters costs 9 steps whether the trie holds a thousand words or a billion. That's why a trie hits our latency target.

The trade-off

Storing a top-k list on every node uses a lot of memory (we're trading space for speed โ€” a recurring theme in HLD). And because the lists are precomputed, the trie is essentially read-only at serving time: you don't edit it live, you rebuild it offline (next section).

Recap A trie stores words as paths of characters, sharing common prefixes. Insert and prefix lookup are O(L) and O(P) โ€” independent of dataset size. The autocomplete trick is to precompute the top-k popular suggestions on each node, turning a request into a quick walk-and-read at the cost of extra memory and a read-only structure.

7 Ranking & data collection


Explain like I'm 5

Imagine the librarian keeps a tally chart. Every time someone asks a question, she adds a tick mark next to it. When you start a question, she suggests the ones with the most ticks first โ€” because lots of people asking it probably means it's what you want too. And she slowly fades old tick marks so last year's hot question doesn't beat today's. That tally chart is how she ranks.

In Section 2 we waved our hands and said each node "knows" its top-k. Now we earn that. Where do the popularity numbers come from, and how do we keep them current?

Ranking by frequency and recency

The simplest signal is frequency: how many times has each full query been searched? More searches โ†’ higher rank. But pure frequency makes the list stale โ€” an all-time favorite would cling to the top forever. So we add recency: recent searches should count for more than ancient ones.

A common technique is a time-decayed score: every day, multiply all existing counts by a factor like 0.95, then add today's fresh counts. Old popularity gently fades; trending queries rise. The final rank for a query is just this decayed score.

Worked example: decay in action

Query "world cup schedule" with a daily decay of 0.95:

# score_today = score_yesterday * decay + searches_today
Day 1:   0      * 0.95 + 100  = 100
Day 2:   100    * 0.95 +  50  = 145
Day 3:   145    * 0.95 +   0  = 138   # no searches: fades
Day 4:   138    * 0.95 + 900  = 1031  # goes viral: jumps up

Notice a once-popular query naturally decays when interest dies, and a sudden spike quickly lifts it. This keeps suggestions feeling alive without us hand-curating anything.

How query logs feed popularity

Every search a user runs is recorded in a query log โ€” append-only lines of "this query was searched at this time." At our scale that's billions of lines a day, far too much to count live on the serving path. Instead the logs stream into a message queue (think of a conveyor belt like Kafka) and pile up in cheap storage for batch processing.

Why not just count as queries arrive?

Updating a live ranking on every single search would put writes on the hot read path and wreck our latency โ€” exactly the read-heavy lesson from Section 1. Decoupling "collect now, compute later" keeps the fast serving path read-only and lets the heavy counting happen safely off to the side.

The offline aggregation pipeline

This is the offline pipeline โ€” a scheduled job (say, hourly) that turns raw logs into a fresh trie. "Offline" means it runs separately from live traffic; users never wait on it.

โŒจ๏ธ
1. Log
Every search written to query logs
โ†’
๐Ÿšš
2. Collect
Logs stream to a queue + storage
โ†’
๐Ÿงฎ
3. Aggregate
Count + time-decay per query
โ†’
๐ŸŒณ
4. Build trie
Insert top queries, fill top-k on nodes
โ†’
๐Ÿš€
5. Publish
Ship the new trie to serving nodes

Step 3 is typically a MapReduce-style job: "map" each log line to (query, 1), then "reduce" by summing per query and applying decay. Step 4 builds a fresh trie and, while building, computes each node's top-k by merging the top-k of its children (a child's best suggestions are candidates for the parent). Step 5 atomically swaps the new trie in for the old one.

Computing a node's top_k from its children
# A node's best suggestions come from merging its
# children's best suggestions, plus its own word if any.
def compute_top_k(node, k=5):
    candidates = []
    if node.is_word:
        candidates.append((node.word, node.score))
    for child in node.children.values():
        compute_top_k(child, k)          # bottom-up
        candidates.extend(child.top_k)
    # keep only the k highest-scoring
    node.top_k = sorted(candidates, key=lambda x: -x[1])[:k]

Because each node only keeps its k best, the merge stays cheap even near the root.

Key takeaway

Ranking is a blend of frequency and recency (time-decayed scores), and it's computed offline from query logs, never on the live request. The serving trie is just a frozen snapshot of "what's popular right now," rebuilt and swapped in periodically โ€” the same prefill-then-serve spirit you'll see all over HLD.

Recap Rank queries by a time-decayed popularity score so trending beats stale. Searches are captured in query logs and processed by an offline aggregation pipeline (collect โ†’ aggregate/decay โ†’ build trie with top-k on each node โ†’ publish), keeping all the heavy counting off the fast read path.

8 Serving at scale


Explain like I'm 5

One librarian can't answer a whole stadium at once. So we hire many identical librarians (copies), put a greeter at the door to send each person to a free one (load balancing), and split the giant book collection alphabetically across rooms so no single room is overwhelmed (sharding). We also keep a cheat-sheet of the most-asked questions right at the front desk (caching), and we politely ask you to finish a few letters before we run off to fetch an answer (debouncing). Together they keep everyone served in a blink.

We now have a fast trie (Section 2) kept fresh by an offline pipeline (Section 3). The last job is delivering it to ~400k QPS with sub-100 ms latency. We reuse the big HLD building blocks from earlier sessions.

Caching hot prefixes

A tiny fraction of prefixes accounts for a huge fraction of traffic โ€” single letters and common starts like "f," "you," "how to." We put a cache (a fast in-memory keyโ†’value store like Redis, recall the caching session) in front of the trie, keyed by prefix. The first lookup of "how to" computes the answer; the next million read it straight from cache.

Why caching wins here

Suggestions for "how to" are identical for everyone and change only when we rebuild the trie. That makes them perfect cache entries: same input โ†’ same output, rarely changing. A cache hit avoids even the cheap trie walk and shaves precious milliseconds. We set a short TTL (time-to-live) so the cache refreshes after each trie rebuild.

Sharding the trie

Our 10 GB trie fits in one machine's RAM, but one machine can't handle 400k QPS alone. So we shard (split the data across machines, recall the sharding session). The natural key is the prefix's first letter(s): shard A handles prefixes starting "a," shard B handles "b," and so on. A request for "how to ti" is routed to the "h" shard.

The hot-shard problem

Naive first-letter sharding is uneven โ€” far more queries start with "s" than "z," so the "s" shard melts while "z" sits idle. Fixes: shard on the first two or three letters for finer slices, or use consistent hashing to spread load evenly. And because the trie is read-only at serving time, we can freely keep multiple replicas of each shard to share the read load โ€” read-heavy systems love replication.

CDN and edge

The fastest request is the one that never reaches your servers. A CDN (Content Delivery Network โ€” a fleet of caches physically close to users) and edge nodes can cache the suggestions for the most common prefixes right next to the user, in their region. Latency is dominated by physical distance, so answering "how to" from a city 50 km away instead of a data center 5,000 km away is a giant win for our latency budget.

Debouncing on the client

Half of our scaling problem can be solved in the browser. Debouncing means: don't fire a request on every keystroke โ€” wait until the user pauses typing (say 150โ€“300 ms) and only then send the latest prefix. A fast typer entering "how to tie" generates one request instead of ten.

Debounce in code (client-side JS)
let timer;
input.addEventListener("input", () => {
  clearTimeout(timer);                  // cancel the pending request
  timer = setTimeout(() => {            // wait for a pause
    fetchSuggestions(input.value);      // only the latest prefix is sent
  }, 200);                            // 200 ms of quiet
});

This alone can cut autocomplete QPS several-fold โ€” the cheapest scaling lever we have, and it lives entirely on the client.

The end-to-end request flow

Putting every block together, here's the journey of one keystroke from your keyboard to a dropdown:

โŒจ๏ธ
1. Type
Keystroke, debounced in browser
โ†’
๐ŸŒ
2. Edge / CDN
Hot prefix? Return instantly
โ†’
โš–๏ธ
3. Load balancer
Route to a free server
โ†’
โšก
4. Cache
Redis hit on prefix? Return
โ†’
๐ŸŒณ
5. Trie shard
Walk to node, read top-k
โ†’
๐Ÿ“‹
6. Respond
Dropdown shows suggestions

Each layer is an opportunity to answer early: the request stops at the first layer that already knows the answer. Most requests never reach the trie at all โ€” they're caught at the edge or in the cache. That layered, "answer as early as possible" shape is the heart of serving any read-heavy system at scale.

Graceful degradation

From Section 1, autocomplete is a nice-to-have. If the suggestion service is overloaded or down, the page should simply show no dropdown and let the user search normally โ€” never block the search box. Designing the failure to be invisible is just as important as designing the success.

Recap Serve at scale by stacking the HLD building blocks: debounce on the client to cut QPS, CDN/edge to answer near the user, a load balancer to spread traffic, a cache for hot prefixes, and a sharded, replicated trie behind it. Each layer answers as early as it can, and autocomplete degrades gracefully if it fails.

โ˜… Putting it all together


You just designed a real, production-grade system end to end. Here's the one-paragraph story that connects all four topics:

Autocomplete must return the top-k most popular queries for a typed prefix in under ~100 ms at hundreds of thousands of QPS โ€” a read-heavy problem whose data is small enough to live in RAM. We store the queries in a trie and precompute each node's top-k so a lookup is a quick O(P+k) walk-and-read. Those popularity numbers come from query logs, turned into time-decayed rankings by an offline pipeline that periodically rebuilds and publishes the trie. Finally we serve it at scale by stacking debouncing, CDN/edge, load balancing, caching of hot prefixes, and a sharded, replicated trie โ€” each layer answering as early as it can, and failing gracefully when it can't.

Quick self-check

What are the 5 steps of the design approach, and how is the 45 min split?

(1) Problem statement, (2) Functional requirements, (3) Non-functional requirements, (4) Scale estimation, (5) API + System design. Roughly the first 20โ€“25 min on steps 1โ€“4 (understanding), the remaining 20โ€“25 min on step 5 (building).

How is typeahead different from general autocomplete?

Autocomplete (keyboards, IDEs, Grammarly, Gmail) is usually local/standalone and personal to you. Typeahead is always tied to search, and its suggestions are the most popular queries searched globally in the past that prefix-match what you typed.

Can the typeahead system tolerate stale reads and small data loss? Why?

Yes to both. Users don't know or care about the true search counts, and we don't need a strict ordering โ€” a few good suggestions missing is fine as long as the shown ones are good enough. So we favor availability/eventual consistency.

Walk the load estimate: from DAU to peak typeahead QPS.

5B users ร— 20% = 1B DAU; ร— 20 searches/day = 20B searches/day รท 10โต s โ‰ˆ 200k searches/sec; ร— ~10 typeaheads/search = 2M typeaheads/sec; ร— 5 peak factor = ~10M typeaheads/sec at peak. (log_search โ‰ˆ 200k/sec.)

Why is autocomplete described as a "read-heavy" system, and why does that matter?

Users read suggestions billions of times a day, but the popular-query set changes slowly, so there are far more reads than writes. That justifies precomputing answers, caching aggressively, and replicating the read-only trie.

What is the time complexity of a prefix lookup in a trie, and what does it depend on?

O(P) to walk the prefix (plus O(k) to copy the precomputed suggestions). It depends only on the prefix length P, not on how many millions of queries are stored โ€” which is why it's fast at any scale.

Why precompute top-k on each node instead of searching the subtree at request time?

Exploring the whole subtree below a popular prefix (like "a") is far too slow per keystroke. Precomputing the top-k offline turns the request into a cheap walk-and-read, trading extra memory for speed.

How do we keep suggestions fresh without slowing down live requests?

Capture searches in query logs, then run an offline pipeline that aggregates them with a time-decayed score, rebuilds the trie, and swaps it in periodically. All the counting happens off the hot read path.

Naive first-letter sharding overloads some shards. Name two fixes.

(1) Shard on the first two or three letters for finer, more even slices; (2) use consistent hashing to distribute load evenly. Also replicate each read-only shard to share the read load.

What is debouncing and why is it the cheapest scaling lever?

Debouncing waits for a short typing pause before sending only the latest prefix, instead of firing on every keystroke. It can cut QPS several-fold and lives entirely on the client โ€” no server cost.

๐Ÿ“š References & Further Reading


Class material

Papers, docs & deep dives