Design a Search Autocomplete System
This page is one interview loop in three rounds. All three rounds design the same system. Each round opens with the interviewer raising the scope, and the design from the round before has to evolve to meet it.
| Round 1: Mid-level | Round 2: Senior | Round 3: Architect | |
|---|---|---|---|
| Story | The product search box of one online store | A search engine: 1B searches a day; breaking news must show up fast | Worldwide, 40 languages, personal suggestions, privacy rules |
| Level (Amazon) | SDE II (L5) | Senior SDE (L6) | Principal (L7) |
| Data | ~1M distinct queries | 100M queries kept (30-day window); ~16 GB trie | ~560M queries in 40 locales (~98 GB of tries); per-user history |
| Traffic | ~10M searches/day → ~1K requests/s peak | 1B searches/day → 100K requests/s peak | ~5B searches/day → ~510K requests/s summed over regional peaks |
| Footprint | 1 region, 3 AZs | 1 region + CloudFront edge | 4 regions + edge |
| Targets | P99 < 50 ms; 99.9% | Server P99 < 10 ms; 99.99%; trending within 5 minutes | Server P99 < 10 ms in every region; 99.99% per region; privacy rules met |
| Reading time | ~35 min | ~40 min | ~45 min |
You can start at any round. Rounds 2 and 3 open with a "Where we left off" summary that catches you up.
Loop Opener: What Is Autocomplete?
You Already Know It: Finishing Someone's Sentence
A friend starts a sentence, "How do you b…", and you jump in: "boil an egg?" You didn't wait for the whole question. You guessed the most likely ending from what people usually say.
Autocomplete (also called typeahead) does the same thing for a search box. You type how to b, and before you type the next letter a list appears: how to boil an egg, how to be happy, how to budget. Each item is a completion: a whole query that starts with what you've typed so far, which we call the prefix.
| You type (the prefix) | You see (the completions) | Where the list came from |
|---|---|---|
how to b | how to boil an egg · how to be happy · how to budget | What other people searched most |
sneak | sneakers · sneakers for men · sneakers white | Popular queries on this store |
earthq | earthquake today · earthquake near me | Something that just happened |
What Makes It Hard
- Time. The list has to appear before the next keystroke. People type a character every 150 to 300 ms (a 40 words-per-minute typist types about 200 characters a minute, one every 300 ms; at 80 words per minute it's one every 150 ms). So the whole round trip gets about 100 to 150 ms, and our servers get only a few milliseconds of that.
- Numbers. Every search is several requests, one per pause in typing, and a big search engine handles billions of searches a day.
- Freshness. When news breaks, people search for it within minutes. A list built yesterday doesn't know about it.
- Safety. A suggestion is the product speaking. It must never suggest something offensive, illegal or private, even when bots try to push it in.
The Question the Whole Loop Answers
How do we answer every keystroke in milliseconds with the best, freshest and safest completions?
The answer gets sharper every round:
- Round 1: do the expensive work offline. Build a prefix tree with the top completions stored at every node, once a day, and serve it from memory.
- Round 2: keep that offline structure, but add a fast lane for trending queries, an edge cache, safe deploys of a 16 GB structure, a typo fallback and a safety filter.
- Round 3: add languages, people and law: tries per locale, sharding, personal history, privacy-preserving counts, regions and experiments.
Round 1 · Mid-level · "Autocomplete for One Online Store"
~35 min · SDE II (L5) · 1 region, 3 AZs · ~1M distinct queries · ~1K requests/s peak · P99 < 50 ms · 99.9%
R1.1 Establish Design Scope
The interviewer says: "Our online store has a search box. Add autocomplete to it." Before we draw anything, we ask questions and say what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How many suggestions, and what's in one? | Five. Each is a whole search query, not a product. | A response is 5 short strings, a few hundred bytes. We complete queries, so our data is the query log, not the product catalog. |
| How are they ranked? | By popularity: how often people searched that query. | We need a count per query, and "top 5 by count among queries starting with the prefix" (steps 1.1 and 1.2). |
| How fresh must they be? | Yesterday's data is fine. | We can build the whole thing offline once a day (step 1.3). |
| Which languages? | English only, for now. | One tree. Lowercasing and trimming are enough normalization this round. |
| Personal suggestions? Typo handling? | Not yet. | Everyone who types the same prefix gets the same answer, which makes answers easy to cache. |
| Where do we get the queries? | The search service already logs every search: the query, a time and a session ID. About 10M searches a day. | That log is our only input. We never read the product database. |
| How big is the query space? | About 1M distinct queries in a month. | Small enough to fit in one server's memory many times over (R1.7). |
Out of scope for this round:
- Trending queries. Something that became popular an hour ago waits for tomorrow's build.
- Typos.
snaekersgets no suggestions. - Safety filtering beyond not suggesting anything with personal data in it.
- Personalization and other languages.
In a multi-round loop, the interviewer brings parts of the out-of-scope list back later. Write it where you can see it.
R1.2 Functional Requirements, Derived Step by Step
| Phrase from the problem | Requirement |
|---|---|
| "As the user types, show suggestions" | GET /v1/suggest?q=<prefix>&limit=5 returns up to 5 completions of the prefix |
| "Ranked by popularity" | Order by how many times the query was searched in the last 30 days |
| "Yesterday's data is fine" | Rebuild the data once a day from the search log |
| "The search box already works" | Autocomplete is optional: if it fails, the search box still works |
Not yet: trending, typos, safety filtering, personalization, other languages.
R1.3 Non-Functional Requirements: the Questions
We state each quality in words first. The numbers come in R1.7.
- Latency against the keystroke. The list must feel instant. With 150 to 300 ms between keystrokes and a network round trip of 20 to 80 ms inside one country, our server should answer in a few milliseconds. We set the target at the load balancer: P99 < 50 ms, which is generous; an in-memory design will do far better.
- Availability, with graceful failure. Autocomplete is a helper. If it's down, the user types the whole query and presses Enter, and the search still works. So we aim for 99.9% (at most
0.1% × 8,760 h ≈ 8.8 ha year), and the bigger promise is that autocomplete failing never breaks search. - Freshness. Daily. A query that became popular today appears tomorrow.
- Cost. It's a small feature on a store's website. It should cost hundreds of dollars a month, not thousands.
R1.4 The API
Ask for suggestions
httpGET /v1/suggest?q=sne&limit=5 HTTP/1.1 Host: api.shop.example.com Accept: application/json
httpHTTP/1.1 200 OK Content-Type: application/json Cache-Control: private, max-age=300 { "prefix": "sne", "snapshot": "2026-09-27", "suggestions": [ { "text": "sneakers" }, { "text": "sneakers men" }, { "text": "sneakers white" }, { "text": "sneaker cleaner" }, { "text": "sneaker socks" } ] }
qis the prefix exactly as typed; the server lowercases and trims it.limitis at most 10.snapshotnames the daily build that answered. It helps debugging ("why did I see this?") and tells the client when the data changed.Cache-Control: private, max-age=300lets the browser reuse the answer for 5 minutes. When the user deletes a character and retypes it, the browser already has the answer.- An empty list is a normal answer:
"suggestions": [].
What the client does, and why
The client is half of the design. Without it, the server would get one request per keystroke and the screen would flicker.
- Debounce. A debounce waits until the user pauses. After each keystroke the client starts a short timer (about 100 ms; a design choice in the 100–200 ms range), and restarts it on the next keystroke. The request goes out only when the timer runs out. A fast typist entering
sneakers white(14 characters) pauses only a few times, so the client sends about 4 requests instead of 14. That "about 4 requests per search" is the number we size everything with. - Cancel stale requests. When a new request goes out, the client cancels the previous one still in flight (in a browser, with
fetchand anAbortController). There's no point waiting forsnwhen the user has typedsne. - Ignore out-of-order answers. Responses can still arrive out of order on a mobile network. The client numbers its requests and drops any response older than the newest one it has shown, so
sn's answer can never replacesne's. - Fail quietly. If no answer arrives within about 300 ms, or the server returns an error, the client shows no list. It never blocks typing or the Enter key.
Recap
- One operation: prefix in, top 5 queries out.
- About 10M searches a day and 1M distinct queries; English only.
- Daily freshness; P99 < 50 ms at the load balancer; autocomplete failing never breaks search.
Let's build it, starting with the simplest version that works.
R1.5 Design Evolution: From a SQL Query to a Precomputed Trie
Every step follows the same pattern: a problem, your turn to think, the answer, and what the answer costs us. The cost is always the next problem.
Step 1.0: The Baseline
A table in the store's existing database, filled by a nightly job from the search log:
sqlCREATE TABLE query_counts ( query TEXT PRIMARY KEY, -- lowercased, trimmed count_30d INTEGER NOT NULL ); SELECT query FROM query_counts WHERE query LIKE 'sne%' ORDER BY count_30d DESC LIMIT 5;
Synthesizing vector architecture diagram...
Every keystroke becomes a query against the store's main database.
It works, and for a demo it's the right answer. Everything that follows exists because this query does more work than it looks like.
Step 1.1: The Query Gets Slower as the Table Grows
The problem: with 1M rows, sne is fast, but s takes 80 ms and a takes 100 ms. Short prefixes are exactly the ones people type first.
What would you do?
Primitive: Trie Data Structure & Inverted Index
Step 1.2: Finding the Top 5 Under "s" Means Visiting Thousands of Nodes
The problem: the node for s is found in one step. But its subtree holds every query starting with s, over 100,000 of them. To pick the top 5, we visit them all.
What would you do?
A worked example. Seven queries from the store's log, with their 30-day counts: socks 1,200 · sneakers 900 · snow boots 700 · sandals 650 · sunglasses 500 · sneakers men 400 · socks wool 300. We show the top 3 at each node to keep the picture small.
Synthesizing vector architecture diagram...
A radix tree for seven queries. Each edge carries a string, and each node stores its top list. Notice the sn node: its top 3 merges its children's lists (sneakers 900, sneakers men 400 and snow boots 700). And the s node's top 3 comes from its four children's lists without looking at any leaf.
Three lookups, traced:
| Prefix typed | Walk | Answer read |
|---|---|---|
s | root → s | socks, sneakers, snow boots |
sn | s → edge n → node sn | sneakers, snow boots, sneakers men |
sne | node sn → part-way along edge eakers | the list of the node that edge leads to: sneakers, sneakers men |
snx | node sn → no edge starts with x | empty list |
The third row is the radix-tree rule worth remembering: a prefix that ends inside an edge has exactly the same completions as the node at the end of that edge, because everything below that point is below that node. So we only store lists at real nodes, not at every character.
Step 1.3: Counts Change All Day
The problem: every search changes a count. If sneakers white gets searched, its count goes up, and that may change the top-5 list at every node on its path.
What would you do?
Step 1.4: Where Does the Trie Live?
The problem: we have a snapshot file in S3. Requests arrive at 1,000 a second. What would you do? Where should the trie be when a request comes in?
Primitive: Distributed Cache Patterns & Eviction
How a server switches to a new snapshot. Every 5 minutes it reads latest.json. If the manifest names a new file, the server downloads it next to the one it is serving, checks the checksum and the size, builds its lookup structures, then swaps a single pointer so new requests use the new trie. Requests already running finish on the old one. Then the old one is freed. There is never a moment with no trie.
Step 1.5: Every Keystroke Fires a Request, and Answers Arrive Out of Order
The problem: in testing, typing sneakers white quickly sends 14 requests. On a phone, the answer for sneak arrives after the answer for sneaker, and the list jumps backwards.
What would you do?
Round 1 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.0 | (baseline) | LIKE 'sne%' ORDER BY count LIMIT 5 | Scans and sorts on every keystroke |
| 1.1 | Slower as the table grows | Radix tree: one step per character | Still need the top 5 under the node |
| 1.2 | Top 5 means visiting the subtree | Top-k list stored at every node, built bottom-up | Memory; rebuild when counts change |
| 1.3 | Counts change all day | Daily offline build → read-only snapshot in S3 | Up to a day stale |
| 1.4 | Where does the trie live? | In the memory of every suggest server | Load time; memory per server |
| 1.5 | Too many requests, out of order | Debounce, cancel, drop stale, browser cache | ~100 ms delay after a pause |
R1.6 Architecture v1
Now the concepts get AWS names.
Synthesizing vector architecture diagram...
The request path is short: load balancer, then a lookup in memory. Everything that takes real work (reading logs, counting, building) happens once a day, offline, and reaches the servers as one file.
Why these pieces:
- Firehose collects the search service's log lines and writes them to S3 in files every few minutes. Firehose bills each record rounded up to 5 KB, so the search service packs about 25 log lines (200 bytes each) into one record instead of sending one record per line.
- AWS Glue runs the daily Spark job. The job is small this round (a few hundred million log lines); Glue means no cluster to keep running between builds.
- Graviton
m7g.large(2 vCPUs, 8 GiB): far more memory and CPU than we need. There are three of them for availability, one per AZ, not for load. - ALB health checks call
/health, which returns 200 only after the server has loaded a snapshot. A server that's still loading gets no traffic.
The snapshot file
| Section | Contents | Size for 1M queries (assumption) |
|---|---|---|
| Header | Format version, build date, counts of each section, checksum (CRC-32C) | Bytes |
| Nodes | Per node: first child, number of children, top-5 list | ≤ 2M nodes |
| Edge labels | The characters on each edge, packed | Included in the node estimate |
| Top lists | 5 × (query ID 4 B + count 4 B) = 40 B per node | Included in the node estimate |
| Query strings | Every kept query, indexed by query ID | 1M × 20 B = 20 MB |
The top lists hold query IDs, not text. The text lives once, in the query-string table, and the server turns 5 IDs into 5 strings with 5 array reads.
Tracing one request: the user types sne
Synthesizing vector architecture diagram...
One pause, one request, one walk in memory. The only real latency is the network.
Tracing the daily build
- At 02:00 UTC, the Glue job reads the last 30 days of log files from S3: about 300M lines.
- It normalizes, counts, and keeps about 1M queries seen at least 5 times.
- It builds the radix tree, computes every node's top 5 bottom-up, and writes
snapshots/2026-09-27/trie.binwith its checksum. - It writes
snapshots/latest.json→{"snapshot": "2026-09-27", "file": "snapshots/2026-09-27/trie.bin", "crc32c": "…", "bytes": 167772160}. - Within 5 minutes each server sees the new manifest, loads the file beside the old one, checks it, and swaps.
R1.7 Numbers
Targets
| Quality | Target | Why this number |
|---|---|---|
| Latency | P99 < 50 ms at the load balancer | Leaves most of a 150–300 ms keystroke gap for the network and the browser |
| Availability | 99.9% | ≈ 8.8 h a year; and search works without us |
| Freshness | New data every day | From R1.1 |
Traffic
| Item | Math | Result |
|---|---|---|
| Searches | given | 10M a day |
| Requests per search | ~4 after debounce (R1.4) | assumption |
| Requests per day | 10M × 4 | 40M |
| Average rate | 40M ÷ 86,400 s | ≈ 463 a second |
| Peak rate | 463 × 2.2 (evening shopping peak; assumption) | ≈ 1,019 → ~1K a second |
Memory for the trie (every per-node figure is an assumption for a compact array layout)
| Item | Math | Result |
|---|---|---|
| Nodes | A radix tree with n queries has at most about 2n nodes: n ends of queries plus at most n − 1 branching nodes | ≤ 2M nodes |
| Bytes per node | 40 B top-5 list + ~30 B for child links and edge labels | ~70 B |
| Nodes total | 2M × 70 B | 140 MB |
| Query strings | 1M × 20 B (average query length, assumption) | 20 MB |
| Snapshot | ≈ 160 MB | |
| During a swap | old + new | ≈ 320 MB, plus a few hundred MB for the process itself |
An 8 GiB server is more than enough. Loading 160 MB from S3 at ~100 MB/s takes about 2 seconds.
Servers
| Item | Math | Result |
|---|---|---|
| Work per request | Parse, normalize, walk, read 5 IDs, write JSON | Well under 1 ms of CPU |
| Capacity per server | ~2,500 requests/s per vCPU (assumption; load-test it) × 2 vCPU | ~5,000 a second |
| Needed for load | 1,000 ÷ 5,000 | 0.2 of a server |
| Chosen | One per AZ, for availability | 3; after losing an AZ, 2 carry 500 a second each (10% busy) |
Rough monthly cost (us-east-1 on-demand list prices; check the AWS Pricing Calculator before quoting)
| Line | Math | ≈ Monthly |
|---|---|---|
| Suggest servers | 3 × m7g.large × $0.0816/h × 730 h | $179 |
| ALB | $0.0225/h × 730 = $16, plus ~2 LCUs average (new connections are the largest dimension) × $0.008 × 730 = $12 | $28 |
| Glue | 10 DPUs × 20 min a day = 3.3 DPU-hours × $0.44 × 30.4 days | $45 |
| Firehose | 10M × 200 B = 2 GB a day, packed into 5 KB records: 61 GB a month × $0.029 | $2 |
| S3 | 90 days of logs (~180 GB before compression) + snapshots | $5 |
| CloudWatch | metrics, alarms, logs | $20 |
| Total | ≈ $280 |
About $280 a month. That's what "precompute everything" buys: the serving path is so cheap that the servers are there for availability, not load.
R1.8 Trade-Offs
Where the suggestions come from
| In-memory trie with top-k (chosen) | Key-value table: prefix → top-k | Search engine (OpenSearch) | |
|---|---|---|---|
| How a request is answered | Walk + read in local memory | One network lookup | Match the prefix against an index, then rank the matches |
| Latency | Microseconds in the server | ~1 ms (ElastiCache) to a few ms (DynamoDB) | Several ms to tens of ms, depending on how many documents match |
| Ranking | Precomputed | Precomputed | Computed per request (unless we use its completion suggester, below) |
| Typos, words in any order | No | No | Yes: fuzzy matching, word-level matching |
| Operations | A build job and a file | A build job and a table | A cluster to size, patch and tune |
| Cost at 1K/s | 3 small servers | A small cache or table | A 3-node cluster, several times the cost |
Why not just use a search engine with prefix matching? An inverted index (a map from each word, or each word's leading characters with edge n-grams, to the list of documents containing it) answers "which queries start with sne?" well. But it then has to score and sort the matches for every request, and for a one-letter prefix that is a large fraction of the whole index: the same "search the subtree" work as step 1.2, just inside another system, and an index built with edge n-grams stores every prefix of every word, so it is several times larger than the text. OpenSearch's completion suggester is closer to our design: it keeps a compact prefix structure (a finite-state transducer) in memory and returns completions ordered by a weight we set at index time. It's a fine choice; we'd still pay for a cluster and a network hop to get what a 160 MB file in our own process already gives us. We'll use OpenSearch in Round 2 for what it's good at: typos.
How many completions to store at each node (k)
| k | Bytes per node for the list | Nodes × list for 1M queries | What it buys |
|---|---|---|---|
| 5 | 40 B | 80 MB | Exactly what we show |
| 10 | 80 B | 160 MB | Room to drop some later (filters, dedup) and still show 5 |
| 25 | 200 B | 400 MB | Room to re-rank on the server |
At this size any of them fits. We store 5, because we show 5 and nothing filters them yet. Round 2 revisits this at 200M nodes, where each extra entry costs 1.6 GB.
Daily vs hourly builds. An hourly build is possible (the job takes minutes). But hourly doesn't really solve freshness (a news spike is minutes, not hours), and it multiplies build cost and deploys by 24. For a store, daily is right; for news, Round 2 adds a separate fast lane.
Drill: The search bar that stalled on every keystroke (step 1.2 answers why walking the subtree on every keystroke breaks down as the phrase count grows; the paragraph above answers why not an inverted index with prefix matching)
R1.9 Failure Modes
| Trigger | What you'd see | How the design responds |
|---|---|---|
| The daily build fails | No new latest.json. | Servers keep serving yesterday's snapshot; suggestions are a day older, nothing breaks. An alarm fires if the snapshot is older than 36 hours. |
| A build produces a bad file | A truncated upload, or a bug that empties the tree. | The manifest is written only after the job verifies the file: checksum matches, node count within ±20% of yesterday's, and a list of 100 test prefixes (such as s, sne, shoe) all return results. A server re-checks the checksum before swapping, and keeps the old trie if anything fails. |
| A server restarts | It gets no traffic for a few seconds. | It loads the 160 MB snapshot in about 2 seconds; the ALB health check passes only after the load. The other two servers carry the traffic meanwhile. |
| Losing an AZ | One server gone. | The ALB stops sending to it; the other two handle 500 requests a second each. |
| Autocomplete is completely down | Errors or timeouts. | The client waits at most ~300 ms, then shows nothing. The user types the whole query and searches. This is the most important failure behavior of the design. |
R1.10 Pillar Check
| Pillar | What Round 1 covers |
|---|---|
| Reliability | Three servers in three AZs behind an ALB; health checks that wait for the snapshot; yesterday's snapshot as the fallback; a client that fails quietly so search never depends on us. REL 10 · REL 11 |
| Performance Efficiency | Work moved offline: precomputed top-k per node, one walk per request in local memory; client debounce cuts requests about 3.5×. PERF 1 · PERF 3 |
| Cost Optimization | ≈ $280 a month derived; servers sized for availability, not load; Glue runs only while building. COST 5 · COST 6 |
| Security | Light this round: search logs can contain personal data (people paste emails and order numbers into search boxes). The build drops queries that look like personal data, and only queries seen at least 5 times can be suggested. Logs are encrypted in S3 and kept 90 days; TLS to the ALB. SEC 7 · SEC 9 |
| Operational Excellence | Light this round: alarms on snapshot age (> 36 h), suggest P99, error rate and the share of empty answers. OPS 8 |
| Sustainability | Light this round: the build runs once a day on serverless Spark and releases its capacity; a 90-day log lifecycle rule stops storage from growing forever. SUS 4 |
R1.11 Round 1 Rubric and Follow-Ups
What a strong mid-level (L5) answer shows
- Asks about ranking, freshness and where the data comes from before designing.
- Explains why an index doesn't fix
ORDER BY count LIMIT 5for short prefixes. - Arrives at a trie and, crucially, at storing the top-k at every node, with a correct way to build it.
- Separates the offline build from the read-only serving path.
- Designs the client: debounce, cancel, drop stale, fail quietly.
- Sizes memory and servers from the numbers, and notices the servers exist for availability.
Follow-up questions
-
"Why not compute the top 5 for every prefix string, instead of a trie?" Answer: that's the key-value table, and it works. It stores one entry per distinct prefix string, which is several times more entries than a radix tree has nodes, because the radix tree shares one list among every prefix that ends inside the same edge. At 1M queries either fits; at 100M the difference is gigabytes.
-
"A query is in the top 5 for
sn. Must it be in the top 5 fors?" Answer: no.shas more competitors:sockscan push it out. The rule goes the other way: anything ins's top 5 that starts withsnmust be insn's top 5. That's why lists are built from the leaves up, not from the root down. -
"The build ran, but one server is still showing yesterday's suggestions an hour later. Why might that be, and how would you know?" Answer: its manifest poll may be failing (permissions, network), or the new file failed its checksum on that server and it kept the old one, which is the safe behavior. The response's
snapshotfield and a per-server "snapshot in use" metric show it immediately; an alarm fires when any server's snapshot is older than the newest manifest by more than 30 minutes.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
"An index on query makes LIKE 'x%' fast" | It finds the rows; it can't rank them. Short prefixes still sort a large part of the table. |
| "DFS the subtree with a heap on each request" | Work per request grows with the data; short prefixes are the most common and the most expensive. |
| "Update counts in the trie on every search" | A concurrent write-heavy structure for a requirement that tolerates a day of staleness. |
| "Throttle fast typists on the server" | Punishes the most engaged users and doesn't fix out-of-order responses. |
| "If suggestions fail, show an error" | Autocomplete is optional; the search box must work without it. |
Round 2 · Senior · "1B Searches a Day, Trending Topics, Typos"
~40 min · Senior SDE (L6) · 1 region, 3 AZs + CloudFront edge · 100M queries, ~16 GB trie · 100K requests/s peak · server P99 < 10 ms · 99.99% · trending within 5 minutes
R2.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 2. If you're starting here, it's everything you need from Round 1.
Round 1 in 60 seconds. "We built autocomplete for one online store: 10M searches a day, about 1M distinct queries, English only, top 5 by 30-day popularity, refreshed daily. The expensive work is offline. Once a day a Glue job reads 30 days of search logs from S3, normalizes and counts queries, and builds a radix tree where every node stores its top 5 completions, computed bottom-up from its children's lists. The snapshot, about 160 MB, goes to S3 with a manifest written last. Three suggest servers, one per AZ behind an ALB, each hold the whole trie in memory, so a request is one walk and one read: microseconds. They poll the manifest and swap in a new snapshot beside the old one. The client debounces about 100 ms, cancels stale requests and drops out-of-order answers, so a search is about 4 requests, about 1K a second at peak. About $280 a month. Open costs: a day of staleness, no typo help, no safety filtering, and everything sized for one small store."
Architecture v1, compact
Synthesizing vector architecture diagram...
Round 1 in one picture: a daily build turns logs into one file, and every server answers from its own copy in memory.
Round 1 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | Slower as the table grows | Radix tree | Still need the top 5 |
| 1.2 | Top 5 means visiting the subtree | Top-k stored at every node | Memory; rebuilds |
| 1.3 | Counts change all day | Daily offline build → snapshot | A day stale |
| 1.4 | Where does the trie live? | In every server's memory | Load time |
| 1.5 | Too many requests, out of order | Debounce, cancel, drop stale | ~100 ms after a pause |
Open costs: a day of staleness; no typo help; no safety filtering; 160 MB was easy to load and swap, and a much bigger snapshot won't be.
R2.1 The Scope Raise
Interviewer: "Now it's the autocomplete for a general web search engine. A billion searches a day. When news breaks, people search for it within minutes, and the suggestions must show it within 5 minutes. People make typos and still expect help. Some suggestions must never appear: hateful, sexual content about real people, things a court has ordered removed. 'Café' and 'cafe' are the same search. Our servers must answer in under 10 ms at P99, at 99.99%."
A scope raise is not the end of scoping. We ask back, and say what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Is it still one market and one language? | Mostly US English for now. One region is fine. | One trie, one region. Languages are Round 3. |
| How many requests does a billion searches mean? | Use about 4 requests per search after debounce, and a 2.2× daily peak. | 4B requests a day, 100K a second at peak (R2.6). Everything below is sized from this. |
| "Within 5 minutes" of what? | From the moment many people start searching a new phrase, to the moment it appears in suggestions. | A daily build can't do it; we need a separate path that counts recent searches continuously (step 2.1). |
| How many queries do we keep? | The top 100M from a 30-day window; drop anything searched fewer than 50 times. | A ~16 GB trie: loading, swapping and memory become real problems (steps 2.3 and R2.6). |
| Where do users come from, and how fast must it feel? | All over the US, plus travelers. Suggestions should appear within about 100 ms of a pause. | Network distance matters: the answers for 1–3 letter prefixes are the same for everyone and can be served from the edge (step 2.2). |
| What about typos? | sytem des should still suggest system design. | A fuzzy search system as a fallback when the trie has nothing (step 2.4). |
| Who decides what's blocked, and how fast must a removal take effect? | A trust-and-safety team; an urgent removal must disappear within minutes. | A blocklist applied at build time and at serve time, with an emergency push (step 2.5). |
| Have you seen manipulation? | Yes: bots searching a phrase over and over to push it into suggestions. | Count people, not searches, and check where the volume comes from (step 2.5). |
Scope change
| Round 1 | Round 2 | |
|---|---|---|
| Searches | 10M/day | 1B/day |
| Requests | ~1K/s peak | 46,296/s average, 100K/s peak |
| Data | ~1M queries, 160 MB | 100M queries (30-day window, ≥ 50 searches), ~16 GB |
| Freshness | Daily | Nightly build + trending within 5 minutes |
| Footprint | 1 region, 3 AZs | 1 region, 3 AZs + CloudFront edge |
| Latency | P99 < 50 ms at the ALB | Server P99 < 10 ms |
| Availability | 99.9% (8.8 h/year) | 99.99% (52.6 min/year) |
| Features | Top 5 by popularity | + trending, typos, blocked suggestions, Unicode normalization |
Why not 99.999%? Five nines allows 5.26 minutes of downtime a year. Autocomplete is a helper: when it fails, search still works. Paying for five nines (several regions, instant failover) buys little for a feature whose failure users barely notice. We state 99.99% for the service and keep the Round 1 promise that search never depends on it.
R2.2 What Breaks in the Round 1 Design
| Round 1 choice | What breaks at the new scope |
|---|---|
| A daily build | Breaking news needs 5 minutes; a daily (or even hourly) rebuild of a 100M-query trie can't get close. |
| A 160 MB snapshot | 100M queries make a ~16 GB trie. Loading takes about a minute, and two copies during a swap need ~32 GB per server. |
| Swapping in place, no checks beyond a checksum | A subtly bad 16 GB snapshot (half the queries missing, a ranking bug) would be served everywhere at once. |
| Every request goes to our region | 1–3 letter prefixes are about 40% of requests, their answers are identical for everyone, and users far away pay the full round trip for them. |
| Exact prefix match only | sytem des matches nothing. The user gets an empty list exactly when they need help. |
| No filtering | Anything popular gets suggested, including what bots push and what must never appear. |
| Lowercase and trim only | Café, cafe and CAFE are counted as three queries, splitting their counts and wasting space. |
We fix them in this order: freshness (2.1), short prefixes at the edge (2.2), safe deploys of a big snapshot (2.3), typos (2.4), safety (2.5), normalization (2.6).
R2.3 New Requirements and API Additions
A locale parameter and a fuzzy flag
httpGET /v1/suggest?q=sytem%20des&locale=en-US&limit=5 HTTP/1.1 Host: suggest.example.com
httpHTTP/1.1 200 OK Content-Type: application/json Cache-Control: private, max-age=60 { "prefix": "sytem des", "normalized": "sytem des", "locale": "en-US", "snapshot": "2026-09-27", "fuzzy": true, "suggestions": [ { "text": "system design interview", "type": "FUZZY" }, { "text": "system design primer", "type": "FUZZY" }, { "text": "system design course", "type": "FUZZY" } ] }
localeselects the trie. In Round 2 there is onlyen-US, but putting it in the API now is free and saves a breaking change in Round 3.fuzzy: truetells the client the list comes from typo matching, so it can say "Did you mean…" style text instead of pretending these complete what was typed.- Each suggestion has a
type:POPULAR(from the nightly trie),TRENDING(from the fast lane) orFUZZY. The client can mark trending items, and our logs can measure each source.
An admin API for blocked terms (internal, trust-and-safety reviewers only)
httpPOST /admin/v1/blocked-terms HTTP/1.1 Host: suggest-admin.internal.example.com Authorization: Bearer <reviewer token> Content-Type: application/json { "locale": "en-US", "phrase": "<the phrase, as typed>", "match": "PHRASE", "reason": "HARASSMENT", "ticket": "TS-4412", "urgent": true }
httpHTTP/1.1 201 Created Content-Type: application/json { "id": "bt_01J8ZK4", "normalized": "<normalized phrase>", "list_version": 4127 }
match | Blocks | Example use |
|---|---|---|
PHRASE | Any suggestion whose normalized text equals the phrase | A specific defamatory query |
TOKEN | Any suggestion containing the word | A slur |
PHRASE_PREFIX | Any suggestion starting with the phrase | A person's name followed by anything, while a court case runs |
DELETE /admin/v1/blocked-terms/{id} removes an entry; GET /admin/v1/blocked-terms?locale=en-US lists them. Every change records who made it and the ticket, and bumps list_version. urgent: true also pushes the change to the serving fleet within about a minute (step 2.5).
R2.4 Design Evolution: Fresh, Fast, Forgiving and Safe
Step 2.1: Breaking News Takes a Day to Appear
The problem: an earthquake hits at 09:00. By 09:02, 50,000 people have searched earthquake los angeles. Our suggestions for earthq still show last month's queries until tomorrow's build.
What would you do? The target is 5 minutes.
Primitive: Message Queues vs Event Streams (a stream lets both the Flink job and the log archive read the same events)
Synthesizing vector architecture diagram...
Every step waits for the one before, so the worst cases add up: about 164 s (2.7 minutes) until every edge location has it, and about 224 s (3.7 minutes) until a browser that cached the old answer (max-age=60) asks again. That leaves over a minute of the 5-minute budget for enough people to search the phrase: at 1,000 searches a minute, 300 distinct users take well under a minute.
Step 2.2: The Top 1–3 Letter Prefixes Are Most of the Traffic
The problem: about 40% of requests are for prefixes of 1 to 3 letters, and there are only about 18,000 of them (26 + 26² + 26³, ignoring digits). Their answers are the same for every user. A user in Seattle waits 70 ms of round trip to us-east-1 for the answer to w, and a viral moment sends a burst of identical requests at once.
What would you do?
Drill: The image service that made every origin server sweat (this step answers why an edge cache instead of a bigger origin: distance and bursts, not server count; step 2.5 answers how long a removed item stays visible: at most the edge TTL plus the browser's cache time, unless we invalidate it)
Step 2.3: Swapping a 16 GB Snapshot Causes Errors
The problem: the nightly build finishes. Each server loads 16 GB. During the load, CPU and memory are busy and P99 jumps. One night the build had a bug that dropped every query containing a digit; every server loaded it within minutes, and iphone 1 suggested nothing for hours.
What would you do?
Synthesizing vector architecture diagram...
A snapshot only reaches the fleet after the offline checks and a canary. The previous snapshot stays in memory for 2 hours, so rollback costs nothing but a pointer swap.
Step 2.4: "sytem des" Returns Nothing
The problem: the trie matches prefixes exactly. sytem des has no node, so the list is empty. About 3% of the requests reaching our servers are like this (an assumption; we measure it).
What would you do? The fix must not break the 10 ms target for everyone else.
Primitive: Circuit Breaker, Bulkhead & Fault-Tolerance Patterns · Drill: The slow recommendation service that took down checkout (this step answers both: why a slow dependency exhausts the caller's threads, and why a timeout alone isn't enough; see also "The fuzzy backend is slow" in R2.8)
Why a timeout alone isn't enough. Suppose OpenSearch slows from 10 ms to 5 seconds. With no timeout, each fuzzy request holds a server thread for 5 seconds; at 1,800 fuzzy requests a second, that's thousands of threads, and the threads serving the trie run out too: a failure of an optional feature takes down the whole service. A tight timeout (our 30 ms) caps each wait, but every request still goes to a sick backend, still costs 30 ms, and adds load while it struggles. The bulkhead caps how many threads fuzzy calls can ever hold, and the breaker stops sending them at all until a trial call succeeds. And making the timeout even tighter everywhere backfires: normal variation (a garbage-collection pause, a slow disk read) then fails healthy calls, which look like an outage and trip the breaker for no reason.
The fuzzy index
json{ "settings": { "number_of_shards": 1, "number_of_replicas": 2, "analysis": { "analyzer": { "folded_phrase": { "type": "custom", "tokenizer": "keyword", "filter": ["lowercase", "asciifolding"] } } } }, "mappings": { "properties": { "suggest": { "type": "completion", "analyzer": "folded_phrase" }, "query_id": { "type": "integer" } } } }
One shard with two replicas puts a full copy on each of the three data nodes, one per AZ. Each document looks like {"suggest": {"input": "system design interview", "weight": 98500}, "query_id": 18234}.
The fuzzy query
json{ "suggest": { "typo": { "prefix": "sytem des", "completion": { "field": "suggest", "size": 5, "skip_duplicates": true, "fuzzy": { "fuzziness": "AUTO", "prefix_length": 1 } } } } }
prefix_length: 1 requires the first character to match, which keeps the search small (people rarely mistype the first letter). The nightly build writes a new index, suggest-fuzzy-en-us-2026-09-27, and moves the alias suggest-fuzzy-en-us to it only after it has been checked: the same blue/green idea as the trie.
Step 2.5: An Offensive Phrase Is Trending
The problem: a coordinated group searches a slur attached to a public figure's name thousands of times. The trending job picks it up, and it appears under the person's name for everyone. What would you do?
Primitives: Bloom Filters & Counting Filters · Bot Defense, Sybil Resistance & Registration Abuse · Drill: The web crawler that forgot its history (answered in "Bloom filter sizing" below)
Bloom filter sizing, and why ours is optional. For a 1% false-positive rate, a Bloom filter needs about −ln(0.01) ÷ (ln 2)² ≈ 9.6 bits per element and 9.6 × ln 2 ≈ 7 hash functions: about 1.2 MB for 1M phrases. If we keep adding phrases past the size it was built for, its bits fill up and the false-positive rate climbs (to 15% or worse). In a filter used alone, that means more and more legitimate items wrongly treated as "in the set". In ours, it only means more lookups fall through to the exact set: slower, never wrong. We rebuild the filter at the right size with every snapshot. Why not keep the exact set of phrase hashes in Redis instead? It would be exact, but it would put a network call on the path of every request for a few dozen membership checks, and our exact set fits easily in each server's memory. A Bloom filter earns its place when the exact set is too big to keep local, so the filter saves remote lookups for the "definitely not" answers.
Step 2.6: "Café" and "cafe" Are Counted Separately
The problem: Café, cafe, CAFE and cafe typed with a separate combining accent are four different byte strings. Each gets its own count, so none of them reaches the top 5 that their sum deserves, and the trie stores four branches.
What would you do?
Round 2 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | News takes a day | Kinesis → Flink 10-minute window → trending overlay every minute, merged at serve time | Two sources; a threshold |
| 2.2 | Short prefixes dominate | CloudFront for 1–3 letters, 60 s TTL, request collapsing, Origin Shield; single-flight | ≤ 60 s edge staleness; per-request fees |
| 2.3 | 16 GB swaps cause errors | Validate, canary, pointer in Parameter Store, keep old copy 2 h | Double memory |
| 2.4 | Typos return nothing | OpenSearch completion suggester, fuzzy, 30 ms budget, bulkhead, breaker | A second system; two latency targets |
| 2.5 | Offensive trends | Build-time and exact serve-time filtering; AppConfig emergency list; distinct-user and source checks | Held-back terms; review work |
| 2.6 | Split counts | NFKC, case folding, per-locale accent removal, same function everywhere | Locale rules; versioned normalization |
R2.5 Architecture v2
Synthesizing vector architecture diagram...
Three paths. Serving (top): short prefixes from the edge, the rest from the fleet, which merges the trie with the trending overlay and filters the result. Speed (middle): completed searches become a new overlay every minute. Batch (bottom): the same events become tomorrow's trie and fuzzy index. The two paths meet only inside the servers.
What one suggest server holds in memory
| Item | Size | Refreshed |
|---|---|---|
| Active trie snapshot | ~16 GB | Nightly (blue/green) |
| Previous snapshot | ~16 GB, for 2 hours after a swap | – |
| Trending overlay | ~20 MB | Every 30 s (fetched) |
| Exact blocklist + optional Bloom filter | ~50 MB + ~1.2 MB | Nightly, plus emergency list every 15 s |
| Fuzzy-answer cache | ~100 MB, 60 s entries | Continuous |
Tracing a normal keystroke: the user pauses after earthq
Synthesizing vector architecture diagram...
Six characters is past the edge's 1–3, so it goes to the region. Every step is local memory; the server's own work is well under a millisecond.
Tracing a trending term appearing
Synthesizing vector architecture diagram...
About two minutes from the first searches to the suggestion appearing everywhere, including the edge.
Tracing a typo
Synthesizing vector architecture diagram...
Only requests that find nothing locally reach OpenSearch, and at most one call per prefix is in flight per server.
R2.6 Numbers and Cost
Traffic
| Item | Math | Result |
|---|---|---|
| Searches | given | 1B a day |
| Requests per search | ~4 after debounce | 4B requests a day |
| Average | 4 × 10⁹ ÷ 86,400 s | ≈ 46,296 a second |
| Peak | 46,296 × 2.2 | ≈ 101,851 → ~100K a second |
| At the edge | 40% are 1–3 letters (assumption, from logs) | ~40K a second at peak |
| At the origin | 100K × 0.60 | ~60K a second at peak (plus edge misses: at most about 18K prefixes ÷ 60 s ≈ 300 a second through Origin Shield) |
Trie memory (the per-node sizes are assumptions for a compact array layout; measure the real build)
| Item | Math | Result |
|---|---|---|
| Queries kept | top 100M in 30 days, each searched ≥ 50 times | 100M |
| Nodes | radix tree: at most about 2 per query (step 1.2's rule) | ~200M nodes (an upper bound) |
| Bytes per node | 5 × (4 B query ID + 4 B score) = 40 B, + ~30 B links and edge labels | ~70 B |
| Nodes total | 200 × 10⁶ × 70 B | 14 GB |
| Query strings | 100M × 20 B average | 2 GB |
| Trie | 14 + 2 | ≈ 16 GB |
Every ID fits in 4 bytes (100M < 2³² ≈ 4.3B), and so does every score: even a query searched 10M times a day reaches only 300M in 30 days.
Bandwidth
| Item | Math | Result |
|---|---|---|
| Response size | 5 suggestions in JSON | ~350 B |
| Peak egress, all paths | 100,000 × 350 B = 35 MB/s | 280 Mbps |
| Monthly bytes out | 4B × 350 B × 30.4 days | 42.56 TB (17.0 TB from the edge, 25.5 TB from the origin) |
Suggest fleet
| Item | Math | Result |
|---|---|---|
| Capacity per server | r7g.2xlarge (8 vCPUs, 64 GiB); ~2,500 requests/s per vCPU (assumption; load-test it) | ~20K a second at full CPU |
| Planned maximum | 50% CPU, to keep queueing out of the P99 | 10K a second per server |
| Servers | origin peak 60K; after losing an AZ, the other two AZs must carry it: 60K ÷ 10K = 6 servers in two AZs | 9 servers, 3 per AZ: 6.7K a second each normally, 10K after an AZ loss |
| Memory | 16 GB active + 16 GB previous or loading + ~0.2 GB overlay, blocklist, caches + process | ~36 GB of 64 GiB |
| Snapshot load | 16 GB from S3 with parallel ranged reads at ~250 MB/s (assumption) | ~64 s |
Server latency budget (trie-served requests; the steps run one after another, so they add)
| Step | P99 ms |
|---|---|
| Parse the request, normalize | 0.2 |
| Trie walk, top 5, overlay read | 0.2 |
| Merge, blocklist check, write JSON | 0.2 |
| Queueing at ≤ 50% CPU | 2.0 |
| Pauses (memory management, scheduling) | 2.0 |
| Total | 4.6, leaving 5.4 ms under 10 |
The speed layer
| Item | Math | Result |
|---|---|---|
| Events | 1B completed searches a day (keystroke requests aren't needed for counting) | 11,574 a second average; × 2.2 ≈ 25,463 at peak |
| Event size | query, normalized key, time, pseudonymous user ID, session, network, client | ~200 B |
| Peak bytes | 25,463 × 200 B | ≈ 5.1 MB/s |
| Kinesis records | 25 events per ~5 KB record: 25,463 ÷ 25 | ≈ 1,019 records a second |
| Shards | 1 MB/s and 1,000 records/s of writes per shard: 5.1 MB/s ÷ 1 MB/s = 5.1 | 10 shards (51% of write capacity at peak) |
| Reads | Flink and Firehose both read all data: 2 × 5.1 MB/s over 10 shards | 1.02 MB/s per shard, under the 2 MB/s limit |
| Flink | 8 KPUs (1 vCPU and 4 GB each; an assumption to confirm by load test) | + 1 KPU per application for orchestration |
| Overlay | ≤ 10,000 trending queries × ~20 prefixes | ~200K entries, ~20 MB |
Fuzzy fallback
| Item | Math | Result |
|---|---|---|
| Fuzzy requests | 3% of origin requests (assumption) | 60K × 0.03 = 1,800 a second at peak, fewer after single-flight and the 60 s cache |
| Cluster | 3 data nodes r7g.xlarge.search (4 vCPU, 32 GiB), one per AZ, each with a full copy; 3 dedicated master nodes m7g.large.search | ~600 a second per data node at peak (to confirm by load test) |
| Index | 20M queries in a completion field | a few GB (assumption) |
Monthly cost (us-east-1 on-demand list prices; check the AWS Pricing Calculator before quoting)
| Line | Math | ≈ Monthly |
|---|---|---|
| CloudFront requests | 40% of 4B a day × 30.4 = 48.64B HTTPS requests × $0.0100 per 10,000 | $48,640 |
| CloudFront data out | 17.0 TB: first 1 TB free, 9 TB × $0.085, 7.0 TB × $0.080 per GB | $1,325 |
| Suggest fleet | 9 × r7g.2xlarge × $0.4284/h × 730 h | $2,815 |
| Internet egress from the region | 25.5 TB: 10 TB × $0.09, 15.5 TB × $0.085 per GB | $2,220 |
| ALB | $16 + ~231 LCUs average × $0.008 × 730 h (new connections dominate: ~5,800 a second ÷ 25 per LCU; assumption) | $1,365 |
| Glue | nightly: daily aggregation ~30 DPU-hours + trie and index build ~100 DPU-hours (assumptions) × $0.44 × 30.4 | $1,740 |
| OpenSearch | 3 × $0.356/h + 3 × $0.135/h, × 730 h, + storage | $1,090 |
| Managed Flink | 9 KPUs × $0.11 × 730 h + 8 × 50 GB running storage × $0.10 | $763 |
| Data Firehose | 1.216B records × 5 KB = 6,080 GB × $0.029 | $176 |
| Kinesis | 10 shards × $0.015 × 730 h = $110; 1.216B PUT units × $0.014 per million = $17 | $127 |
| CloudFront Origin Shield | requests that miss the regional edge caches and reach the Shield: ~1,000 a second on average (assumption) × 2.63M s = 2.63B × $0.0075 per 10,000 | $1,970 |
| S3 | 30 days of compressed logs (~1.2 TB), snapshots, overlays | $40 |
| CloudWatch, AppConfig, Parameter Store | $500 | |
| Total | ≈ $63,000 |
The line that surprised us: CloudFront's request fee. Together with the other CloudFront lines it is about 83% of the bill (the request fee alone is 77%), and the edge doesn't save money anywhere else: serving from our fleet costs a few cents per million requests in server time ($2,815 a month ÷ ~73B origin requests ≈ $0.04 per million), and CloudFront charges $1 per million. So the edge is there for latency and bursts, not for cost, and we buy it only where it helps: the 1–3 letter prefixes whose answers every user shares. If every request went through CloudFront, requests alone would be 121.6B a month × $0.0100 per 10,000 = $121,600, and the total about $136K. At this volume, AWS offers discounted pricing for committed traffic and a Security Savings Bundle (up to 30% off with a one-year commitment); we don't count on either in the estimate. COST 5 · COST 7
Which numbers changed the design? The ~16 GB trie made us plan double memory per server and blue/green loads. The 224-second worst case of the fresh path at the user (step 2.1, including the edge's and the browser's 60-second caches) told us a 60-second TTL fits the 5-minute budget. And the per-request CDN price decided which requests go through the edge.
R2.7 Trade-Offs
Batch + speed layers vs streaming only
| Nightly trie + trending overlay (chosen) | One streaming job maintains everything | |
|---|---|---|
| What the stream job holds | Counts for the last 10 minutes; ≤ 10K trending queries | Counts for 100M queries over 30 days, and every node's top 5 |
| Top-5 maintenance | Recomputed offline, bottom-up | When a query's count drops, its old place in a top 5 may belong to a query we'd have to find by searching the subtree |
| Recovery from a bug | Rebuild from 30 days of logs in S3 | Replay 30 days of stream into the job |
| Freshness | Popular: nightly; trending: ≤ ~4 minutes at the user | Everything fresh |
The streaming-only design (often called Kappa) is simpler on a diagram and harder in practice here: keeping a top 5 at 200M nodes correct under decreasing counts is exactly the problem the offline build avoids.
In-process trie vs Redis
| In each server's memory (chosen) | ElastiCache (Redis OSS or Valkey), prefix → top 5 | |
|---|---|---|
| Latency | Microseconds, no network | A network round trip, ~0.5–1 ms |
| Memory | ~16 GB per server, ×2 during swaps, × 9 servers | The optimistic estimate: 16 GB × 1.3 (allocator overhead) × 2 (primary + replica) ≈ 41.6 GB |
| Swaps | Pointer per server | A second cluster (blue/green), or millions of writes into a live one |
| Servers | Hold state, load ~1 minute | Stateless, fast to add |
The 41.6 GB is optimistic, for two reasons. First, a flat key-value table needs a key for every prefix a user can type, not just the ~200M radix nodes, because it has no tree to tell it that sne ends inside the edge leading to sneakers: probably several hundred million to a billion keys (an estimate). Second, Redis spends several dozen bytes of bookkeeping on each key before the data. Realistically, it's around 100 GB per copy. And ElastiCache reserves 25% of a node's memory by default for background work, so a cache.r7g.xlarge (26.32 GiB) holds about 19.7 GiB of data. The in-process trie wins here on latency and on memory. Redis would win if many different services needed the data, or if the trie were too big for one server (Round 3 shards it instead).
Edge TTL
| TTL | Staleness at the edge | Origin load from short prefixes | Removal without invalidation |
|---|---|---|---|
| 10 s | 10 s | ~6× the 60 s case (still small) | 10 s |
| 60 s (chosen) | ≤ 60 s | ~300 a second through Origin Shield | ≤ 60 s |
| 1 hour | 1 hour: trending breaks | Negligible | 1 hour: unacceptable |
Because CloudFront charges per request whether it hits or misses, a longer TTL doesn't lower the CDN bill; it only lowers origin load, which is small already. So we pick the TTL for freshness.
Top-k per node vs searching at request time
| Top-5 at every node (chosen) | Search the subtree per request | |
|---|---|---|
| Memory | 40 B × 200M = 8 GB for the lists | 0 |
| Work per request | One read | Grows with the subtree: millions of nodes for one letter |
| Ranking changes | Need a rebuild | Immediate |
8 GB of memory buys constant-time answers. Ranking experiments that need a different order are Round 3's problem.
Our consistency stance. Different servers can serve different snapshots or overlays for a minute or two during swaps, and the edge can be 60 seconds behind. Suggestions are hints, so this is fine. The one thing that must be immediate is removal, which is why the blocklist is checked at serve time and pushed separately.
R2.8 Failure Modes
| Trigger | What you'd see | How the design responds | Drill |
|---|---|---|---|
| A viral prefix stampede | A new phrase, not in the trie, typed by 50,000 people in a minute. | Edge: request collapsing and Origin Shield collapse short prefixes. Origin: single-flight per server means at most 9 concurrent fuzzy calls for the same prefix; the 60 s answer cache absorbs the rest. Within ~3 minutes the phrase is in the overlay and no longer needs the fallback. REL 5 | The product page that melted Redis |
| A bad snapshot | Validation or canary fails. | Nothing reaches the fleet; last night's snapshot keeps serving; alarm. If something slips through, set the pointer back: rollback takes one poll interval, because the old copy is still in memory for 2 hours. REL 8 | – |
| The speed layer lags | Flink falls behind; overlay age grows. | Servers keep the last overlay but ignore it once it is older than 15 minutes, so a stale "trending" item doesn't linger; alarm on overlay age > 3 minutes. Popular suggestions from the nightly trie are unaffected. | – |
| The fuzzy backend is slow | OpenSearch latency rises from 10 ms to seconds. | The 30 ms budget caps each wait, the bulkhead caps threads (64 per server), and the breaker opens: typos get an empty list, everything else is untouched. | The slow recommendation service that took down checkout |
| Losing an AZ | 3 of 9 servers and one OpenSearch node gone. | The ALB stops sending to them; 6 servers carry 60K a second at 10K each (our planned maximum). OpenSearch still has two full copies. The build and stream services are regional and span AZs. REL 10 | – |
| An urgent removal | A blocked phrase is still showing. | AppConfig emergency list reaches servers within about 15–30 s; CloudFront invalidation for affected short prefixes; the next overlay and snapshot are filtered too. | – |
How the stampede drill applies here. The drill asks what happens when a cache restarts empty and the first wave all misses at once. Our servers hold no cache that starts empty (they load the whole snapshot before taking traffic), but the edge and the fuzzy-answer cache do start empty. The first wave at each edge location is collapsed into one request, Origin Shield collapses edge locations into one, and single-flight collapses concurrent fallback calls. The drill's second question, why not cache forever and skip TTLs, has a direct answer here: trending terms must appear within minutes and removed terms must disappear within minutes, so our edge answers live 60 seconds.
R2.9 Production Gotchas
1. Traversing the trie on every keystroke
- Symptom: CPU climbs with the size of the data; short prefixes are the slowest requests.
- Cause: searching the subtree and sorting on each request.
- Fix: top-k stored at every node (step 1.2), computed in the build.
2. No edge caching for short prefixes
- Symptom: far-away users see suggestions late for the first letters; bursts on one letter hit the origin directly.
- Cause: every request, including the ~18,000 shared short prefixes, crosses to the region.
- Fix: CloudFront for 1–3 letters with a 60 s TTL, request collapsing and Origin Shield (step 2.2).
3. No normalization
- Symptom: a popular query is missing from the top 5; the trie is bigger than expected.
- Cause: case, accent and width variants counted separately.
- Fix: one versioned normalization function for counting and lookup; display the most common spelling (step 2.6).
4. A Bloom filter as the final blocklist
- Symptom: some harmless suggestions never appear, and nobody knows which.
- Cause: the filter's false positives are treated as "blocked".
- Fix: confirm every Bloom hit with an exact check, or skip the filter (step 2.5).
5. One P99 for two paths
- Symptom: P99 alarms fire whenever typo traffic rises, though the trie path is fine.
- Cause: 3% of requests take the 30 ms fallback, so they own the 99th percentile.
- Fix: separate targets and alarms for trie-served and fuzzy requests (step 2.4).
R2.10 Pillar Check
| Pillar | What Round 2 adds |
|---|---|
| Reliability | Blue/green snapshot deploys with validation, canary and a 2-hour in-memory rollback; a fuzzy fallback behind a time budget, a bulkhead and a circuit breaker; stale overlays ignored; 9 servers sized for an AZ loss. REL 5 · REL 8 · REL 10 |
| Performance Efficiency | Short prefixes at the edge; everything else from local memory in ~5 ms at P99; a server latency budget; request collapsing and single-flight for bursts. PERF 3 · PERF 4 |
| Security | Admin API limited to trust-and-safety reviewers, with every change tied to a person and a ticket; blocked suggestions filtered at build and serve time; manipulation detected by source diversity and behavior, not only volume. SEC 3 · SEC 4 |
| Cost Optimization | ≈ $63K a month derived; CloudFront found to be about 80% of it; the edge used only where it helps; commitment options named, not assumed. COST 5 · COST 7 |
| Operational Excellence | Canary snapshots and pointer rollback; alarms on overlay age, snapshot age, fallback rate and blocked-term hits (below). OPS 6 · OPS 8 |
| Sustainability | Light this round: compute each answer once a night instead of once per request; 30-day raw-log retention; the fuzzy index covers the 20M queries worth fixing, not all 100M. SUS 3 · SUS 4 |
Operations in detail.
| Alarm | Threshold | Severity | First action |
|---|---|---|---|
| Trie-served P99 | > 10 ms for 5 min | P2 | CPU per server; is a snapshot loading? |
| Fuzzy share of origin requests | > 6% for 15 min | P2 | Is the active snapshot missing queries? Compare with yesterday's |
| Overlay age | > 3 min | P2 | Flink health and lag |
| Snapshot age | > 36 h | P2 | Did the build or validation fail? |
| Blocked candidates removed at serve time | 3× baseline | P3 | Something the build should have removed is popular; review |
| Empty-answer rate | 2× baseline | P2 | Normalization or snapshot mismatch |
R2.11 Round 2 Rubric and Follow-Ups
What a strong senior (L6) answer adds over L5
- Separates a batch layer from a speed layer, and gives the freshness budget as a sum of steps.
- Merges two sources with a common scoring unit, and makes replays harmless.
- Knows why the edge helps (latency, bursts) and what it costs (per-request fees, staleness).
- Deploys a large in-memory structure safely: validation, canary, pointer flip, rollback.
- Adds a fallback without letting it hurt the main path: budget, bulkhead, breaker, separate latency targets.
- Treats safety as a serve-time guarantee, not only a build step, and knows Bloom filters can't be the last check.
- Normalizes the same way everywhere, and versions it.
Follow-up questions
-
"Why count distinct users rather than searches for trending?" Answer: a search count measures activity, and one script can produce any amount of it. Distinct users measure how many people care. We count each pseudonymous user once per query per window, so 10,000 searches by one bot add 1. It doesn't stop many accounts, which is why we also look at network and behavior diversity.
-
"A trending item is wrong: it was a bot campaign we missed. How fast can we remove it, everywhere?" Answer: the reviewer adds a blocked-term entry marked urgent. AppConfig delivers the emergency list within about 15–30 seconds, and every server filters it from every source at serve time. The admin tool invalidates the affected short prefixes at CloudFront. The next overlay (within a minute) and next snapshot won't contain it. Browsers may hold a copy for their cache time (60 s for these answers).
-
"Why not have the client call OpenSearch directly when the list is empty?" Answer: then OpenSearch is exposed to the internet, every client needs its own budget and backoff logic, and we lose single-flight, the answer cache and the serve-time blocklist. The server is the one place where we can bound the fallback's load and filter its output.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Rebuild the trie every 5 minutes" | Reading 30 days of logs and reloading 16 GB takes far longer than 5 minutes. |
| "A Bloom filter is our blocklist" | False positives silently block legitimate suggestions. |
| "The CDN will save us money" | CloudFront bills per request; the edge buys latency and burst protection. |
| "Swap the snapshot in place" | Minutes of a half-loaded structure; no rollback. |
| "One P99 across trie and fuzzy requests" | The slower path owns the percentile when it's more than 1% of traffic. |
| "Lowercase is normalization" | Accents, width, ß and locale rules still split counts. |
Round 3 · Architect · "Every Language, Personal, Private and Global"
~45 min · Principal (L7) · 4 regions, 3 AZs each, + edge · ~5B searches/day · 40 locales, ~98 GB of tries · server P99 < 10 ms in every region · 99.99% per region
R3.0 Where We Left Off
What the candidate says in the first 60 seconds of Round 3, and everything you need if you start here.
Round 2 in 60 seconds. "We run autocomplete for a web search engine in one region: a billion searches a day, 100K requests a second at peak, US English. A nightly Glue build turns 30 days of logs into a 16 GB radix trie with the top 5 stored at every node, validates it, canaries it, and nine
r7g.2xlargeservers load it beside the old one and swap a pointer set in Parameter Store; the old copy stays two hours for rollback. A speed layer (Kinesis into a Flink job with a 10-minute sliding window over distinct users) publishes a trending overlay every minute, and servers merge it with the trie by searches per day: trending within about four minutes at the user, worst case. Prefixes of 1–3 letters are served by CloudFront with a 60-second TTL, request collapsing and Origin Shield. Typos fall back to an OpenSearch completion index with fuzzy matching, behind a 30 ms budget, a bulkhead and a circuit breaker. Every candidate is checked against an exact blocklist, with an emergency list pushed by AppConfig. One normalization function, versioned, for counting and lookup. About $63K a month, about 80% of it CloudFront. Open costs: one language, one region, nobody's personal history, raw logs full of personal data, and no way to test a new ranker safely."
Architecture v2, compact
Synthesizing vector architecture diagram...
Round 2 in one picture: the edge answers what everyone shares, each server answers the rest from memory, and two offline paths (minute-by-minute and nightly) keep that memory fresh.
Rounds 1–2 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | Slower as data grows | Radix tree | Still need the top 5 |
| 1.2 | Top 5 means the subtree | Top-k at every node | Memory; rebuilds |
| 1.3 | Counts change all day | Offline build → snapshot | Staleness |
| 1.4 | Where the trie lives | In every server's memory | Load time |
| 1.5 | Too many requests | Debounce, cancel, drop stale | ~100 ms after a pause |
| 2.1 | News takes a day | Speed layer: trending overlay | Two sources to merge |
| 2.2 | Short prefixes dominate | CloudFront, 60 s, collapsing | Edge staleness; request fees |
| 2.3 | Big swaps fail | Blue/green, validate, canary | Double memory |
| 2.4 | Typos | Fuzzy fallback with budget and breaker | Second system |
| 2.5 | Offensive trends | Exact serve-time blocklist; source checks | Held-back terms |
| 2.6 | Split counts | Versioned normalization | Locale rules |
Open costs: one language and one trie on every server; no personal signal; raw logs with user IDs; one region; no safe way to compare rankers.
R3.1 The Scope Raise
Interviewer: "We're launching worldwide: 40 languages and locales, including Japanese, Chinese, Korean and Thai. About 5 billion searches a day. Suggestions should reflect your recent searches, for people who want that. Our privacy team says query logs are personal data: they need retention limits and deletion on request, and nothing personal can leak into suggestions. Users everywhere need the same speed: under 10 ms on our servers, from a region near them. And the product team wants to A/B test new rankers."
We ask back, and say what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How is the traffic spread? | Americas 30%, Europe and Africa 25%, East Asia 25%, South and Southeast Asia and Oceania 20%. Still ~4 requests per search. | Four regions, each sized for its own peak (R3.6). |
| How big is the biggest language now? | English keeps 200M queries; Japanese and Chinese about 30M each, with most typing done through input methods. | English alone is a 32 GB trie: too big for our 64 GiB servers with a swap copy (step 3.2). Japanese and Chinese need their readings indexed (step 3.1). |
| What does "your recent searches" mean, and for whom? | For signed-in users who keep history on: their own recent searches that match what they type, shown first. They can turn it off or delete it. | A per-user history store, a blend at query time, an opt-out and a deletion API (step 3.3). |
| What must the privacy design guarantee? | Raw logs kept at most 30 days. A deleted history is gone from what we show at once and from raw logs within the retention window. No suggestion can come from only a handful of people. | Aggregate early, count distinct users with a minimum threshold, keep identifiers out of everything that lives longer than 30 days (step 3.4). |
| Can user data leave the region it was collected in? | Keep personal data in the region where it was collected. Aggregates without identifiers can move. | Each region counts its own logs; only thresholded aggregates go to the central build (steps 3.4 and 3.5). |
| What do experiments need? | A few percent of users on a new ranker, compared on real metrics, stopped automatically if it hurts. | Assignment, a cache key that respects it, metrics and guardrails (step 3.6). |
Scope change
| Round 2 | Round 3 | |
|---|---|---|
| Searches | 1B/day | ~5B/day |
| Requests | 100K/s peak | ~510K/s summed over regional peaks (231K/s average worldwide) |
| Languages | US English | 40 locales, 4 scripts that need special input handling |
| Data | 100M queries, 16 GB | ~560M queries, ~98 GB of tries; per-user history |
| Footprint | 1 region + edge | 4 regions + edge |
| Latency | Server P99 < 10 ms | Server P99 < 10 ms in every region |
| Availability | 99.99% | 99.99% per region; a region's users move to another region if it fails |
| New | – | Per-locale tries, sharding, personal history, privacy rules, experiments |
R3.2 What Breaks in the Round 2 Design
| Round 2 choice | What breaks at the new scope |
|---|---|
| One trie, English normalization | A Japanese user types in hiragana through an input method; our English trie has nothing, and accent stripping is wrong for many languages. |
| The whole trie on every server | 40 locales are ~98 GB; with a swap copy, ~196 GB per server. And a bad snapshot for one locale would reach every server. |
| No per-user signal | Everyone sees the same list, even for a prefix the user searched yesterday. |
| Raw logs with user IDs, kept for the build | Personal data kept as long as the pipeline wants it, flowing wherever the build runs. |
| One region | Tokyo users pay ~150 ms of round trip before our 10 ms even starts, and a regional outage takes suggestions away worldwide. |
| One ranker, no experiment path | A ranking change ships to everyone and can only be judged after the fact. |
R3.3 New Requirements and API Additions
Locale detection. locale is optional now. The router picks it in this order: the locale parameter (the user's setting) → the site or app language → the Accept-Language header → the script of what was typed (hiragana means Japanese). The answer says what it chose. Requests to the edge hostname always carry a resolved locale: the client resolves it (from the user's setting, the app language, the browser language and the script typed) before sending, because locale is in the CDN cache key and Accept-Language is not. Otherwise a Japanese and an English user asking for the same short prefix would share one cached answer. (A CloudFront Function could do the same by mapping Accept-Language to a locale parameter at the edge.) The router's own detection only fills in locale for origin requests that arrive without one:
httpGET /v1/suggest?q=%E3%81%A8%E3%81%86%E3%81%8D%E3%82%87 HTTP/1.1 Host: suggest.example.com Accept-Language: ja-JP,ja;q=0.9 X-Exp: ranker=control
httpHTTP/1.1 200 OK Content-Type: application/json Cache-Control: private, max-age=60 { "prefix": "とうきょ", "locale": "ja-JP", "locale_source": "ACCEPT_LANGUAGE", "snapshot": "2026-09-27", "suggestions": [ { "text": "東京 天気", "type": "POPULAR" }, { "text": "東京駅", "type": "POPULAR" }, { "text": "東京タワー", "type": "POPULAR" }, { "text": "東京都", "type": "POPULAR" }, { "text": "東京 ホテル", "type": "POPULAR" } ] }
The session call: history, blend rules and experiment assignment. When the search box gets focus, a signed-in client makes one call:
httpGET /v1/session HTTP/1.1 Host: suggest.example.com Authorization: Bearer <user token>
httpHTTP/1.1 200 OK Content-Type: application/json Cache-Control: private, no-store { "personalization": "ON", "history": [ { "id": "h_9f2a", "text": "tokyo marathon results", "at": "2026-09-26T21:14:03Z" }, { "id": "h_77c1", "text": "tomato soup recipe", "at": "2026-09-25T18:02:40Z" } ], "blend": { "max_history": 2, "history_first": true }, "experiments": { "ranker": "control" } }
Opt-out and deletion
httpPUT /v1/settings/personalization HTTP/1.1 Authorization: Bearer <user token> Content-Type: application/json { "enabled": false, "delete_existing": true }
httpDELETE /v1/history HTTP/1.1 Authorization: Bearer <user token>
httpHTTP/1.1 202 Accepted Content-Type: application/json { "cleared_at": "2026-09-27T10:41:07Z", "raw_logs_deleted_by": "2026-10-29" }
DELETE /v1/history/{id} removes one entry. The answer states both deadlines honestly: history is gone from suggestions now on this device and in every future session call; another device that is signed in keeps its session copy until its next session call (the next time its search box gets focus); raw logs age out by a stated date (step 3.4).
The experiment header. X-Exp: ranker=<variant> comes from the session call (or, for signed-out users, from a hash of a device cookie). The client sends it on every suggest request, and the CDN includes it in the cache key along with q and locale, so users in different variants never share a cached answer.
R3.4 Design Evolution: Languages, People, Privacy and Regions
Step 3.1: 40 Languages
The problem: we add 39 locales. A Japanese user types とうき in an input method, meaning to reach 東京 天気 (Tokyo weather). A Chinese user types beij in pinyin, meaning 北京 (Beijing). A German user types strasse.
What would you do?
Synthesizing vector architecture diagram...
For Japanese, the prefix the user sees and the prefix we look up can differ: kana typed through an input method goes straight to the reading index; Latin letters are converted to kana first, and also tried against English.
Step 3.2: The Trie Is Too Big for One Server in Big Locales
The problem: English is 32 GB and growing about 30% a year. With a swap copy, that's 64 GB: it no longer fits our 64 GiB servers. All 40 locales together are ~98 GB, ~196 GB during a swap. What would you do?
Primitive: Database Sharding & Partition Keys · Drill: The search bar that stalled on every keystroke (answered in step 1.2 and R1.8)
Step 3.3: Show My Own Recent Searches First
The problem: a user searched tokyo marathon results yesterday. Today they type to, and the global list shows today's weather, translate, toyota.
What would you do? Keep the 10 ms and the edge cache.
The history item
| Attribute | Example | Notes |
|---|---|---|
user_id (partition key) | u_4f1c… | The account ID; home region encoded in it |
searched_at (sort key) | 2026-09-26T21:14:03.118Z#h_9f2a | Time, plus an entry ID so two searches in the same millisecond don't collide |
text | tokyo marathon results | As displayed |
norm | tokyo marathon results | Normalized key, for matching prefixes |
expires_at | 90 days after searched_at | DynamoDB TTL attribute (see step 3.4 on what TTL does and doesn't promise) |
The session call is a Query on user_id, newest first, Limit 50: about 7.5 KB, one read request unit with eventually consistent reads.
Step 3.4: Query Logs Are Personal Data
The problem: raw search logs hold what people typed and who typed it: health questions, names, addresses. We keep them for the build, in every region, for as long as the pipeline finds useful. And a query searched by three people could become a suggestion that reveals something about them. What would you do?
Synthesizing vector architecture diagram...
Personal data never leaves the region and never lives past 30 days in the pipeline. What crosses the boundary is a count per query that at least 50 people searched. History is a separate store the user controls.
Step 3.5: Users Are Far From Our Region
The problem: a user in Tokyo types とうきょ. The request crosses the Pacific to us-east-1: about 150 ms of round trip before our 10 ms of work. The edge helps only for 1–3 letter prefixes. And if us-east-1 has a bad day, nobody in the world gets suggestions.
What would you do?
Primitive: Cloud Disaster Recovery & Multi-Region Active-Active
Step 3.6: Test a New Ranker Safely
The problem: the ranking team has a new scoring function that mixes recency into popularity. They believe it's better. If they're wrong, a billion searches a day get worse suggestions. What would you do?
Round 3 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 3.1 | 40 languages | Per-locale tries and normalization; reading indexes; romaji and jamo handling | 40 builds; memory |
| 3.2 | Too big for one server | Shards by locale, English by prefix range; routers; more replicas for hot shards | Router hop; min 3 servers per shard |
| 3.3 | Personal history | History in DynamoDB, fetched once per session, blended on the client | Session-stale history; client logic |
| 3.4 | Logs are personal data | Pseudonymous IDs; 30-day raw logs in-region; ≥ 50 distinct users; only aggregates move | Rare queries never appear |
| 3.5 | Users far away | Four regions, latency routing, central build, S3 Cross-Region Replication | Four fleets; replication |
| 3.6 | Test rankers safely | Hash-bucket assignment, variant snapshots, cache key, guardrails | Serving complexity |
R3.5 Global Architecture
Synthesizing vector architecture diagram...
Users reach the nearest healthy region. Counts flow in to one build (no identifiers), and snapshots flow back out to every region.
Synthesizing vector architecture diagram...
Inside one region: routers do everything that doesn't need a trie, and each shard server holds one shard's trie. The overlay now lives in the routers, because it is small and every request needs it.
Tracing a Japanese query (a user in Osaka types とうきょ: four characters, so it goes to the region, not the edge; the 1–3 rule counts characters as they are in the box, kana included)
Synthesizing vector architecture diagram...
The user typed kana; the answer is in kanji, because the reading index maps one to the other.
Tracing a personalized result
Synthesizing vector architecture diagram...
One history read per session. Every keystroke after that is a shared, cacheable request, blended on the device.
Tracing a deletion request
Synthesizing vector architecture diagram...
History disappears from suggestions at once on this device, and on the user's other devices at their next session call. Raw logs age out by their stated date; aggregates never held the user's ID.
R3.6 Numbers and Cost
Traffic by region (shares from R3.1; 2.2× local daily peak; 40% of requests served by the edge)
| Region | Share | Average/s | Peak/s | Origin peak/s |
|---|---|---|---|---|
| us-east-1 | 30% | 69,444 | 152,778 | 91,667 |
| eu-west-1 | 25% | 57,870 | 127,315 | 76,389 |
| ap-northeast-1 | 25% | 57,870 | 127,315 | 76,389 |
| ap-southeast-1 | 20% | 46,296 | 101,852 | 61,111 |
| World | 100% | 231,481 (5B × 4 ÷ 86,400) | ~509K summed (peaks don't coincide) | ~306K summed |
Memory per locale (160 B per query = Round 2's 16 GB ÷ 100M; reading indexes add ~2 nodes × 70 B = 140 B per query; all assumptions)
| Group | Locales | Queries kept | Bytes per query | Trie each | Total |
|---|---|---|---|---|---|
| English (one trie for all English markets) | 1 | 200M | 160 B | 32 GB | 32 GB |
| Japanese, Chinese | 2 | 30M each | 300 B | 9 GB | 18 GB |
| Other large: ko, es, pt, de, fr, ru, hi | 7 | 30M each | 160 B | 4.8 GB | 33.6 GB |
| Small | 30 | 3M each | 160 B | 0.48 GB | 14.4 GB |
| All | 40 | 560M | 98 GB |
Shards (each ≤ 24 GB, so a 64 GiB r7g.2xlarge holds it plus a swap copy: 48 GB + the process)
| Shard | Holds | Size |
|---|---|---|
| en-A, en-B | English, split by prefix range at a load-based boundary | ~16 GB each today (32 GB total), each capped at 24 GB |
| G1 | es, pt, de | 14.4 GB |
| G2 | fr, ru, hi | 14.4 GB |
| G3 | ja, ko | 13.8 GB |
| G4 | zh | 9.0 GB |
| G5 | 30 small locales | 14.4 GB |
Shard servers per region. Same rule as Round 2: a server's planned maximum is 10K requests/s (50% CPU), and a shard must survive losing an AZ, so it needs load ÷ 10K servers in two AZs, with at least one per AZ. Worked for us-east-1 (English is 75% of its origin traffic, split evenly by the load-based boundary):
| Shard | Share of origin | Peak load | Servers |
|---|---|---|---|
| en-A | 37.5% | 34,375/s | 2 per AZ = 6 (after an AZ loss, 4 × 10K = 40K ≥ 34.4K) |
| en-B | 37.5% | 34,375/s | 6 |
| G1 | 12% | 11,000/s | 3 (2 × 10K = 20K ≥ 11K) |
| G2, G3, G4, G5 | 5%, 3%, 3%, 2% | ≤ 4,583/s | 3 each, the minimum |
| Total | 91,667/s | 27 |
| Shard | us-east-1 | eu-west-1 | ap-northeast-1 | ap-southeast-1 |
|---|---|---|---|---|
| en-A / en-B | 6 / 6 | 3 / 3 | 3 / 3 | 3 / 3 |
| G1 (es, pt, de) | 3 | 6 (30% of eu: 22,917/s) | 3 | 3 |
| G2 (fr, ru, hi) | 3 | 3 (25%: 19,097/s) | 3 | 3 (20%: 12,222/s) |
| G3 (ja, ko) | 3 | 3 | 9 (60%: 45,833/s) | 3 |
| G4 (zh) | 3 | 3 | 3 (20%: 15,278/s) | 3 |
| G5 (small) | 3 | 3 | 3 | 3 (15%: 9,167/s) |
| Servers | 27 | 24 | 27 | 21 |
99 shard servers worldwide. Of them, 21 × 4 = 84 would exist even with almost no traffic: 7 shards × 3 AZs in each region. That's the fixed cost of holding every locale everywhere.
Routers. Same planned maximum, 10K/s per c7g.2xlarge, sized for an AZ loss: us-east-1 91,667 ÷ 10K → 10 needed in two AZs → 15; eu-west-1 and ap-northeast-1 76,389 → 8 → 12 each; ap-southeast-1 61,111 → 7 → 12 (9 would leave only 60K after an AZ loss). 51 routers.
Server latency budget (measured at the router, P99; parallel steps take the maximum, dependent steps add)
| Step | ms |
|---|---|
| Parse, pick locale, normalize, shard map | 0.3 |
| In parallel: shard call (in-AZ network + lookup + queueing on the shard, 3.0) · overlay read (0.05) · English lookup for Latin input on a ja query (3.0) | max = 3.0 |
| Merge, blocklist, experiment, JSON | 0.3 |
| Queueing on the router at ≤ 50% CPU | 2.0 |
| Pauses | 2.0 |
| Total | 7.6, leaving 2.4 ms under 10 |
If a shard call hasn't answered after about its P95 (2 ms), the router sends the same call to another server of that shard and takes the first answer. A typical hedged request costs 0.3 + (2 + 1) + 0.3 + 2 + 2 = 7.6 ms, the same as the budget. This isn't a hard worst case (if the second call also hits its own P99 tail, the request can pass 10 ms); hedging makes that tail much rarer, which is all a P99 target needs. Romaji's five kana lookups go to one shard in one call. The history read is not in this budget at all: it happens once per session, before typing.
Personalization store
| Item | Math | Result |
|---|---|---|
| Searches with history on | 20% of 5B (assumption) | 1B a day |
| Writes | 1B × 1 WRU (item < 1 KB) × 30.4 × $0.625 per million | $19,000 a month |
| Session reads | 5B searches ÷ 2.5 per session × 20% = 400M a day × 1 RRU × 30.4 × $0.125 per million | $1,520 a month |
| Storage | 1B new items a day kept 90 days (TTL) = ~90B items × 150 B = 13.5 TB × $0.25 per GB-month | $3,375 a month |
| If read on every keystroke instead | 20% of 20B requests = 4B reads a day × 30.4 × $0.125 per million | $15,200 a month, plus a network read in every request's 10 ms, and no edge caching for those users |
Snapshot replication: ~98 GB a night × 3 destination regions = 294 GB a day × 30.4 × $0.02/GB inter-region transfer ≈ $179 a month. The data is small next to the serving fleet; the reason to replicate is load speed and independence, not cost.
Monthly cost (us-east-1 list prices for every region, so real costs in the other regions are somewhat higher; check the AWS Pricing Calculator before quoting)
| Line | Math | ≈ Monthly |
|---|---|---|
| CloudFront requests | 40% of 20B a day × 30.4 = 243.2B: North America (25% of traffic, an assumption) 60.8B × $0.0100; South America (5%) 12.16B × $0.0220; everywhere else (70%) 170.24B × $0.0120 per 10,000 (Australia's $0.0125 ignored) | $292K |
| CloudFront data out | 85 TB at $0.08–0.12 per GB depending on the edge region (rough) | $8K |
| Shard servers | 99 × r7g.2xlarge × $0.4284 × 730 h | $31.0K |
| Personalization (DynamoDB) | above: $19,000 + $1,520 + $3,375 | $23.9K |
| Internet egress from regions | ~128 TB at $0.085–0.12 per GB (rough) | $12K |
| Routers | 51 × c7g.2xlarge × $0.29 × 730 h | $10.8K |
| Glue | 4 regional aggregations (~150 DPU-hours a day) + central build (~560) × $0.44 × 30.4 | $9.5K |
| ALB | Round 2's $1.4K scaled by 5× the origin traffic, 4 load balancers | $6.9K |
| OpenSearch | 4 regional clusters like Round 2's | $4.4K |
| Managed Flink | 40 KPUs + 4 orchestration × $0.11 × 730 h + storage | $3.7K |
| Kinesis + Data Firehose | 5× Round 2's volume | $1.5K |
| S3 (logs, snapshots) + replication | $0.7K | |
| CloudWatch, AppConfig, KMS, misc. | $3K | |
| Total | ≈ $407K |
That's about $0.67 per million suggest requests (608B requests a month), of which $0.48 is CloudFront's request fee. The design's own machinery (shards, routers, pipelines, fuzzy clusters) is about $60K. At this volume, the CDN contract matters more than any server choice. COST 1
Which numbers changed the design? English's 32 GB, not the 98 GB total, forced sharding. The minimum of 3 servers per shard per region made us pack small locales together (30 shards of 0.48 GB would need 90 servers per region). The per-keystroke history read ($15K a month and no edge caching) moved history to once per session. And the CDN's per-request price is again the biggest line.
R3.7 Trade-Offs
Personalization vs latency
| Server reads history per keystroke | History once per session, blended on the client (chosen) | History only on the device | |
|---|---|---|---|
| Reads | ~4B a day | ~400M a day | 0 |
| Hot path | A network read inside 10 ms | Nothing added | Nothing added |
| Edge caching | Lost for signed-in users | Kept | Kept |
| Across devices | Yes, instantly | Yes, at the next focus | No |
| Ranking control | Server | Server-sent blend rules | Client only |
Privacy threshold vs coverage
| Threshold (distinct users in 30 days, per region) | Protects against | Loses |
|---|---|---|
| 10 | Suggestions from one or two people | Little; but a small group can still surface something about itself |
| 50 (chosen) | Small groups surfacing private searches | Niche queries; queries spread thinly across regions |
| 500 | Coordinated small campaigns, too | A large share of the long tail; suggestions get generic |
Shard layouts
| Layout | Fits memory | Blast radius of a bad build | Servers for cold locales | Verdict |
|---|---|---|---|---|
| Every locale on every server | Needs ~256 GiB servers | Every server | Carried everywhere | Simplest; couples everything |
| One shard per locale | Yes, except English | One locale | 3 per locale per region: 90 servers for the 30 small ones | Wasteful |
| Locale groups + English by range (chosen) | Yes | One group | 3 per group | Our choice |
| Hash of prefix | Yes | Everywhere | – | Breaks subtrees; a flat table of all prefixes |
Central build vs a build per region
| Central build, replicate snapshots (chosen) | Each region builds its own | |
|---|---|---|
| Input | Thresholded counts from all regions | Its own raw logs |
| Result | The same tries everywhere; global popularity | Tries tuned to each region's habits |
| Cost | One build, ~294 GB replicated a night | Four builds |
| Failure | Central build down: every region keeps last night's snapshot | Independent |
A per-region trie is a reasonable later step for locales whose regions search very differently (English in India vs the US). We'd start central, because it's one pipeline to operate.
Closing the loop. The question was: how do we answer every keystroke in milliseconds with the best, freshest and safest completions?
- Milliseconds: everything expensive is precomputed. The answer for any prefix is one walk and one read, in memory, on a server in the user's region, or already cached at the edge.
- Best: popularity counted over 30 days, normalized per locale, blended with your own history, and improved only by experiments that prove it.
- Freshest: a nightly build for what's popular, a minute-by-minute overlay for what's trending, and a 60-second edge TTL that fits inside the 5-minute budget.
- Safest: filtered at build time and checked exactly at serve time, with an emergency push; manipulation met with distinct-user and source checks; and privacy built into the pipeline: aggregate early, threshold, and keep identifiers local and short-lived.
R3.8 Failure Modes
| Trigger | What you'd see | How the design responds |
|---|---|---|
| A region outage | ap-northeast-1 fails its health checks. | Route 53 stops returning it; users go to the next-lowest-latency healthy region (mostly ap-southeast-1 and us-east-1). Those regions hold every locale, so Japanese still works, over a longer network path. We keep no idle spare region; their fleets scale out, and a new shard server takes a few minutes to be ready (boot, then about a minute to load its snapshot), so expect a few minutes of higher latency until it catches up. Peaks in different regions rarely coincide, which helps. Home-region history for those users is unavailable, so they see global lists. The edge keeps serving short prefixes. REL 10 · REL 13 |
| A bad snapshot in one locale | The ja-JP build dropped the reading index; とうき returns nothing. | Validation catches most of this (golden prefixes per locale, including kana prefixes). If it slips through, only the G3 shard is affected: set G3's pointer back in each region; the other six shards never loaded anything. |
| The personalization store is down | Session calls fail or time out. | The client shows the global list without history. Suggest requests don't depend on the store, so nothing else changes. |
| An experiment hurts metrics | Treatment acceptance drops 3%, or its P99 rises. | The hourly guardrail job sets its share to zero; AppConfig's alarm-based rollback covers bad config deployments. Treatment users return to control within a session. |
| A router has a stale shard map | After a rebalance, a router sends a prefix to a shard that no longer holds it. | The shard server answers "not my range" with the shard-map version it has; the router reloads the map and retries once on the right shard. Routers also compare the map version on every snapshot flip. |
| Replication to one region is late | eu-west-1 hasn't received tonight's G1 files by morning. | Its deploy job doesn't flip G1 until every file has arrived and matches the manifest; eu-west-1 serves yesterday's G1, alarm on snapshot age. |
R3.9 Runbook
Golden signals, per region and per locale OPS 8 · REL 6
| Signal | Alarm | Severity | First action |
|---|---|---|---|
| Trie-served P99, by region and locale | > 10 ms for 5 min | P2 | Which shard? CPU, a snapshot load in progress, hedging rate |
| Fuzzy P99 and fallback rate, by locale | P99 > 40 ms; rate 2× baseline | P2 | A locale's snapshot missing queries? OpenSearch health |
| Edge hit ratio for 1–3 letter requests | < 90% for 15 min | P3 | Cache key change? A new experiment variant fragmenting it? |
| Snapshot age, per shard per region | > 36 h | P2 | Build, validation or replication failed? |
| Trending lag (overlay age) | > 3 min | P2 | Flink health in that region |
| Blocked candidates removed at serve time | 3× baseline | P3 | Something popular that the build should have caught |
| Empty-answer rate, per locale | 2× baseline | P2 | Normalization version mismatch? Bad snapshot? |
| Session-call error rate | > 1% for 10 min | P3 | DynamoDB health in the home region |
Procedure: snapshot cutover for one shard in one region
- Confirm the replicated manifest and files are present, and checksums match.
- Load the new snapshot on one canary server per AZ beside the current one; compare latency and empty-answer rate on replayed traffic for 10 minutes.
- Set the shard's pointer parameter; servers load and swap a few at a time.
- Watch P99, empty-answer rate and blocked-candidate rate for that shard for 30 minutes.
Procedure: rollback (within 2 hours of a flip)
- Set the shard's pointer back to the previous version. Servers swap back at their next poll (≤ 30 s), with no loading.
- After 2 hours the old copy is freed; rolling back further means a normal cutover of an older snapshot.
Checked CLI commands, in order: see which snapshot each shard in Tokyo serves; roll the G3 shard back; push an urgent blocklist version to one region; remove two cached short-prefix answers from the edge; confirm a replicated manifest arrived (the response shows ReplicationStatus: REPLICA).
textaws ssm get-parameters-by-path --path /suggest/prod/snapshot --recursive --region ap-northeast-1 aws ssm put-parameter --name /suggest/prod/snapshot/g3 --value 2026-09-26 --type String --overwrite --region ap-northeast-1 aws appconfig start-deployment --application-id a1b2c3d --environment-id e4f5g6h --configuration-profile-id p7q8r9s --configuration-version 4128 --deployment-strategy-id AppConfig.AllAtOnce --region eu-west-1 aws cloudfront create-invalidation --distribution-id E2EXAMPLE1 --paths "/v1/suggest?q=ba&locale=en-US" "/v1/suggest?q=bad&locale=en-US" aws s3api head-object --bucket suggest-snapshots-apne1 --key g3/2026-09-27/manifest.json --region ap-northeast-1
For the invalidation, the client always sends q before locale, so the path we invalidate is exactly the path that was cached.
Game days. Monthly: fail a region's health checks on purpose during business hours and watch the neighbors absorb it; flip a canary to a deliberately broken snapshot and check validation stops it. Quarterly: run a history deletion end to end and check nothing of the user's appears anywhere afterwards. REL 12
R3.10 Pillar Check
| Pillar | What Round 3 adds |
|---|---|
| Reliability | Four regions with latency routing and health checks; shards that fail and roll back independently; personalization and experiments that degrade to the global list; replicated snapshots checked before any flip; game days. REL 10 · REL 12 · REL 13 |
| Performance Efficiency | A region near every user; per-locale tries with reading indexes; a 7.6 ms budget with parallel steps taking the maximum; hedged shard calls; history kept off the keystroke path. PERF 3 · PERF 4 |
| Security | Query logs classified as personal data; pseudonymous IDs with a KMS-held key; raw logs readable only by the aggregation role; data encrypted at rest in S3 and DynamoDB; deletion API scoped to the signed-in user. SEC 3 · SEC 7 · SEC 8 |
| Cost Optimization | ≈ $407K a month and $0.67 per million requests derived; the CDN's share named as the lever; small locales packed to avoid 3 servers each; history reads cut 10×. COST 1 · COST 5 · COST 8 |
| Operational Excellence | Privacy requirements turned into mechanisms (retention, thresholds, deletion with stated deadlines); per-locale signals; cutover and rollback procedures; guarded experiments. OPS 1 · OPS 6 · OPS 8 |
| Sustainability | Regions chosen by where users are; raw data deleted after 30 days and only aggregates kept; one central build instead of four; history that expires after 90 days. SUS 1 · SUS 4 |
R3.11 Round 3 Rubric and Follow-Ups
What an architect (L7) answer adds over L6
- Treats "a language" as a normalization, an input method and a data set, not a translation.
- Shards for fit and blast radius, knows it isn't cheaper, and packs cold data to avoid per-shard minimums.
- Keeps personalization off the hot path, and says what staleness that costs.
- Builds privacy into the data flow: short-lived identifiers, in-region raw data, thresholds, honest deletion deadlines, and knows TTL-style deletion isn't a deadline.
- Runs regions from one build with independent flips, and plans for a neighbor's traffic.
- Makes ranking changes prove themselves, with guardrails that act on their own.
- Knows which line dominates the bill and what moves it.
Follow-up questions
-
"Cut the bill by 30%." Answer: 30% of ≈ $407K is about $122K, and $292K of the bill is CloudFront requests, so the lever is request count, not servers or hit ratio (CloudFront charges per request whether it hits or misses). If about one request in four is a single letter (an assumption), dropping the one-letter request and showing history plus a per-locale "popular now" list on focus instead (one cacheable request per session, about 2B a day) takes edge requests from 8B to about 5B a day: roughly 3/8 of $292K, about $110K saved. A committed-use deal (the Security Savings Bundle is up to 30% on CloudFront, or custom pricing for committed volume) covers the rest.
-
"A user in Germany deletes their history, then flies to Tokyo. Can they still see a deleted search?" Answer: no. History lives only in their home region (eu-west-1). In Tokyo, the session call is forwarded there, and it filters on
cleared_at, so even an item written in the same second as the delete isn't shown. Their raw search events from Germany age out of eu-west-1's logs within 30 days; the ones they make in Tokyo stay in ap-northeast-1's logs under the same rule. Aggregates never held their ID. -
"Why not a DynamoDB global table for history, so every region has it?" Answer: it would put every user's personal history in all four regions, which the privacy team ruled out, and it would multiply write costs by the number of replicas. One cross-region read per session, off the keystroke path, is a better price for travelers.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "One trie for all languages" | Mixed counts, conflicting normalization, and nothing for IME input. |
| "Hash the prefix to pick a shard" | Destroys subtrees; you're back to a flat table of every prefix. |
| "Shard to save money" | Memory costs the same per GB on big or small instances; per-shard minimums add servers. |
| "Read history on every keystroke" | A network read in the 10 ms and no edge caching for signed-in users. |
| "Hashed user IDs are anonymous" | A consistent hash links a person's searches; it's pseudonymous. |
| "DynamoDB TTL deletes at expiry" | It deletes typically within a few days; filter on read and state deadlines with that lag. |
| "Route 53 anycast sends users to the nearest region" | Route 53's name servers use anycast; the region choice comes from latency records. |
Loop Closer: Interview Strategy for All Three Rounds
How to Run Each 60-Minute Round
| Time | Round 1 | Round 2 | Round 3 |
|---|---|---|---|
| 0–5 min | Scoping questions | Restate the Round 1 design in 60 seconds | Restate the Round 2 design in 60 seconds |
| 5–15 min | Requirements, API and client behavior | Scope raise → what breaks | Scope raise → what breaks |
| 15–40 min | Design steps 1.0–1.5 | Design steps 2.1–2.6 | Design steps 3.1–3.6 |
| 40–50 min | Numbers + trade-offs | Numbers, cost, trade-offs | Numbers per region and locale, cost, trade-offs |
| 50–60 min | Failures + pillar check | Failures + pillar check | Failures, runbook, pillar check |
For how to spend a single 45-minute round, see the 45-minute interview blueprint.
The Two Sentences That Matter Most
- Opening a round: "Before I design, let me ask a few scoping questions."
- When the scope is raised: "Here's what breaks in the current design, and here's the order I'll fix it in."
And the one idea for this system: "Everything expensive happens offline; a keystroke is one walk and one read."
Well-Architected Review Sheet
Interviewers rarely ask "which pillar is this?". They ask the pillar's question in plain words. Rehearse one sentence per row.
| Pillar | Question you'll hear | One-sentence answer | Round | Backed by |
|---|---|---|---|---|
| Reliability | "What if autocomplete is down?" (REL 11) | The client waits ~300 ms, shows nothing, and search works as before. | 1 | R1.4, R1.9 |
| "How do you ship a new 16 GB snapshot safely?" (REL 8) | Validate offline, canary one server per AZ, flip a pointer, keep the old copy 2 hours for instant rollback. | 2 | Step 2.3 | |
| "What if the typo backend is slow?" (REL 5) | A 30 ms budget, a 64-call bulkhead and a circuit breaker: typos lose suggestions, nothing else does. | 2 | Step 2.4 | |
| "What if a region fails?" (REL 13) | Latency routing sends its users to the next region, which holds every locale; history falls back to global. | 3 | Step 3.5, R3.8 | |
| Performance | "How do you answer in under 10 ms?" (PERF 3) | Top-k precomputed at every node, in memory: one walk and one read, ~5 ms P99 with queueing. | 1–2 | Step 1.2, R2.6 |
| "How do far-away users get speed?" (PERF 4) | Short prefixes at the edge, everything else from a region picked by latency. | 2–3 | Steps 2.2, 3.5 | |
| Cost | "What does the edge cost?" (COST 5) | Per request, whether hit or miss: it's most of the bill, so we use it only where answers are shared. | 2–3 | R2.6, R3.6 |
| "What does a million suggestions cost?" (COST 1) | About $0.67, of which $0.48 is CDN request fees. | 3 | R3.6 | |
| "Should we shard to save money?" (COST 6) | No: we shard for fit and blast radius, and pack small locales to avoid per-shard minimums. | 3 | Step 3.2, R3.6 | |
| Operations | "How would you know suggestions went bad?" (OPS 8) | Alarms on empty-answer rate, fallback rate, snapshot and overlay age, and blocked candidates, per locale. | 2–3 | R2.10, R3.9 |
| "How do you roll out a new ranker?" (OPS 6) | A randomized experiment on a few percent, with guardrails that switch it off automatically. | 3 | Step 3.6 | |
| "How do you meet privacy requirements?" (OPS 1) | Turn them into mechanisms: 30-day raw logs in-region, a 50-user threshold, deletion with stated deadlines. | 3 | Step 3.4 | |
| Security | "What's sensitive here?" (SEC 7) | Query logs: people type personal data into search boxes, so they're classified and handled as personal data. | 1–3 | R1.10, step 3.4 |
| "How would you notice someone gaming your suggestions?" (SEC 4) | Count distinct users, not searches, and hold back any query whose volume comes from few networks or sessions that don't behave like people; the exact serve-time blocklist removes what slips through. | 2 | Step 2.5 | |
| Sustainability | "How do you avoid waste?" (SUS 3, SUS 4) | Compute each answer once a night instead of per request, keep raw data 30 days, and keep only what can be suggested. | 1–3 | Step 1.3, step 3.4 |
Rubric Across Levels
| Dimension | L5 (Round 1) | L6 (Round 2) | L7 (Round 3) |
|---|---|---|---|
| Data structure | Radix trie with top-k at every node, built bottom-up; knows why an index and a DFS don't scale. | Sizes it (~16 GB), knows the radix bound and what a flat key-value table would cost. | Per-locale tries, reading indexes for IME input, shards by locale and prefix range. |
| Freshness | Daily offline build, read-only snapshot. | Batch + speed layer, merged on a common score; a freshness budget as a sum of steps. | Regional trending; one central build replicated to every region with independent flips. |
| Latency | In-memory lookup; client debounce, cancel, drop stale. | Server budget; edge for shared short prefixes; request collapsing; separate targets for the fallback. | Parallel steps take the max; hedged shard calls; personalization kept off the keystroke path. |
| Safety and privacy | Drops personal-looking queries; minimum count. | Exact serve-time blocklist, emergency push, manipulation checks; Bloom filters only with confirmation. | Pseudonymous, in-region, 30-day raw data; distinct-user thresholds; deletion with honest deadlines. |
| Cost | Servers sized for availability, not load. | Finds that CDN request fees dominate and routes by prefix length. | Cost per million requests; knows sharding isn't cheaper; names the request-count lever. |
| Evolving under new scope | Builds from a SQL query, one problem at a time. | Opens with "what breaks" and fixes it in order. | Changes the model (locales, people, law, regions, experiments), not just the components. |