Search typeahead — a full worked answer
The whole thing, start to finish, in the order you would actually say it. Read it to learn the shape of a strong answer — then go and produce one yourself, because reading is not the same skill as producing.
The question
Design search suggestions that appear as the user types. Every keystroke should produce updated suggestions, ranked by what people actually search for.
Search and ranking. The user gives you a fragment and expects the right answer ranked first.
Two systems: one that narrows millions to dozens, one that ranks the dozens. Confusing them is a common mistake.
Before anything: where is the slack?
How long the system has before a person notices. Ask this before you draw a single box — it decides more of the design than scale does.
A hundred milliseconds per keystroke, because a person types the next character in about 200. And hours of slack on how fresh the suggestions are.
The latency budget is set by typing speed. A moderate typist produces a character every 150 to 250 ms, so a suggestion arriving after 300 ms is already answering a question the user has moved past. Above about 100 ms, the list visibly lags behind the cursor. That budget rules out almost any approach that searches at request time.
The freshness budget, by contrast, is huge, and this is the asymmetry to exploit. Suggestions come from what people searched for in the past, and yesterday's popularity predicts today's very well. An index rebuilt once an hour, or even once a day, gives results that look the same as real-time ones to almost every user. So you have hours to precompute whatever you need.
The exception is trending: a breaking news event makes a term explode in minutes, and suggestions that ignore it feel broken. That is a narrow, high-value exception. It calls for a small fast-moving layer over a slow-moving base, not for making the whole system real-time.
Hours of build slack plus 100 ms of serve budget makes this one of the clearest precompute-versus-compute-on-read decisions on the site.
So: You precompute the answer for every prefix ahead of time and serve it from memory. You do not search at request time. The only live component is a small trending overlay.
Requirements
about 5 minWhy this stage exists: Because the question is deliberately vague, and every box you draw before this is a guess.
Assumptions: global, and suggestions come from historical search queries rather than from a product catalogue: the top queries starting with what has been typed. One search generates several requests, one per keystroke, so request volume is many times the search volume. Suggestions are short strings, so the data is small. Users type on mobile keyboards, with typos.
First question, and it decides whether the answer can be shared: are suggestions personalised, or the same for everyone typing the same prefix? Global suggestions can be precomputed once and cached at the edge for everybody. Personalised ones mean a per-user computation inside a 100 ms budget, and no shared caching. I will assume global, with light personalisation applied on the client from local history.
Second question: do we need to handle typos and near-misses, or exact prefixes only? Fuzzy matching is a much more expensive lookup and would change the data structure. I will assume exact prefix matching first, with a limited typo tolerance as a second layer.
Scope: return ranked suggestions for a prefix, keep them reasonably fresh, and handle trending terms. Out of scope: the actual search results page, spelling correction on submitted queries, and multi-language handling, though I would flag the last as a real complication.
What an interviewer is ticking off in this stage
- Stated that suggestions come from historical queries, not a catalogue
- Noted request volume is a multiple of search volume — one per keystroke
- Asked global versus personalised and named the caching consequence
- Asked about typo tolerance and deferred it as a second layer
- Proposed a scope and excluded the results page
Actors and lifecycle
about 6 minWhy this stage exists: Because the happy path is the easy half. The failure branches are where the design actually lives.
Actors: the person typing, the population of past searchers whose behaviour creates the rankings, and the system, which decides what qualifies as a suggestion, what to suppress, and what counts as trending.
The chain: queries logged → aggregated by frequency → filtered and ranked → built into the prefix structure → served for a prefix → eventually decays out of the rankings.
Failure branches. At logged: bot traffic inflates a query's count, so rankings reflect scripts rather than people. The system must filter obvious automation, or suggestions get poisoned. At filtered: an offensive or harmful query becomes popular, and because suggestions come from raw behaviour, the system will promote it. This is a requirement, not a nicety: it needs a blocklist and a review path, and candidates often forget it. At built: the index build job fails, so suggestions silently go stale. A slightly old index looks normal, so this needs explicit monitoring on index age.
At served: the responses arrive out of order. The user typed "ca", then "cat", and the "ca" response arrives second, overwriting the more specific suggestions with less specific ones. This is a common bug, and the fix belongs in the design: every response carries the prefix it answers, and the client discards anything that does not match the current input.
At served: the user types faster than the round trip, generating requests for prefixes they have already moved past. The client should debounce briefly and cancel in-flight requests.
At served: no suggestions match at all. Return nothing cleanly rather than falling back to unrelated popular queries, which feels broken.
And the decay branch: a query that was hugely popular last month should fade, so ranking must weight recency rather than all-time totals, or the suggestions become a museum.
What an interviewer is ticking off in this stage
- Traced the suggestion from logged queries to being served
- Handled bot traffic poisoning the rankings
- Handled offensive suggestions — blocklist and review path
- Handled a failed index build, and noted the failure is silent
- Handled out-of-order responses by matching prefix to current input
- Debounced and cancelled in-flight requests on the client
- Handled the empty result case cleanly
- Weighted ranking by recency so old queries decay
The lifecycle, drawn
Numbers
about 6 minWhy this stage exists: Because one part of this is hard and the rest is easy, and they look identical until you do the arithmetic.
Requests: assume 100,000 searches per second at peak. At 6 keystrokes per search with debouncing, that is around 600,000 suggestion requests per second. That is a high request rate for a 100 ms budget, and it rules out any expensive work per request.
Distinct queries: people search for a huge variety of things, but the tail is worthless as suggestions. A query searched twice ever should not be suggested. Take the top 10 million queries, which covers nearly everything worth suggesting.
Index size: 10 million queries at roughly 40 bytes each is 400 MB of raw text. Storing the top 10 completions at every prefix node inflates that; call it a few gigabytes with the structure and rankings. That fits in memory on one machine, and this conclusion shapes the design: the suggestion index can be copied onto every serving node rather than sharded and queried over a network.
Build cost: aggregating a day of query logs, maybe 10 billion entries, is a batch job of tens of minutes on a modest cluster. That fits easily inside hours of slack.
Cache effectiveness: prefix popularity is highly concentrated. The top few thousand prefixes account for a large share of all requests, because everyone types the same first two or three characters. So a small cache absorbs most traffic, and short prefixes — the most requested — are also the most cacheable.
So the hard part here is request rate against a 100 ms budget, and the answer is precomputation plus caching. Not index size, not build cost, and not the ranking algorithm.
What an interviewer is ticking off in this stage
- Applied the keystroke multiplier to get the real request rate
- Bounded the index to the top N queries and justified dropping the tail
- Concluded the whole index fits in memory on one machine
- Noted the build is a batch job that fits comfortably in the slack
- Noted prefix popularity is concentrated, making caching very effective
- Finished the sentence: request rate against a tight latency budget
The numbers, drawn
The index fits on one machine, so replicate it to every serving node and take the network out of the hot path entirely. So the hard part is request rate against a 100 ms budget, which precomputation solves and per-request search does not.
High-level design
about 12 minWhy this stage exists: Because now — and only now — you have a reason for every box you are about to draw.
The data structure is the design. A trie over the top 10 million queries, where each node stores the top 10 completions beneath it, precomputed at build time. Answering a request is then a walk down the prefix, one step per character, and a read of a list that is already there. No search, no ranking, no scoring at request time. Justified directly by the 100 ms budget and the 600,000-per-second rate: anything computed per request would not fit.
That precomputation is the central trade. Building it is expensive and takes hours; serving from it is close to free. With hours of slack on freshness and 100 ms on serving, that is the right way round.
Ranking happens at build time: score each query by frequency weighted toward recent days so popularity decays, apply blocklists, and store the ordered top 10 at each node. Justified by the Stage 2 branches on decay and offensive content, which can both be handled at build time.
Serving: the whole index is replicated to every serving node, held in memory. Nodes are stateless with respect to each other and scale horizontally behind a load balancer. Justified by the size estimate: a few gigabytes per node is cheap, and it removes any network hop from the lookup.
Caching: an edge cache in front, keyed by prefix, with a TTL of minutes. Because short prefixes dominate traffic and are identical for everyone, the hit rate is very high, and most requests never reach a serving node. This box saves more work than any other, justified by the concentration observed in Stage 3.
Index building: a batch job reads query logs, aggregates, filters, ranks, and produces a new index artefact. Deployed by building the new index alongside the old one and switching an atomic pointer. Nothing is mutated in place, so there is no window where the index is half-updated. Justified by the Stage 2 branch about failed builds: with an atomic switch, a failed build leaves the previous index serving.
Trending overlay: a small, separately-updated layer of terms rising sharply in the last few minutes, merged into results at serve time. Kept small and separate on purpose, so the fast-moving part does not force the whole system to be real-time. Justified by the carve-out in the slack analysis.
Client behaviour is part of the design: debounce by about 50 ms, cancel superseded requests, discard responses whose prefix does not match the current input, and cache locally so backspacing does not re-request. That last one matters more than it sounds. Backspacing is common, and the client has already seen the response for every shorter prefix.
What an interviewer is ticking off in this stage
- Chose a trie with precomputed top-N at each node, and justified it with the latency budget
- All ranking done at build time, nothing at request time
- Index replicated to every node and held in memory
- Edge cache keyed by prefix, justified by prefix concentration
- Index deployed by atomic pointer switch, so a failed build is safe
- Trending handled as a small separate overlay
- Client-side debounce, cancellation, prefix matching and local cache
The architecture, drawn
Deep dive and tradeoffs
about 14 minWhy this stage exists: Because naming what a decision costs is the single clearest signal of seniority there is.
Hard part one: freshness against precomputation, the main tension in this design. The base index rebuilds hourly, which is fine for the long tail. Trending is the exception, so I keep a separate small structure updated every minute or two from a stream of recent queries, holding terms whose rate has risen sharply. At serve time the two are merged, with trending items boosted. Keeping this small (thousands of terms, not millions) is what makes it affordable. Cost: two ranking sources merged at serve time, so the ordering logic is more complex and the results are harder to explain when someone asks why a suggestion appeared.
Deploying a new index has a memory cost worth naming: to switch atomically you hold both the old and new index in memory briefly, so peak memory is roughly double steady state. If nodes are not sized for it, the swap itself can run a node out of memory. Cost: sizing every node for 2x, or accepting a brief window of reduced capacity while nodes rebuild in rotation.
Hard part two: typo tolerance without abandoning the structure. A trie is exact by nature. Options: precompute common misspellings as additional entries pointing at the correct query, which is cheap and covers most real typos because they cluster heavily; or fall back to a fuzzy search only when the exact prefix returns too few results, which is slower but rare. I would take both: precomputed misspellings for the common cases, fuzzy fallback when the exact lookup comes back nearly empty. Cost: a larger index, and the fallback path has a much worse latency profile, so it needs a tight timeout and must degrade to returning nothing rather than blowing the budget.
Hot prefix protection: single-character prefixes are requested constantly. Those are cache hits at the edge, so they rarely reach a serving node. I would give the shortest prefixes long TTLs, since their answers barely change. Cost: a trending term takes slightly longer to appear under a single-letter prefix, which users are unlikely to notice.
Abuse and rate limiting: an automated client can generate enormous request volume cheaply here, since every keystroke is a request. Per-client rate limiting is needed here, because without it one script can consume capacity meant for real typists. Cost: an aggressive typist on a shared network could hit a limit, so the limit must be generous and keyed carefully.
Multi-language, raised because it complicates this a lot in practice: tokenisation and prefix semantics differ by script. In languages written without spaces (Chinese, Japanese, Thai), or typed through an input method, "prefix" does not mean the same thing. I would build per-language indexes and route by locale rather than pretend one structure handles everything. Cost: N indexes to build and hold, and ambiguity for multilingual users.
Consistency per feature: suggestions are eventually consistent by up to an hour and that is by design. Trending is eventually consistent by a minute or two. Blocklists must apply immediately, which means they are checked at serve time as well as at build time. A filter that only runs at build time leaves an offensive suggestion live until the next rebuild.
What I would monitor: p99 latency per request, cache hit rate by prefix length, index age (the silent failure), trending pipeline lag, and the rate of empty-result responses, which is the earliest signal that an index build produced something wrong.
What an interviewer is ticking off in this stage
- Trending handled as a small separate structure merged at serve time
- Noted the 2x memory cost of an atomic index swap
- Typo tolerance via precomputed misspellings plus a bounded fuzzy fallback
- Hot short prefixes handled by caching with long TTLs
- Rate limiting justified by the per-keystroke request pattern
- Raised multi-language as a real complication with a real answer
- Blocklists enforced at serve time, not only at build time
- Named what to monitor, including index age and empty-result rate
- Cost named after every decision
The decision this turns on
- —A request is a short prefix walk plus a list read: microseconds.
- —All ranking, filtering and decay happen offline where time is free.
- —Index is a few GB, so every node can hold the whole thing.
- —New queries are invisible until the next rebuild.
- —Fresh immediately, and fuzzy matching comes for free.
- —No build pipeline, no artefact management, no atomic swaps.
- —Scoring per request will not hold 100 ms at 600,000 requests/sec.
- —Cost scales with request volume rather than with data size.
And then they push back
A complete answer is where the interview starts, not where it ends. These are the questions this design invites — the interview is decided here far more often than in the diagram.
10x traffic tomorrow. What fails first?
"We would add more servers and scale horizontally." This says nothing. Every system has a first thing to break, and not naming it means you do not know your own design well enough to have found it.
Name a specific component and the reason. "The stateless services scale out fine, so they are not the constraint. The database primary is, because every write goes through one machine and I am at roughly 40% of its write capacity now. So at 4x I am at the ceiling, and I would see write latency climb and connection pool waits before anything errors. The second thing is the cache, because 10x reads on a fixed working set is fine but 10x on a growing key space means my hit rate drops and the misses land on the database that is already the constraint. In order: database writes, then cache hit rate, then the connection pool. And I would find out from p99 write latency, not from errors."
The cache just died. What happens?
"Requests would go to the database instead, so it would be a bit slower." At the ratios that justify a cache, the database cannot take that load. This answer means you added a cache without checking what it was protecting you from.
"Every read that was being served from memory lands on the database at once. My cache was absorbing something like 99% of reads, so the database sees roughly 100x its normal load, and it will not survive that. A naive design goes down. What I would do about it, designed in advance: coalesce misses so a thousand simultaneous requests for the same key produce one database read rather than a thousand; keep a small in-process cache on each server as a second layer so the hottest keys never leave the machine; shed load at the edge rather than let the database die, because a degraded site beats a dead one; and bring the cache back warm rather than empty, because restarting into an empty cache reproduces the outage. Cost of the in-process layer: servers can briefly disagree with each other."
One user reports a slow request. How do you debug it?
"I would check the logs." Which logs, for what? At scale, "check the logs" without a way to find that specific request is a way of saying you have not thought about observability.
"Work down rather than guessing. First: is it slow for everyone or just them? A dashboard of that endpoint's p50 and p99 answers it in seconds — if p99 is bad and p50 is fine, it is probably data-dependent, meaning that user has far more rows than the median. Second: get a trace of their actual request, which requires request ids propagated through every service, and see whether the time went to one slow call or two hundred fast ones. Two hundred fast ones is an N+1, which is invisible in development and common in production. Third: if it is one slow query, run EXPLAIN with their parameters, not typical ones, because the plan can differ once their row count crosses a threshold. The common outcome is that they are a heavy account and the query has no index for their filter combination. What makes this possible at all is having decided beforehand to propagate request ids and to log with enough structure to find one user's request among billions."
Why this database and not Postgres?
"It scales better and is designed for high throughput." Vague and unfalsifiable. Postgres handles far more than most people assume, and an answer with no number in it sounds like a preference.
"The answer has to be a number or an access pattern. Something like: the write rate is around 80,000 a second sustained on one logical table, append-only, with no cross-row transactions. That is more than I would plan to commit on one Postgres primary, so I would be sharding it myself and hand-building rebalancing and failover that a distributed store already has. The read pattern is a single-key lookup, so I am giving up joins I do not need. And then what it costs: no cross-partition transactions, so any invariant spanning partitions moves into application code, and analytics needs a separate copy of the data. If I cannot produce a sentence like that, then the correct answer is that Postgres is fine. Saying so is the stronger answer: choosing the boring option on purpose shows more judgement than reaching for the distributed one."
Now make it multi-region.
"We would replicate the database to another region." That covers reads. The question is what happens to writes, and that is where the design actually changes.
"Reads are the easy half: replicas in each region, users read locally, and they may be slightly stale — I would say how stale and which reads cannot tolerate it. Writes are the hard part, and there are three options. Single write region: simplest, no conflicts, and users far away pay 150 ms per write, which is fine if writes are rare and unacceptable if they are not. A home region per data partition, so each user's data lives near them: local writes without conflicts, but it needs a routing layer plus a plan for users who move. Multi-leader with conflict resolution: local writes everywhere, and now I own a conflict problem, which is only acceptable where merges are safe. I would pick the second for user-owned data and single-region for anything globally contested like inventory. What changes elsewhere: cross-region replication cost, a failover story per region, and data residency rules that may force certain data to stay in certain places. I would ask about that constraint rather than discover it later."
What does this cost per month?
"I am not sure, it depends on the provider." Interviewers rarely want exact prices. They want to know whether you understand which line item dominates, and that is answerable from your own estimates.
"I would not guess at prices, but I can tell you the shape, which is what matters. The dominant cost here is egress bandwidth: my estimate said 1,350 GB a second to viewers, and at that volume nothing else on the bill comes close. Second is storage, because I keep raw data for two years and it grows every day whether anyone reads it or not. Third is compute, which is elastic and therefore the easiest to control. That ordering tells me where to spend engineering effort: a percentage point of CDN hit rate is worth more than any amount of compute optimisation, so cache hit rate is the number I would put on a dashboard and defend. Storage cost I would attack with lifecycle policies moving cold data to cheaper tiers and with tiered granularity. Compute I would run on interruptible capacity because the work has hours of slack."
How do you deploy a new version with no downtime?
"Rolling deploy behind the load balancer." Correct and incomplete. The hard part is database changes and in-flight requests, and stopping there suggests you have only done it on a stateless service.
"Rolling deploy, taking instances out of the pool, draining in-flight requests (skip connection draining and you drop live traffic during a zero-downtime deploy), then updating, waiting for health checks, and putting them back, keeping enough capacity in the pool throughout. The part that makes it work is that the new version must tolerate the old version running beside it, because for several minutes both are live. That means database migrations get split: add the new column, deploy code that writes both and reads the old, backfill, deploy code that reads the new, then remove the old column. That is three or four releases instead of one. Cost: every schema change is now a multi-deploy sequence, which is slower and is the price of not taking downtime. And I would rather roll forward to a known-good build than roll back, because undoing a migration is much harder than undoing code."
Replica lag is 30 seconds and a user reads their own write. What do they see?
"They might see slightly old data." They see their own comment missing, which they will report as data loss. Understating this suggests you have not watched users hit it.
"They see the state from before their write, and they will report it as the app losing their work. This is the read-your-own-writes problem, and users notice it because it is their own data. The fix is not to make everything strongly consistent, which would slow every read for everyone. It is to route that user's reads to the leader for a short window after they write, or to have the client pass the write position it saw and let the read wait for a replica that has caught up. Cheapest of all for this specific case: render their own item from what the client already has and do not re-fetch. Cost of leader-pinning: my most active users generate the most leader load, which is a real but bounded price. And separately, 30 seconds of lag is itself a symptom — usually a long transaction or a bulk write hogging the leader — so I would alert on it rather than only design around it."
The topics this answer used
You have read a good answer. That is worth very little until you can produce one on a blank page — so go and write this problem without looking, and compare afterwards.