Courses / System Design Interview / Chapter 33
Module 3 · Design Real Products · Chapter 33 of 39
Design Typeahead Suggestions
Builds on Back-of-the-Envelope Estimates, Content Delivery Networks, Distributed Caches and Distributed Search. Next up: Design Collaborative Document Editing.
Part 1 · Requirements and Scale
The brief
It is 10:40 pm, the last over of an India vs Australia final. Lakhs of people pick up their phones and type ind into a search app at the same moment. On Neha's phone in Kanpur the list under the box arrives only after her third letter, still offers last month's "india vs australia 2nd odi", then jumps back to suggestions for in while she is typing indi. Nothing is down. The suggestions are simply late, stale and out of order. This chapter designs a search box that answers every keystroke within about 100 ms, with tonight's queries in the list.
The product: the suggestion list that drops down under a search box. Show up to ten completions for the letters typed so far, favour what is popular right now, and never suggest anything offensive or private. About 50 million people search every day. Neha typing ind on final night is the example we follow throughout.
What the search box must do
Neha's problem was three problems: a slow list, a stale list and a list from the wrong keystroke. Start by pinning down what the feature must do. Type ind and a list should drop down before the next key press: "india vs australia live score", "indigo flight status", "indian railway pnr status". This is typeahead, also called autocomplete: given the prefix typed so far, return the few complete queries the user most likely wants.
It looks small, but it runs on almost every keystroke of every user, which gives it several times the traffic of search itself, and each answer has to arrive before the next key.
| Requirement | Why it matters | |
|---|---|---|
| Functional | Up to 10 completions for the typed prefix, best first | The whole point of the feature |
| Functional | Popular and recent queries rank higher | Tonight's final should beat last month's match |
| Functional | The user's own recent searches can appear first | People repeat their searches a lot |
| Functional | No offensive, illegal or private suggestions | A suggestion reads like the product speaking |
| Non-functional | About 100 ms from keystroke to list | A slower list lags behind the typing |
| Non-functional | Highly available, but allowed to degrade | Search must work even with no suggestions |
The 100 ms target comes from typing speed. Someone typing 40 words a minute presses a key about every 300 ms; a fast typist, about every 150 ms. If suggestions take longer than the gap between keys, the list describes a prefix the user has already moved past. About 100 ms is also roughly where a response stops feeling instant.
Spelling correction, the search results page and ads. They sit next to this feature but do not change its design.
How big it gets
Before drawing servers, count the requests, because every keystroke is one. Assume 50 million daily users who search 6 times a day. A query is about 20 characters, but the app skips keys during a fast burst and many people tap a suggestion early, so count about 8 suggestion requests per search.
Try it first: 50 M users × 6 searches × 8 requests a day. What is the average rate, and the evening peak at 3× the average? If the edge caches answer 60% of requests and one suggestion server handles about 10,000 a second, how many servers are busy at a planned peak of 100,000 a second?
- 50 M × 6 × 8 = 2.4 B requests a day; ÷ 86,400 s ≈ 28,000 a second.
- Evening peak ≈ 3 × 28,000 ≈ 83,000 a second; plan for 100,000.
- 40% miss the edge: 40,000 a second ÷ 10,000 ≈ 4 busy servers.
| Quantity | Working | Result |
|---|---|---|
| Suggestion requests | 300 M searches × 8 | 2.4 B a day, ≈ 28,000 /s |
| Planned peak | evening ≈ 3×, rounded up | 100,000 /s at the edge |
| Big match burst | 5 to 10× for minutes, on a few shared prefixes | mostly absorbed by the edge |
| Edge misses at peak | 100,000 × 40% (assumed) | ≈ 40,000 /s |
| Suggestion servers | 40,000 ÷ about 10,000 each (assumed) | 4 busy; run 9, 3 in each of 3 regions |
| Peak bandwidth | 100,000 /s × under 1 KB | under 100 MB/s, mostly from the edge |
| Search events logged | 300 M × about 100 B | ≈ 30 GB a day |
Nine servers, not four, because a whole region can fail: the six left still have room for 40,000 a second. The big-match row is why the edge matters so much. A final multiplies traffic 5 to 10 times, but nearly everyone types the same few prefixes, which is exactly what a cache serves best.
How much memory the index needs
Requests decide how many servers answer; memory decides what each one can hold. We keep only queries that enough different people typed recently, about 100 million distinct queries across all languages, in a compressed trie that stores the top 10 answers at every node. The deep dives explain both ideas; first, estimate what they cost.
Try it first: 100 M queries in a compressed trie, with at most 2 nodes per query and about 60 bytes per node (10 query ids of 4 bytes, plus links), plus about 24 bytes of text and score per query. How much memory is one copy, and how much while a server swaps in a new one?
- 200 M nodes × 60 B ≈ 12 GB.
- 100 M × 24 B ≈ 2.4 GB of text and scores, so one copy is about 14.4 GB, call it 15 GB.
- During a swap the old and new tries sit side by side: about 29 GB, comfortable on a 64 GB server.
Two checks before trusting that. First, 60 bytes a node assumes a compact layout: nodes packed into large arrays, with children and lists stored as 4-byte indexes. Built as one object per node with pointers, as a quick prototype would be, the same trie takes several times more and stops fitting. Second, is 100 M queries sane? Thirty days bring 300 M × 30 = 9 B searches, and a query is kept only if at least 50 different people typed it, so at most 9 B ÷ 50 = 180 M can qualify. Popular queries use up many searches each, so the real number is lower: 100 M is a generous ceiling.
This result shapes the whole design: a full copy of the index fits in one server's memory. Every suggestion server holds all of it, any server can answer any prefix, and we add servers for throughput, not for space.
Spending the 100 ms
Servers and memory are settled; now check that an answer can actually arrive in time. Split the 100 ms across every step a keystroke passes through, and give the biggest share to the part you control least: the mobile network.
| Step | Budget | How to stay inside it |
|---|---|---|
| Key press to request sent | about 5 ms | a tiny GET; no heavy work on each key |
| Phone to the nearest edge and back | 30 to 60 ms | edge servers in many cities; reuse one open connection |
| Edge cache lookup | under 1 ms | short prefixes are almost always cached |
| Edge to a region and back, on a miss | 10 to 40 ms | suggestion servers in three regions; Mumbai to Delhi is about 30 ms |
| Suggestion server lookup | under 1 ms | the trie is in memory; no disk, no database call |
| Drawing the list | about 10 ms | plain text rows, no images |
Add it up honestly. An edge hit takes about 45 to 75 ms, comfortably inside the budget. A miss takes about 55 to 115 ms, so on a slow network it can run over. The server's own work is about 1 ms either way: almost all of the budget is network. That is why the design answers as many keystrokes as possible from the edge, and why a faster lookup would barely help.
Part 2 · API, Data and the Big Picture
The API
An answer can only come from the edge if the request is cacheable, so the API is designed around that. One read endpoint carries almost all the traffic. Make it a plain GET with everything in the URL, so the browser, the CDN and any proxy on the way can cache it.
GET /v1/suggest?q=ind&lang=en-IN&limit=10 200 OK Cache-Control: public, max-age=300 { "prefix": "ind", "suggestions": [ { "text": "india vs australia live score" }, { "text": "indigo flight status" }, { "text": "indian railway pnr status" } ] } POST /v1/events { "type": "search", "q": "indigo flight status", "picked_rank": 2, "lang": "en-IN" }
The suggest call never needs to know who is typing, which is what keeps it cacheable.
- Normalise the prefix before using it as a cache key: lowercase it and collapse repeated spaces, so
INDandindshare one entry. Keep a single trailing space, becauseind(a finished word) means something different fromind. langpicks the right index. People searching in Hindi, Tamil or English type different things.limitis capped at 10. Nobody reads a longer list while typing.max-age=300lets the browser and the edge reuse an answer for 5 minutes. A longer time means more cache hits but staler lists.POST /v1/eventsis sent when a search is submitted, with the rank of the suggestion the user picked, if any. It feeds the offline build, never the live path.
Personal suggestions, such as your own recent searches, deliberately do not come from this endpoint. The app keeps them on the device and merges them in, so the shared response stays identical for everyone.
The data model
The suggest call reads nothing but memory, so where does that memory come from? There are two worlds: raw logs that grow every second, and serving data rebuilt from them on a schedule. Keep them apart: the live path never reads a log.
tsq_normalisedlanguser_hashpicked_rankq_normalised (pk)lang (pk)day (pk)countusers_sketchversion (pk)lang (pk)file_keybuilt_atquery_countpattern (pk)reasonadded_byadded_at- query_daily counted from → search_events
- trie_snapshots built from → query_daily
- trie_snapshots filtered by → blocklist
| Data | Size | Where it lives | Why there |
|---|---|---|---|
| search_events | ≈ 30 GB a day | hourly log files in a blob store | append-only; read only by batch jobs |
| query_daily | one row per query per day, most of them rare | a table the batch job appends to | lets each build add one hour instead of rereading 30 days |
| trie_snapshots | ≈ 15 GB per version, all languages | versioned files in a blob store | servers download the whole file |
| blocklist | thousands of rows | a small database, also pushed to servers | changes must apply within minutes |
Size≈ 30 GB a day
Where it liveshourly log files in a blob store
Why thereappend-only; read only by batch jobs
Sizeone row per query per day, most of them rare
Where it livesa table the batch job appends to
Why therelets each build add one hour instead of rereading 30 days
Size≈ 15 GB per version, all languages
Where it livesversioned files in a blob store
Why thereservers download the whole file
Sizethousands of rows
Where it livesa small database, also pushed to servers
Why therechanges must apply within minutes
user_hash is a one-way hash of the user id, made with a secret key so nobody can match it back by hashing known ids. The build needs to count how many different people typed a query, so it never suggests something only a handful searched for, but it never needs to know who they were.
The high-level design
With the data split in two, the system splits the same way. The online path answers keystrokes from caches and memory. The offline path turns logs into a fresh trie every hour and ships it to the servers as one file. Follow the online path first.
Neha types "i". The edge in her city already holds this prefix
Read it as steps
Short prefixes are answered at the edge; rarer ones go to a suggestion server that answers from memory. A crashed server is replaced by loading a file.
Three lessons. Many keystrokes never leave Neha's city, because short prefixes are shared by everyone and stay cached at the edge, as in the CDN chapter. A miss costs extra network hops but no disk or database work. And a failed server is replaced by copying a file, because suggestion servers hold no state of their own.
That is the offline path, the second deep dive. The contract between the two paths is the snapshot file, plus two small lists covered later: trending queries and blocked ones. As long as a valid snapshot exists, the online path keeps working, even if the build pipeline is down for a day.
The phone's share of the work
The diagram starts at the phone, and the phone decides two things: how many requests reach the edge, and which answers reach the screen. Remember Neha's list jumping back to in. Four habits fix both.
- Keep one request in flight. While an answer is on its way, send nothing more; when it returns, send only the newest prefix. A fast typist skips the prefixes in between, and a slow typist still gets an answer for every key. If an answer takes longer than about 300 ms, stop waiting and send the newest prefix anyway.
- Debounce lightly, if at all. Debouncing means sending only after typing pauses for a set time. A 150 ms pause would skip most keys but add 150 ms to every answer, more than the whole budget; one request in flight saves requests without that delay.
- Drop stale answers. An abandoned request can still answer late, so number every request and show an answer only if it is newer than the list on screen.
- Reuse what you have. Filter the list for
indon the phone to show something forindiwhile the real answer loads, and keep recent answers in memory so backspacing touches no network at all.
Watch for request #1: on a weak network its answer for "in" is slow
Read it as steps
A slow answer for "in" would pass a prefix check after the user has typed "ind", but its request number is older, so the phone drops it.
The rule is "newest request wins", not "matching prefix wins". It costs one counter on the phone, and it is what stops the list jumping back to an older answer while the user types.
Part 3 · Deep Dives
A trie with answers at every node
The heart of the serving path: a stored top 10 at every node turns millions of matches into a 1 ms lookup.
A server receives ind and must return 10 queries in about a millisecond. Search results come from an inverted index (the distributed search chapter), which matches whole words. Suggestions must match the start of a query, and the classic structure for that is a trie (said like "try"): a tree in which each edge adds one character. Every node stands for a prefix, and every query below it starts with that prefix, so reaching ind takes three steps however many queries are stored. Think of a library shelved by the first letters of each title: finding the shelf is quick, but the shelf for "i" is enormous.
Predict before you watch: a short prefix such as "i" has millions of queries below its node. If nodes store nothing but children, what does each keystroke cost? What would you store at each node to make it cheap?
- A walk to the node, then a scan and sort of everything below it: millions of queries for "i", on every keystroke.
- Store each node's top 10 query ids, filled offline from its children; a lookup becomes a short walk plus one read.
Naive: walk to "ind", then visit every query below it and sort them
Read it as steps
A naive lookup scans everything below the prefix on every keystroke. Lists filled offline, bottom-up, turn it into a short walk and one read.
The lesson: precompute the answer. Finding the best 10 below a prefix happens once per build, not once per keystroke, and the stored list is only 40 bytes.
def suggest(trie, prefix, k=10): node = trie.root for ch in prefix: # one step per character typed node = node.children.get(ch) if node is None: return [] # no kept query starts with this prefix return [trie.text[qid] for qid in node.top[:k]] # precomputed list
A plain trie; a compressed trie compares a whole edge label per step.
Filling the lists and shrinking the trie
The scene filled the lists from the bottom up. Precisely: a node's top 10 is the best 10 among the query that ends at that node, if any, and its children's top 10 lists. One pass over the tree fills every node, offline, so its cost never touches a keystroke. Here is the result for six queries, keeping the top 3 to fit the page:
| Query | Score |
|---|---|
| india vs australia | 900 |
| indigo flight status | 700 |
| indian railway pnr | 650 |
| india post tracking | 400 |
| inox movies | 300 |
| indore weather | 120 |
| Prefix | Stored top 3 |
|---|---|
| in | india vs australia, indigo flight status, indian railway pnr |
| indi | india vs australia, indigo flight status, indian railway pnr |
| india | india vs australia, indian railway pnr, india post tracking |
| indo | indore weather |
| ino | inox movies |
Four tricks keep the trie inside the 15 GB estimated earlier. Store 4-byte query ids in each list, not the text: about 16 bytes saved per entry. Compress the trie: merge chains of single-child nodes where no query ends into one edge, so there are at most 2 nodes per query instead of one per character; a prefix that ends mid-edge uses the list of the node the edge leads to, since the same queries lie below both. Keep 10 entries per node, not 50, because the list is the biggest part of each node. Drop queries few people typed, the huge long tail that is rarely useful.
Since a lookup never looks below the prefix's node, the trie can be flattened into a key-value table: prefix → top 10. It takes several times more memory, because every distinct prefix becomes its own row, but it is easy to shard and keep in an ordinary key-value store. That is the usual next step when one copy no longer fits a server.
From search logs to scores
The trie is only as good as the scores in its lists, and those come from real searches. Every submitted search is logged as an event, about 30 GB a day. Each hour a batch job normalises the new hour's queries (lowercase, collapsed spaces, one Unicode form, so equal queries count together) and adds them to a count per query per day. It never rereads 30 days of raw logs.
Scoring with decay. Raw counts would favour old hits forever. Instead, each day's count is multiplied by a weight that halves every 7 days, a 7-day half-life. Say a query about tonight's final is searched 1,000 times today: it scores 1,000. Last month's match, searched 10,000 times four weeks ago and not since, has been halved four times: 10,000 ÷ 16 ≈ 625. Tonight wins, as it should. Days older than 30 weigh under 5%, so the job stops there.
HALF_LIFE_DAYS = 7 def score(daily_counts, today): # daily_counts: {date: count} for one normalised query total = 0.0 for day, count in daily_counts.items(): age_days = (today - day).days total += count * 0.5 ** (age_days / HALF_LIFE_DAYS) return total
A small bonus for suggestions people tapped, and a small penalty for ones they skipped, also helps.
Counting different people. The privacy rule keeps a query only if at least 50 different people typed it. Daily counts of distinct users cannot simply be added up, because Neha searching on three days would count three times. So the job keeps a small mergeable sketch per query per day, such as a HyperLogLog, which estimates distinct users within a few percent; merging 30 daily sketches gives the 30-day figure. Keeping sketches only for queries searched more than a handful of times that day keeps their storage small, at the cost of missing a few slow, steady queries.
Build, check, ship and swap
Scores in hand, the job builds a compressed trie per language, fills its lists bottom-up and writes it as one versioned file. Three steps stand between that file and Neha's screen, and each one prevents a specific failure.
- Write the file in the memory layout. If the file holds the same packed arrays the server uses, a server maps it into memory instead of parsing 200 M nodes, so a swap costs a download and seconds of work, not minutes.
- Validate before publishing. A bug that produces an empty or half-built trie would blank every search box at once. Compare each build with the last one (query count, file size, answers for a list of common prefixes) and refuse it if they differ wildly.
- Canary, then swap. One server loads the new file first and takes a little traffic; if its error rate and acceptance rate look normal, the rest follow. Each maps the new file beside the old one and switches a single pointer; requests in progress finish on the old trie. That is why the memory estimate planned for two copies.
Watch for the check that stops a broken trie before any server loads it
Read it as steps
The hourly build is checked against the last one, then tried on one canary server, before every server swaps it in.
Shipping is cheap. Each server fetches about 15 GB an hour, about 12 seconds over a 10 Gbps link, so 9 servers move about 135 GB an hour from the blob store. Rolling back is pointing at the previous file. Hundreds of servers would call for staggered downloads or shipping only the changed parts.
Fresh, personal and safe
An hourly trie with decayed scores covers most needs. Three gaps remain: news faster than an hour, what this user personally wants, and what must never appear.
Trending queries. An hourly build is too slow when a match ends on the last ball. Add a small fast layer: a streaming job counts queries over the last 10 to 15 minutes, spots those rising far above their usual rate, and publishes a short trending list per prefix every few minutes. It passes the same safety filters, and servers merge it with the trie's answer at request time.
Personal suggestions. Your own recent searches are the best predictor of what you will type next. Keep the last 50 or so on the device, match them locally and show them above the shared list. The shared response stays the same for everyone, so the edge can still cache it.
Language and place. Build one trie per script and language, and pick it by what is being typed: many people type Hindi words in English letters, and those queries belong in the Latin-script trie. Where suggestions differ by place ("weather" should complete to the user's own city), add a coarse region such as the state to the request and the cache key. Keep regions few, because each one splits the edge cache.
| Never suggest | Enforced | Why |
|---|---|---|
| Slurs, hate and sexual terms | at build time, plus a serve-time emergency list | a suggestion reads like the product speaking |
| Phone numbers, emails, ID numbers | at build time, by pattern matching | personal data typed into a search box |
| Queries typed by fewer than 50 different people | at build time | a rare query may contain one person's private details |
| Completions removed for legal reasons | serve-time list, live within minutes | cannot wait for the next hourly build |
The serve-time list is the safety valve: an entry pushed to every server, plus a purge of the affected prefixes from the edge caches, removes a bad suggestion within minutes. The build filter is fixed afterwards.
Part 4 · Failures and Trade-offs
What breaks, and how to cope
Typeahead has one rule above the rest: suggestions may fail, the search box may not. If anything below goes wrong, the app shows no list and the user types and searches as usual.
| Failure | What users see | Handling |
|---|---|---|
| A suggestion server crashes | nothing | health checks remove it; every server has a full copy |
| A zone or region fails | slightly slower lists | servers run in several zones; when a region is lost, edges send misses to the next region, a few tens of ms farther, and the 6 servers left absorb 40,000 a second |
| The build pipeline stops | suggestions slowly go stale | servers keep the last good trie; alert when it is over 3 hours old |
| A bad build is produced | nothing, if checks hold | validation refuses it; a canary catches what validation misses; roll back to the previous file |
| An offensive suggestion slips through | a screenshot on social media | serve-time blocklist and an edge purge within minutes, then fix the build filter |
| A big match, 5 to 10× for minutes | more edge misses for new prefixes | keep headroom: a new server must fetch and map 15 GB before it helps |
Hot prefixes are a gift here, not a problem. In most systems a hot key hurts. In typeahead the hottest prefixes are one or two letters long, and there are only about 700 of those with the 26 English letters, so the edge caches them almost perfectly.
Sharding, when you need it. If many more languages arrive or a trie outgrows one server, split by prefix range (for example, a to c on one group of servers), with ranges sized by measured data, since far more queries start with s than with x. Keep one- and two-letter prefixes in a small table on every server, so the shortest prefixes never depend on the shard map.
Trade-offs, cost and what to watch
Most choices in this design trade one thing: fresher and more personal results cost cacheability and simplicity. The table shows where each choice landed.
| Decision | Option A | Option B | Usually pick |
|---|---|---|---|
| Freshness | hourly batch build | fully streaming updates | batch, plus a small trending layer |
| Index form | trie in memory | prefix table in a key-value store | trie while it fits in RAM; a sharded trie or a table beyond that |
| Top 10 lists | stored at every node | computed per request | stored, unless memory is very tight |
| Personal results | merged on the server | merged on the device | device, to keep responses cacheable |
| Client requests | one per keystroke | newest prefix only, one in flight | newest only, with local filtering |
Option Ahourly batch build
Option Bfully streaming updates
Usually pickbatch, plus a small trending layer
Option Atrie in memory
Option Bprefix table in a key-value store
Usually picktrie while it fits in RAM; a sharded trie or a table beyond that
Option Astored at every node
Option Bcomputed per request
Usually pickstored, unless memory is very tight
Option Amerged on the server
Option Bmerged on the device
Usually pickdevice, to keep responses cacheable
Option Aone per keystroke
Option Bnewest prefix only, one in flight
Usually picknewest only, with local filtering
Each pick flips under a condition: a product where freshness within seconds matters, such as live scores inside the app, justifies streaming updates; a product whose index is far larger than RAM, such as every address in the country, starts as a sharded table.
What dominates the bill: the request count. 2.4 B requests a day is about 72 B a month, and edges price or provision per request, while the data is small and nine 64 GB servers answer the misses.
Part 5 · Interview kit
The 45-minute answer
A 45-minute round leaves about 40 minutes of design. Here is typeahead on that clock.
| Minutes | What you say and draw | Key number or decision |
|---|---|---|
| 0–5 | Requirements: up to 10 completions per prefix, popular and recent first, own recent searches, nothing offensive or private. Out of scope: spelling, the results page. | About 100 ms from keystroke to list |
| 5–8 | Estimates that decide something: requests per second, edge misses, trie memory. | 28,000/s average, plan for 100,000/s; about 15 GB per copy |
| 8–13 | API: GET /v1/suggest with Cache-Control, POST /v1/events. Data: events, daily counts, trie snapshots, blocklist. | The same answer for everyone, so it is cacheable |
| 13–22 | Online path: phone, edge cache, load balancer, servers with the whole trie in memory. Offline path: events, hourly build, validation, snapshot file, swap. | One copy fits in RAM: add servers for throughput, not space |
| 22–37 | Deep dive: the top 10 at every node, filled bottom-up; then decay scoring, validation, canary and swap. | About 1 ms per lookup; 7-day half-life |
| 37–40 | Wrap-up: a bad build, a lost region, a big match; personal results on the device; sharding by prefix. | Suggestions may fail; the search box may not |
Pick the trie if they ask how a lookup stays near 1 ms with millions of queries. Pick the build pipeline if they ask how suggestions stay fresh, ranked or safe. If they ask about personal results, explain the merge on the device and why it keeps the edge cache working.
- Say
- "About 8 requests per search makes 100,000 a second at peak, inside a 100 ms budget that is mostly network, so short prefixes are answered at the edge."
- Say
- "Each trie node stores its top 10 query ids, so a lookup is a short walk plus one read, about 1 ms; the price is an hourly offline build."
- Say
- "The trie is about 15 GB in a packed layout, so every server holds a full copy; personal results merge on the device so the shared answer stays cacheable."
- Avoid
- "I will run LIKE 'ind%' on the search database for every key." A short prefix matches millions of rows that must be sorted by score, on every key, at 100,000 requests a second.
Interview drill
Each question changes one thing. Answer it before you open it.
A cricket final ends and suggestion traffic jumps 10× for 15 minutes, everyone typing the same few new queries. What happens?
- Those prefixes are shared, so after the first miss in each city the edge serves them; servers see a short burst of misses.
- The new queries are not in the hourly trie yet; the trending layer adds them within 10 to 15 minutes.
- Keep headroom rather than counting on autoscaling: a new server must fetch and map 15 GB first.
- If servers saturate, return empty lists fast: the search box still works.
Product wants pnr to suggest "indian railway pnr status", matching the start of any word. What changes in the data?
- Insert each query once per word start, every copy pointing to the same query id, with a lower score for matches after the first word.
- At about 3.5 words per query that is 350 M entries, about 45 GB a copy and 90 GB during a swap: past a 64 GB server.
- Index word starts only for the top 20 M queries (about 20 GB a copy), or shard by prefix.
You must cut the serving cost in half. Where do you look?
- Requests are the bill, so send fewer: a node whose list has under 10 entries holds every completion below it, so the phone filters longer prefixes itself.
- Cache one- and two-letter prefixes for longer than 5 minutes, since they change slowly.
- A 30 to 50 ms debounce for fast typists cuts requests further, at a small latency cost.
- Common miss
- A personal field in the shared response. One per-user value makes every answer uncacheable at the edge.
- Common miss
- Updating the trie on every search. Fresh data belongs in the hourly build and the trending layer, never in the request path.
Wrap-up
Next, Design Collaborative Document Editing keeps a live connection open for every editor and merges many people's keystrokes into one document.
Part 6 · Check yourself
Quick check
Q1Users in one region see yesterday's suggestions while other regions show tonight's trends. Search works. What do you check first?
Think it through, then check.
- Which snapshot version that region's servers have loaded: a failed download or a refused build leaves them on an old trie.
- Whether the trending list reaches that region, and whether its edges keep answers far longer than 5 minutes.
- Load the last good snapshot there; the 3-hour age alert should have caught it.
Q2Product wants personal suggestions computed on the server, so a search on Neha's laptop shows up on her phone. What changes?
Think it through, then check.
- The shared answer stays public and cached; personal results come from a second, signed-in call that the phone merges.
- Each user's last 100 or so searches sit in a key-value store keyed by user id: a few ms per lookup.
- That call cannot be cached at the edge, so it pays the 10 to 40 ms trip to a region; users must be able to delete their history.
Q3A teammate says: skip the trie and run LIKE 'ind%' on the search database, because it is always fresh. What does that freshness cost?
Think it through, then check.
- A one- or two-letter prefix matches millions of rows to sort by score on every keystroke, at 100,000 requests a second.
- The trie moves the sorting into the hourly build, and the trending layer brings freshness back within minutes.
Q4The hourly build has been refused by validation for 12 hours on a day of big news. What do users see, and what do you do?
Think it through, then check.
- Search works and suggestions still appear: servers keep the last good trie, now 12 hours old.
- The trending layer, a separate streaming job, still adds breaking queries if it is healthy.
- Find why builds differ from the last good one, fix it, and send the first good build to a canary before the fleet.
Q5Which building block answers most keystrokes, and why can it do that here when it cannot for most personal APIs?
Think it through, then check.
- The edge caches of a CDN (the CDN chapter).
- The answer is the same for everyone with the same prefix and language, so one cached copy serves a whole city.
- The hottest prefixes are one or two letters, only about 700 in English, so they stay cached almost perfectly.