All problems (16)
System Design courseSystem Design Problems

0%

0/16 done

Practice · High Level Design · Read-Heavy: Serve It Fast

Design Search Autocomplete

IntermediateTrieTop-KCachingOffline aggregation⏱ 2-3 h

Suggestions on every keystroke in under 100 ms: a trie with top-K per prefix, building it offline from search logs, and caching close to the user.

Learn mode: the full solution is shown below.

Problem introduction

DifficultyIntermediate
Time2–3 hours to design and build the core; about 45 minutes in an interview
PrerequisitesStorage and search, caching and CDNs, what a trie is
ConceptsLatency budgets, tries with precomputed top-K, offline batch building vs online serving, immutable snapshots and atomic swaps, versioned cache keys, edge caching, sharding by prefix range, trending with time decay
Real worldThe search box of any big e-commerce site, Google and YouTube suggestions, app stores, maps ("dhanmondi 27..."), the "to" field of a bus or train ticket site

Autocomplete looks like a small feature: a dropdown under a search box. It's a great HLD problem because the numbers force a design that's very different from "query the database": every keystroke is a request, the answer must arrive before the user types the next letter, and the data (what people search for) changes all day. The core lesson is one you'll reuse everywhere: split the system into a slow offline path that prepares answers and a fast online path that only reads them.

Problem statement

Someone in Mirpur opens a big shopping site and starts typing "dha". Before they finish the word, a dropdown already shows "dhaka weather", "dhakaiya biryani", "dhaka traffic". They tap one and skip typing the rest. On a slow phone keyboard, that saves real effort, and it steers people to queries that actually have results.

What we are building: a service that, given what a user has typed so far (a prefix), returns the 10 most popular complete queries that start with it, fast enough to feel instant.

Who uses it: everyone who types in the search box, on every keystroke. Indirectly, the search team, who need the suggestions to be fresh (a new metro line, an Eid sale, breaking news) and clean (no offensive or personal suggestions).

What it solves: less typing, fewer spelling mistakes, and searches that land on things that exist.

What makes it technically interesting:

  • Latency: the answer must come back in well under 100 ms end to end, including a mobile network, or the dropdown lags behind the typing and is useless.
  • Volume: one search becomes several requests (one per pause in typing), so suggestion traffic is many times search traffic.
  • Short prefixes are huge: "d" matches millions of queries. Sorting them on every keystroke is impossible.
  • Freshness vs speed: popularity changes during the day, but rebuilding answers on the request path would kill latency.
  • Content safety: whatever people type can become a suggestion shown to millions, including slurs, rumours and phone numbers.

Requirements

Functional requirements

  1. Suggest: for a prefix, return up to 10 complete queries that start with it, most popular first.
  2. Only real searches: suggestions come from queries people actually submitted (pressed Enter), not from every keystroke.
  3. Freshness: a query that becomes popular shows up within about an hour; fast-breaking trends within about 15 minutes (stretch goal).
  4. Filtering: blocked words, offensive content and anything that looks like personal data are never suggested; a suggestion can be removed within minutes after a complaint.
  5. Out of scope: spelling correction ("dhkaa"), personalised suggestions, and the search results page itself. We discuss the first two in the interview section.

Non-functional requirements

RequirementTargetWhy it matters here
Latencyp99 under 10 ms at the server; the dropdown appears under ~100 ms after the user pausesSlower than the next keystroke means the user never sees it
Throughput~60,000 requests/s at peak (estimated below)Every pause in typing is a request
Availability99.9%Autocomplete is helpful, not critical: if it fails, search still works
FreshnessPopular new queries within ~1 hourStale suggestions miss the Eid sale and the news
ConsistencyEventual; one request must see one version of the dataA few minutes of staleness is fine, a mix of two versions in one answer is confusing
DurabilitySearch logs may lose a little data; the built tries can always be rebuiltCounts are statistics, not money
Safety and privacyNo blocked or personal suggestions; fast takedownOne bad suggestion is a news story

Latency and throughput drive the design. Note what's not strict: freshness is measured in minutes, durability is relaxed, and availability can be 99.9% because the feature degrades gracefully. That freedom is what lets us precompute everything.

Hint 1: the time budget

If the whole round trip must fit in under 100 ms and the mobile network takes most of that, how many milliseconds does the server really have? What kind of work fits in that?

Hint 2: the short-prefix problem

"d" matches millions of queries. You can't sort them on every keystroke. When could that sorting happen instead?

Hint 3: where popularity comes from

Which events should count: keystrokes, or submitted searches? How fresh does the count need to be, and does that work need to happen while the user waits?

Hint 4: fewer requests

Typing "dhaka metro" is 11 keystrokes. Does the server need to see all 11? What can the browser and the CDN do?

Capacity estimation

Assumptions (said out loud, round numbers):

  • 50 million daily active users, each submitting 6 searches a day.
  • A typed query is about 18 characters, but with a short debounce the browser sends about 6 suggestion requests per search.
  • The busiest hour is about 3× the average.
  • Only queries searched by at least 20 different people in 30 days can become suggestions (this is also a privacy rule, see Security). That leaves about 10 million suggestible queries, 20 bytes long on average.
  • We store the top 10 per prefix.

Traffic

FormulaResult
Searches per day50M × 6300 million
Suggestion requests per day300M × 61.8 billion
Suggestion requests per second (avg)1.8B ÷ 86,400≈ 21,000 / s
Suggestion requests per second (peak)21,000 × 3≈ 63,000 / s
Search log events per second (avg / peak)300M ÷ 86,400≈ 3,500 / s, ≈ 10,500 / s

Storage

FormulaResult
Search log per day300M × 200 B per event60 GB / day
Log kept 90 days, 3 copies60 GB × 90 × 3≈ 16 TB
Query text10M × 20 B200 MB
Trie nodesunder 10M × 20 (shared prefixes cut it to ~60M)≈ 60 million nodes
Trie size60M nodes × ~80 B (children + 10 query IDs of 4 B + overhead)≈ 5 GB

Servers: a trie lookup in memory takes microseconds; the HTTP work around it dominates. Assuming a conservative 10,000 requests/s per server, peak needs about 7 servers. Run 10 across two zones so we can lose a zone and still be fine. Each server holds the whole 5 GB trie, so any server can answer any prefix.

Bandwidth: a response with 10 suggestions is about 400 bytes of JSON plus ~300 bytes of headers. 63,000 × 700 B ≈ 44 MB/s (~350 Mbit/s) at peak. Noticeable but fine, and gzip and the CDN cut it further.

Cache size: prefixes of 1 to 3 characters number about 37 + 37² + 37³ ≈ 52,000 (letters, digits, space). At 700 B each that's 36 MB, and these short prefixes are by far the most repeated, so they're perfect for a CDN edge cache.

What the numbers say: the serving data (5 GB) fits in one machine's memory, so a lookup can be a pointer walk with no database at all. The expensive part (counting 300 million searches and ranking every prefix) can happen offline, in batches, because freshness is measured in minutes. That's the whole design in two sentences.

API design

Method and pathRequestSuccessErrors
GET /v1/suggest?q=dha&lang=en&limit=10none200 {"q": "dha", "version": 412, "suggestions": ["dhaka weather", "dhakaiya biryani", "dhaka traffic"]} with Cache-Control: public, max-age=60400 prefix too long or bad lang, 429 too many requests
POST /v1/search (the search service, not ours){"q": "dhaka metro rail", "event_id": "..."}results pagelogged as a search event for counting
POST /admin/v1/blocked-terms{"term": "...", "reason": "..."} (staff only)201403
  • Why GET with the prefix in the URL: it makes the response cacheable by the browser and the CDN, keyed by the URL. That's the most important property of this API.
  • No counts in the response: popularity numbers are internal; exposing them invites manipulation.
  • Authentication: suggestions are anonymous. That's deliberate: a cookie or user ID in the request would make the response personal and uncacheable. Rate limiting is per IP and per client app.
  • Idempotency: GET is naturally idempotent. Search events carry an event_id so the counting pipeline can drop duplicates when the log delivers an event twice.
  • version tells the client which trie answered; useful for debugging "why did I see this suggestion", and it lets the client ignore a response from an older version that arrives late.
  • Client contract: the browser waits a short pause (about 50–100 ms) before sending, cancels the previous request when a new key is pressed, and caches answers for the session. These three rules cut server traffic a lot.

Database design

Access pattern: the online path reads "top 10 for this exact prefix", tens of thousands of times a second, and never writes. The offline path appends search events all day and, once an hour, rewrites all the counts.

Why not just query a database? SELECT query FROM queries WHERE query LIKE 'd%' ORDER BY count DESC LIMIT 10 can use a B-tree index for the prefix, but for "d" it still has to read millions of matching rows to sort them by count. That's fine for a site with 1,000 queries and hopeless at our scale. The fix is to precompute the answer for every prefix, and a trie is a compact way to store those answers.

So the "database" for serving is the trie itself, in memory, loaded from a file. The other stores are ordinary:

-- Raw search events: an append-only log (Kafka topic), then hourly files in object storage
-- (Parquet, partitioned by hour). Not a SQL table; shown as columns for clarity.
--   event_id, query, lang, region, user_hash, ts

-- Aggregated counts, rewritten by every batch (warehouse table or Parquet files).
CREATE TABLE query_counts (
  query        TEXT    NOT NULL,
  lang         TEXT    NOT NULL,
  score        BIGINT  NOT NULL,          -- decayed count: old score / 2 + new searches
  users_30d    INT     NOT NULL,          -- distinct users, for the privacy threshold
  PRIMARY KEY (lang, query)
);

-- Which trie version each shard should serve (small, PostgreSQL).
CREATE TABLE trie_versions (
  version     BIGINT PRIMARY KEY,
  shard       TEXT   NOT NULL,            -- e.g. 'a-c', 'd-k', 'l-z'
  path        TEXT   NOT NULL,            -- object storage key of the snapshot file
  checksum    TEXT   NOT NULL,
  queries     INT    NOT NULL,
  status      TEXT   NOT NULL,            -- building, validated, live, rolled_back
  built_at    TIMESTAMPTZ NOT NULL
);

-- Blocked words and removed suggestions (small, PostgreSQL, edited by staff).
CREATE TABLE blocked_terms (
  term        TEXT PRIMARY KEY,
  reason      TEXT NOT NULL,
  added_by    BIGINT NOT NULL,
  added_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
  • The log is append-only and partitioned by time, so writing 10,000 events a second is cheap and an hour's data is one directory to read.
  • Counts are recomputed, not updated in place: a batch reads the previous counts plus the new hour and writes a fresh set. No row is ever contended.
  • trie_versions is the control plane: servers poll it (or get notified), download the file for their shard, verify the checksum and swap. Rolling back is setting an older row back to live.
  • Replication and partitioning: the log is replicated by Kafka and object storage; the trie is "replicated" by loading the same file on every server; shards are prefix ranges (see Scaling). The small PostgreSQL tables need only a standby.

Visual architecture

Browsertypes d, h, aLBSuggesttrie in RAMPrefix cacheversion:prefixSearch APIon EnterQuery logappend onlyAggregatorhourly batchTrie buildertop 3 per nodeSnapshot storetrie v2 filedhav1:dhahittop 3searchcountstrie v2online: each keystroke reads a precomputed list, cache firstoffline: submitted searches are logged and counted in batchesa new trie is built, stored and swapped in; no request waits for it
Press play. Online: a keystroke goes Browser → LB → Suggest server, which checks the prefix cache and otherwise reads the trie in its own memory. Offline: when the user presses Enter, the search is logged; an hourly batch counts it, a builder makes a new trie, stores it as a file, and the servers swap it in. No request ever waits for the offline path.
ComponentWhy it's hereConnections
BrowserDebounces, cancels stale requests, caches answers for the session→ CDN / LB with GET /v1/suggest
CDN edge (in the final design)Answers the most repeated short prefixes close to the usercaches by URL for 60 s
Load balancerSpreads requests over suggest servers, removes unhealthy ones→ suggest servers
Suggest serversHold the whole trie in RAM; answer a prefix with a pointer walkread-only; load snapshots
Prefix cacheSkips the walk for repeated prefixes; key = version:prefixin-process LRU first; a shared Redis only when answers are expensive to compute
Search APILogs a search event when the user submits a query→ query log
Query logDurable, append-only stream of search events→ aggregator
AggregatorHourly batch: counts searches, halves old scores, applies blocklist and privacy threshold→ trie builder
Trie builderBuilds the trie and every node's top 10, validates it→ snapshot store
Snapshot storeVersioned trie files in object storage→ suggest servers download

On the prefix cache: a trie walk in RAM is already microseconds, so a shared Redis in front of it would add a network hop. Our cache here is an in-process LRU that saves the walk and the JSON encoding for hot prefixes. A shared Redis becomes worth it later, when an answer has to be merged from several shards or mixed with personal history. That's the caching strategy: browser first, CDN second, in-process third, Redis only for expensive merged answers.

We deliberately don't have: a database on the read path, any write on the read path, or a stream processor on day one (hourly batches meet the freshness requirement).

Request lifecycle

Typing a prefix (cache miss)

BrowserSuggestCacheTrie (RAM)d, dh, dha: wait 100 msGET /suggest?q=dhaGET v1:dhamissTop("dha")3 suggestionsSET v1:dha200 [...] max-age=60show the dropdown
Press play. The user types d, h, a; the browser waits for a short pause and sends one request for "dha". The suggest server asks its cache for "v1:dha", misses, walks the trie to the "dha" node, takes the list stored there, fills the cache and answers with a 60-second max-age. The browser shows the dropdown.
  1. User types d, h, a. The browser waits ~100 ms after the last key, so it sends only one request: GET /v1/suggest?q=dha. If a request for dh is still in flight, it's cancelled.
  2. Browser cache, then CDN: if anyone nearby asked for dha in the last 60 seconds, the edge answers and our servers never see it.
  3. LB → any suggest server. The server normalises the prefix (lowercase, single spaces, max 50 characters; a trailing space is kept because dhaka and dhaka are different prefixes).
  4. It reads the current trie pointer once (version 1) and asks the cache for 1:dha. Miss.
  5. It walks d → h → a in the trie: three steps. The top-10 list is already stored at that node, so there's nothing to search or sort.
  6. It stores the list under 1:dha and returns 200 with Cache-Control: public, max-age=60. Total server time: well under a millisecond.

A search being counted (the offline path)

Query lograw searchesCountper queryMergehalve old, blockBuildtop 3 per nodeSnapshottrie v2 fileServer 1Server 2Server 3metro x9metro 9weather 3trie v2v1v1v1v2v2v2every hour: count what people actually submittedhalve old counts so a new trend can win; drop blocked wordsbuild the whole trie offline and store it as one fileservers load v2 and swap one pointer: v1 serves until then
Press play. Searches from the log are counted per query; the merge halves last period's scores so new trends can win and removes blocked words; the builder makes a whole new trie with a top-3 list at each node; it's stored as one file; then each server loads v2 and swaps one pointer. Servers keep answering from v1 until their swap.
  1. The user presses Enter on "dhaka metro rail". The search service logs {event_id, query, lang, user_hash, ts} to the query log. The search itself doesn't wait for this.
  2. Every hour, the aggregator reads the last hour of events, drops duplicate event_ids, normalises queries and counts them.
  3. It merges with the previous scores: score = old_score / 2 + new_count (exponential decay, so last week fades and a new trend can overtake it), removes blocked words and queries below the 20-distinct-users threshold.
  4. The builder makes a new trie, computes every node's top 10 bottom-up, and runs validation (size within ±20% of the last build, a set of "golden" prefixes still return sane results, no blocked terms).
  5. The snapshot file goes to object storage with a checksum; a trie_versions row marks it validated, then live.
  6. Each suggest server downloads the file, loads it into memory next to the old trie, and swaps one pointer. Requests already running finish on the old trie; new ones use the new one. Old cache entries have the old version in their key, so they're never served again and simply age out.

Component deep dive

The trie with top-K at every node

rootddhdhadhakdhaka"dhaka "dhakaidadhakaiya biryani 4dal recipe 2dha"dhaka "walk d, h, a: three steps, no searching belowthe answer is already stored at the nodedha: dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3"dhaka ": dhaka weather 5 | dhaka traffic 3 | dhaka to chittagong bus 2built offline, bottom-up: a node's list = its own query + its children's lists
Press play. Typing "dha" walks three edges from the root and returns the top-3 list already stored at the "dha" node. Typing "dhaka " (with a space) walks further to a different node with a different list. The lists were filled in offline, bottom-up.

A plain trie answers "which queries start with d?" by walking the whole subtree under d: millions of nodes. We don't do that at request time. Instead, while building offline, each node stores the best K queries in its subtree. They're computed bottom-up: a node's list is its own query (if one ends there) merged with its children's lists, sorted, cut to K. Children's lists are already the best of their subtrees, so the merge only looks at K × children items, not the whole subtree.

Without precomputed listsWith top-K at each node
Lookupwalk prefix + scan the whole subtree + sortwalk prefix, return list
Cost for "d"millions of nodes1 step
Cost in generalO(subtree size)O(prefix length)
Extra memorynoneK entries per node (store query IDs, not strings)
Updating one countcheapmust refresh lists on the path to the root, so we rebuild in batches instead

Memory tricks for production: store 4-byte query IDs instead of strings in the lists; compress chains of single-child nodes (a radix tree); only keep lists on nodes up to some depth and fall back to a small scan below it; or use a finite-state transducer (what Lucene's suggesters use), which shares suffixes as well as prefixes.

Immutable snapshots and the atomic swap

The trie is never edited in place. Each build produces a complete new trie; a server loads it alongside the current one and then swaps a single pointer. That gives us three things for free:

  • No locks on the read path: readers never see a half-updated trie, because nobody updates it.
  • Consistent answers: a request reads the pointer once at the start, so its whole answer comes from one version (the Go test TestReadersDuringSwaps checks exactly this under -race).
  • Instant rollback: the previous file is still in object storage; swapping back is the same operation.

The cost is memory: during a swap a server briefly holds two tries (2 × 5 GB). Size servers for that, or load shards one at a time.

Versioned cache keys

If the cache key were just the prefix, then after a swap the cache would keep serving the old list until each entry expired. Purging every key across servers on each swap is slow and racy. Instead the key contains the trie version: 412:dha. After the swap to 413, every lookup uses 413:... keys, so old entries are simply never hit again and the LRU evicts them. The CDN can't use our version in its key (it caches by URL), so there we accept up to max-age (60 s) of staleness, which the freshness requirement allows.

Counting with decay, and trends

The batch computes score = old_score / 2 + this_hour (in production the halving is tuned, e.g. a half-life of a few days). Without decay, last year's "eid ul adha 2025" would beat this week's news forever. With it, a query that suddenly gets searched a lot overtakes old favourites within one or two builds. The demo shows exactly this: "dhaka metro rail" goes from 1 search to the top of d after one day of heavy searching.

For "breaking news in 15 minutes", hourly batches aren't enough. The final design adds a small stream job that keeps counts for the last 15 minutes, finds queries rising much faster than usual, and pushes a short "boost list" to the servers, which merge it into answers until the next batch absorbs it. It's a separate, simpler path so the main build stays boring and reliable.

The core, runnable

This program is the heart of the design, small enough to read in one sitting: normalisation, an aggregator that counts submitted searches, a merge with decay and a blocklist, a trie that precomputes the top K of every prefix, an LRU cache keyed by version:prefix, and a service that answers keystrokes and swaps in new tries atomically.

autocomplete/
├── go.mod
├── trie.go               Trie: Build (with top K per node), Top
├── aggregate.go          Normalize, Aggregator, Merge (decay + blocklist)
├── cache.go              LRU cache of version:prefix -> suggestions
├── service.go            Service: Suggest, Publish (atomic swap)
├── autocomplete_test.go  top K vs brute force, swaps under -race, versioned cache, merge
└── cmd/demo/main.go      two days of searches, one user typing, a rebuild and a swap
// trie.go
package autocomplete

import "sort"

// Suggestion is one query and how often people searched for it.
type Suggestion struct {
	Query string
	Count int
}

// better orders suggestions: more searches first, then alphabetically, so every build and
// every language gives the same list.
func better(a, b Suggestion) bool {
	if a.Count != b.Count {
		return a.Count > b.Count
	}
	return a.Query < b.Query
}

type node struct {
	kids  map[byte]*node
	count int          // > 0 when a whole query ends here
	top   []Suggestion // the best k queries that start with this prefix, precomputed
}

// Trie is a read-only snapshot built offline from search counts. Every node already knows
// its top k, so a lookup is just "walk the prefix, return the list": no searching at query time.
type Trie struct {
	root    *node
	k       int
	Version int
	Queries int
	Nodes   int
}

// Build makes a trie from query counts and fills in the top k of every prefix.
func Build(version int, counts map[string]int, k int) *Trie {
	t := &Trie{root: &node{kids: map[byte]*node{}}, k: k, Version: version, Nodes: 1}
	for q, c := range counts {
		if c <= 0 || q == "" {
			continue
		}
		n := t.root
		for i := 0; i < len(q); i++ {
			next, ok := n.kids[q[i]]
			if !ok {
				next = &node{kids: map[byte]*node{}}
				n.kids[q[i]] = next
				t.Nodes++
			}
			n = next
		}
		n.count = c
		t.Queries++
	}
	t.fill(t.root, nil)
	return t
}

// fill computes top k bottom-up: a node's list is its own query plus its children's lists.
func (t *Trie) fill(n *node, path []byte) {
	var all []Suggestion
	if n.count > 0 {
		all = append(all, Suggestion{string(path), n.count})
	}
	for c, kid := range n.kids {
		t.fill(kid, append(path, c))
		all = append(all, kid.top...)
	}
	sort.Slice(all, func(i, j int) bool { return better(all[i], all[j]) })
	if len(all) > t.k {
		all = all[:t.k]
	}
	n.top = all
}

// Top returns the precomputed best k for a prefix (nil if nobody searched it). The slice
// belongs to the snapshot: callers must not change it.
func (t *Trie) Top(prefix string) []Suggestion {
	n := t.root
	for i := 0; i < len(prefix); i++ {
		if n = n.kids[prefix[i]]; n == nil {
			return nil
		}
	}
	return n.top
}
// aggregate.go
package autocomplete

import (
	"strings"
	"sync"
)

// MaxLen is the longest query we store or look up, in bytes.
const MaxLen = 50

// Normalize makes "  Dhaka   WEATHER " and "dhaka weather" the same query: lowercase, single
// spaces, trimmed, at most MaxLen bytes. (This demo uses ASCII; production also normalises
// Unicode, and Bangla queries work the same way.)
func Normalize(s string) string {
	s = strings.Join(strings.Fields(strings.ToLower(s)), " ")
	if len(s) > MaxLen {
		s = strings.TrimRight(s[:MaxLen], " ")
	}
	return s
}

// Aggregator counts searches as they happen, like the log pipeline that feeds the builder.
type Aggregator struct {
	mu     sync.Mutex
	counts map[string]int
	Seen   int
}

func NewAggregator() *Aggregator { return &Aggregator{counts: map[string]int{}} }

// Add records one search that the user actually submitted.
func (a *Aggregator) Add(raw string) {
	q := Normalize(raw)
	if q == "" {
		return
	}
	a.mu.Lock()
	defer a.mu.Unlock()
	a.counts[q]++
	a.Seen++
}

// Drain hands over the counts collected so far and starts a new period.
func (a *Aggregator) Drain() map[string]int {
	a.mu.Lock()
	defer a.mu.Unlock()
	out := a.counts
	a.counts = map[string]int{}
	a.Seen = 0
	return out
}

// Merge prepares the next build: old counts are halved (so last week fades and new trends can
// win), this period's counts are added, and queries with a blocked word are removed.
func Merge(old, recent map[string]int, blocked map[string]bool) (counts map[string]int, removed int) {
	counts = map[string]int{}
	for q, c := range old {
		if c/2 > 0 {
			counts[q] = c / 2
		}
	}
	for q, c := range recent {
		counts[q] += c
	}
	for q := range counts {
		for _, w := range strings.Fields(q) {
			if blocked[w] {
				delete(counts, q)
				removed++
				break
			}
		}
	}
	return counts, removed
}
// cache.go
package autocomplete

import (
	"container/list"
	"sync"
)

// Cache is a small LRU cache of "version:prefix" -> suggestions, like Redis (or a CDN edge)
// in front of the trie servers.
type Cache struct {
	mu           sync.Mutex
	cap          int
	order        *list.List // front = most recently used
	items        map[string]*list.Element
	Hits, Misses int
}

type entry struct {
	key  string
	list []Suggestion
}

func NewCache(capacity int) *Cache {
	return &Cache{cap: capacity, order: list.New(), items: map[string]*list.Element{}}
}

func (c *Cache) Get(key string) ([]Suggestion, bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	e, ok := c.items[key]
	if !ok {
		c.Misses++
		return nil, false
	}
	c.Hits++
	c.order.MoveToFront(e)
	return e.Value.(entry).list, true
}

func (c *Cache) Put(key string, list []Suggestion) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if e, ok := c.items[key]; ok {
		c.order.MoveToFront(e)
		return
	}
	if c.order.Len() == c.cap {
		old := c.order.Back()
		c.order.Remove(old)
		delete(c.items, old.Value.(entry).key)
	}
	c.items[key] = c.order.PushFront(entry{key, list})
}
// service.go
// Package autocomplete is the core of a search autocomplete service: searches are counted,
// a trie with the top k per prefix is built offline, swapped in atomically, and served
// through a cache whose keys carry the trie's version.
package autocomplete

import (
	"strconv"
	"strings"
	"sync/atomic"
)

// Service is one suggestion server. Lookups never lock the trie: it is read-only, and a new
// build replaces it with one atomic pointer swap.
type Service struct {
	trie    atomic.Pointer[Trie]
	cache   *Cache
	lookups atomic.Int64 // requests that reached the trie (cache misses)
}

func NewService(t *Trie, cache *Cache) *Service {
	s := &Service{cache: cache}
	s.trie.Store(t)
	return s
}

// Publish swaps in a freshly built trie. Requests already running finish on the old one.
func (s *Service) Publish(t *Trie) { s.trie.Store(t) }

// Version is the version of the trie being served now.
func (s *Service) Version() int { return s.trie.Load().Version }

// Lookups is how many requests missed the cache and walked the trie.
func (s *Service) Lookups() int64 { return s.lookups.Load() }

// Suggest answers one keystroke. The prefix is normalised like stored queries, except that a
// trailing space is kept ("dhaka " should not suggest "dhakaiya"). The cache key includes the
// version, so after a swap old answers are never served; they simply age out of the LRU.
func (s *Service) Suggest(raw string) []Suggestion {
	p := Normalize(raw)
	if p == "" {
		return nil
	}
	if strings.HasSuffix(raw, " ") && len(p) < MaxLen {
		p += " "
	}
	t := s.trie.Load() // one snapshot for the whole request
	key := strconv.Itoa(t.Version) + ":" + p
	if list, ok := s.cache.Get(key); ok {
		return list
	}
	s.lookups.Add(1)
	list := t.Top(p)
	s.cache.Put(key, list)
	return list
}
// cmd/demo/main.go
// Command demo counts a day of searches, builds a trie, serves keystrokes through a cache,
// then counts a second day, rebuilds and swaps the new trie in.
package main

import (
	"fmt"
	"strings"

	"autocomplete"
)

func show(s *autocomplete.Service, typed string) {
	list := s.Suggest(typed)
	parts := []string{}
	for _, x := range list {
		parts = append(parts, fmt.Sprintf("%s %d", x.Query, x.Count))
	}
	if len(parts) == 0 {
		parts = append(parts, "(no suggestions)")
	}
	fmt.Printf("  %-12s -> %s\n", `"`+typed+`"`, strings.Join(parts, " | "))
}

func searches(a *autocomplete.Aggregator, q string, times int) {
	for i := 0; i < times; i++ {
		a.Add(q)
	}
}

func main() {
	blocked := map[string]bool{"fake": true}
	agg := autocomplete.NewAggregator()

	// Day 1: what people submitted, messy spelling and all.
	searches(agg, "dhaka weather", 3)
	searches(agg, "Dhaka Weather", 1)
	searches(agg, "  dhaka   weather ", 1)
	searches(agg, "dhakaiya biryani", 4)
	searches(agg, "dhaka fake certificate", 4)
	searches(agg, "dhaka traffic", 3)
	searches(agg, "dhaka to chittagong bus", 2)
	searches(agg, "dal recipe", 2)
	searches(agg, "dhaka metro rail", 1)
	searches(agg, "eid sale", 3)
	searches(agg, "egg price", 1)
	fmt.Printf("day 1: %d searches logged\n", agg.Seen)
	counts, removed := autocomplete.Merge(nil, agg.Drain(), blocked)
	t1 := autocomplete.Build(1, counts, 3)
	fmt.Printf("build v1: %d queries (%d blocked), %d trie nodes, top 3 kept per prefix\n", t1.Queries, removed, t1.Nodes)

	cache := autocomplete.NewCache(8)
	s := autocomplete.NewService(t1, cache)
	fmt.Println("user 1 types \"dhaka m\", one request per keystroke:")
	for _, p := range []string{"d", "dh", "dha", "dhak", "dhaka", "dhaka ", "dhaka m"} {
		show(s, p)
	}
	fmt.Println("user 2 and a few others:")
	for _, p := range []string{"d", "dh", "dha", "  DHAKA  ", "e", "xyz"} {
		show(s, p)
	}
	fmt.Printf("cache: %d hits, %d misses; trie lookups: %d\n", cache.Hits, cache.Misses, s.Lookups())

	// Day 2: a new metro line opens and everyone searches for it.
	searches(agg, "dhaka metro rail", 9)
	searches(agg, "dhaka weather", 1)
	searches(agg, "eid sale", 2)
	fmt.Printf("day 2: %d searches logged\n", agg.Seen)
	counts, _ = autocomplete.Merge(counts, agg.Drain(), blocked)
	t2 := autocomplete.Build(2, counts, 3)
	s.Publish(t2)
	fmt.Printf("build v2: old counts halved + day 2 added -> %d queries; now serving v%d\n", t2.Queries, s.Version())
	for _, p := range []string{"d", "dhaka ", "e"} {
		show(s, p)
	}
	fmt.Printf("cache: %d hits, %d misses; trie lookups: %d\n", cache.Hits, cache.Misses, s.Lookups())
}

Running the demo:

$ go run ./cmd/demo
day 1: 25 searches logged
build v1: 8 queries (1 blocked), 83 trie nodes, top 3 kept per prefix
user 1 types "dhaka m", one request per keystroke:
  "d"          -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
  "dh"         -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
  "dha"        -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
  "dhak"       -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
  "dhaka"      -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
  "dhaka "     -> dhaka weather 5 | dhaka traffic 3 | dhaka to chittagong bus 2
  "dhaka m"    -> dhaka metro rail 1
user 2 and a few others:
  "d"          -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
  "dh"         -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
  "dha"        -> dhaka weather 5 | dhakaiya biryani 4 | dhaka traffic 3
  "  DHAKA  "  -> dhaka weather 5 | dhaka traffic 3 | dhaka to chittagong bus 2
  "e"          -> eid sale 3 | egg price 1
  "xyz"        -> (no suggestions)
cache: 4 hits, 9 misses; trie lookups: 9
day 2: 12 searches logged
build v2: old counts halved + day 2 added -> 7 queries; now serving v2
  "d"          -> dhaka metro rail 9 | dhaka weather 3 | dhakaiya biryani 2
  "dhaka "     -> dhaka metro rail 9 | dhaka weather 3 | dhaka to chittagong bus 1
  "e"          -> eid sale 3
cache: 4 hits, 12 misses; trie lookups: 12

Reading the output:

  • Day 1 and the build: 25 searches were logged, but "Dhaka Weather" and " dhaka weather " were normalised into "dhaka weather", so they count as one query with 5 searches. "dhaka fake certificate" was searched 4 times and would have been the second suggestion under dhaka ; the blocklist removed it before the build. 8 queries made a trie of 83 nodes.
  • Typing "dhaka m": d to dhaka all show the same three suggestions: "dhaka weather" (5), "dhakaiya biryani" (4), "dhaka traffic" (3). That's right: those are the best queries under every one of those prefixes. The moment a space is typed, dhaka drops "dhakaiya biryani" (it doesn't start with "dhaka ") and "dhaka to chittagong bus" moves in. dhaka m leaves only "dhaka metro rail".
  • User 2: d, dh, dha are cache hits: the first user already asked for them. DHAKA normalises to dhaka (the trailing space is kept) and is a hit too. xyz returns nothing, which is cached as well, so a bot can't hammer the trie with junk prefixes.
  • Cache numbers: 4 hits and 9 misses so far; only the misses walked the trie.
  • Day 2: a new metro line opens and "dhaka metro rail" is searched 9 times. The merge halves day 1 (weather 5 → 2, plus 1 new = 3) and adds day 2. "egg price" (1 → 0) disappears. In v2 "dhaka metro rail" (9) tops both d and dhaka .
  • After the swap: d, dhaka and e are misses even though they were cached a minute ago, because the keys are now 2:d, not 1:d. Nobody sees a stale list, and nobody had to purge anything.

The tests check what matters most: the precomputed top K equals a brute-force answer for random data, normalisation and the trailing space, the merge's decay and blocklist, that a swap never serves an old cached list, that readers running during 49 swaps always get an answer from exactly one version (run with -race), and that concurrent counting doesn't lose searches. Replacing the atomic pointer with a plain one makes -race report a data race in the swap test, so the test really guards the swap.

$ go test -race -v ./...
=== RUN   TestTopKMatchesBruteForce
--- PASS: TestTopKMatchesBruteForce (0.00s)
=== RUN   TestNormalize
--- PASS: TestNormalize (0.00s)
=== RUN   TestMergeDecaysAndBlocks
--- PASS: TestMergeDecaysAndBlocks (0.00s)
=== RUN   TestSwapNeverServesOldCacheEntries
--- PASS: TestSwapNeverServesOldCacheEntries (0.00s)
=== RUN   TestTrailingSpaceMatters
--- PASS: TestTrailingSpaceMatters (0.00s)
=== RUN   TestReadersDuringSwaps
--- PASS: TestReadersDuringSwaps (0.00s)
=== RUN   TestAggregatorConcurrentAdds
--- PASS: TestAggregatorConcurrentAdds (0.01s)
PASS
ok  	autocomplete	1.022s
?   	autocomplete/cmd/demo	[no test files]

The same core in five more languages

Single-threaded versions with the same classes and the same output. The swap is still one assignment of a reference; what changes is how each language makes that safe when threads are involved:

  • Python: assigning self.trie = new_trie is atomic under the GIL, and a request keeps its own reference t, so the old trie lives until that request finishes.
  • TypeScript: Node runs requests on one event loop, so the swap can't interleave with a lookup. (The class is called SuggestCache because Cache is a browser global.)
  • Rust: the service holds an Rc<Trie> and each request clones it. With threads you'd use Arc<Trie> behind an atomic swap (for example the arc-swap crate), and the compiler would refuse the non-thread-safe version.
  • Java: AtomicReference<Trie> is the direct equivalent of Go's atomic.Pointer, and LinkedHashMap in access order is a ready-made LRU.
  • C++: the service holds a std::shared_ptr<const Trie>; each request copies it, which keeps the old trie alive. With threads, use std::atomic<std::shared_ptr<const Trie>> (C++20) for the swap.
"""Search autocomplete's core: searches are counted, a trie with the top k per prefix is built
offline, swapped in as a whole, and served through a cache whose keys carry the trie's version."""
from collections import OrderedDict
from dataclasses import dataclass, field

MAX_LEN = 50


@dataclass(frozen=True)
class Suggestion:
    query: str
    count: int


def rank(s: Suggestion) -> tuple[int, str]:
    """More searches first, then alphabetical: the same order in every language."""
    return (-s.count, s.query)


@dataclass
class Node:
    kids: dict[str, "Node"] = field(default_factory=dict)
    count: int = 0  # > 0 when a whole query ends here
    top: list[Suggestion] = field(default_factory=list)  # best k with this prefix, precomputed


class Trie:
    """A read-only snapshot built offline. Every node already knows its top k."""

    def __init__(self, version: int, counts: dict[str, int], k: int):
        self.root, self.k, self.version = Node(), k, version
        self.queries, self.nodes = 0, 1
        for q, c in counts.items():
            if c <= 0 or not q:
                continue
            n = self.root
            for ch in q:
                if ch not in n.kids:
                    n.kids[ch] = Node()
                    self.nodes += 1
                n = n.kids[ch]
            n.count = c
            self.queries += 1
        self._fill(self.root, "")

    def _fill(self, n: Node, path: str) -> None:
        """Bottom-up: a node's list is its own query plus its children's lists."""
        everything = [Suggestion(path, n.count)] if n.count > 0 else []
        for ch, kid in n.kids.items():
            self._fill(kid, path + ch)
            everything += kid.top
        n.top = sorted(everything, key=rank)[: self.k]

    def top(self, prefix: str) -> list[Suggestion]:
        n = self.root
        for ch in prefix:
            n = n.kids.get(ch)
            if n is None:
                return []
        return n.top


def normalize(s: str) -> str:
    """Lowercase, single spaces, trimmed, at most MAX_LEN characters."""
    s = " ".join(s.lower().split())
    return s[:MAX_LEN].rstrip(" ")


class Aggregator:
    """Counts submitted searches, like the log pipeline that feeds the builder."""

    def __init__(self):
        self.counts: dict[str, int] = {}
        self.seen = 0

    def add(self, raw: str) -> None:
        q = normalize(raw)
        if q:
            self.counts[q] = self.counts.get(q, 0) + 1
            self.seen += 1

    def drain(self) -> dict[str, int]:
        out, self.counts, self.seen = self.counts, {}, 0
        return out


def merge(old: dict[str, int], recent: dict[str, int], blocked: set[str]) -> tuple[dict[str, int], int]:
    """Old counts halved, recent counts added, queries with a blocked word removed."""
    counts = {q: c // 2 for q, c in old.items() if c // 2 > 0}
    for q, c in recent.items():
        counts[q] = counts.get(q, 0) + c
    kept = {q: c for q, c in counts.items() if not any(w in blocked for w in q.split())}
    return kept, len(counts) - len(kept)


class Cache:
    """LRU cache of "version:prefix" -> suggestions, like Redis in front of the trie servers."""

    def __init__(self, capacity: int):
        self.cap, self.items = capacity, OrderedDict()
        self.hits = self.misses = 0

    def get(self, key: str) -> list[Suggestion] | None:
        if key not in self.items:
            self.misses += 1
            return None
        self.hits += 1
        self.items.move_to_end(key)
        return self.items[key]

    def put(self, key: str, value: list[Suggestion]) -> None:
        if key in self.items:
            self.items.move_to_end(key)
            return
        if len(self.items) == self.cap:
            self.items.popitem(last=False)
        self.items[key] = value


class Service:
    """One suggestion server. A new build replaces the trie in one assignment."""

    def __init__(self, trie: Trie, cache: Cache):
        self.trie, self.cache, self.lookups = trie, cache, 0

    def publish(self, trie: Trie) -> None:
        self.trie = trie

    def suggest(self, raw: str) -> list[Suggestion]:
        p = normalize(raw)
        if not p:
            return []
        if raw.endswith(" ") and len(p) < MAX_LEN:
            p += " "  # "dhaka " must not suggest "dhakaiya"
        t = self.trie  # one snapshot for the whole request
        key = f"{t.version}:{p}"
        cached = self.cache.get(key)
        if cached is not None:
            return cached
        self.lookups += 1
        found = t.top(p)
        self.cache.put(key, found)
        return found


def show(s: Service, typed: str) -> None:
    parts = [f"{x.query} {x.count}" for x in s.suggest(typed)] or ["(no suggestions)"]
    label = '"' + typed + '"'
    print(f"  {label:<12} -> {' | '.join(parts)}")


def searches(a: Aggregator, q: str, times: int) -> None:
    for _ in range(times):
        a.add(q)


def main() -> None:
    blocked = {"fake"}
    agg = Aggregator()
    # Day 1: what people submitted, messy spelling and all.
    searches(agg, "dhaka weather", 3)
    searches(agg, "Dhaka Weather", 1)
    searches(agg, "  dhaka   weather ", 1)
    searches(agg, "dhakaiya biryani", 4)
    searches(agg, "dhaka fake certificate", 4)
    searches(agg, "dhaka traffic", 3)
    searches(agg, "dhaka to chittagong bus", 2)
    searches(agg, "dal recipe", 2)
    searches(agg, "dhaka metro rail", 1)
    searches(agg, "eid sale", 3)
    searches(agg, "egg price", 1)
    print(f"day 1: {agg.seen} searches logged")
    counts, removed = merge({}, agg.drain(), blocked)
    t1 = Trie(1, counts, 3)
    print(f"build v1: {t1.queries} queries ({removed} blocked), {t1.nodes} trie nodes, top 3 kept per prefix")

    cache = Cache(8)
    s = Service(t1, cache)
    print('user 1 types "dhaka m", one request per keystroke:')
    for p in ["d", "dh", "dha", "dhak", "dhaka", "dhaka ", "dhaka m"]:
        show(s, p)
    print("user 2 and a few others:")
    for p in ["d", "dh", "dha", "  DHAKA  ", "e", "xyz"]:
        show(s, p)
    print(f"cache: {cache.hits} hits, {cache.misses} misses; trie lookups: {s.lookups}")

    # Day 2: a new metro line opens and everyone searches for it.
    searches(agg, "dhaka metro rail", 9)
    searches(agg, "dhaka weather", 1)
    searches(agg, "eid sale", 2)
    print(f"day 2: {agg.seen} searches logged")
    counts, _ = merge(counts, agg.drain(), blocked)
    t2 = Trie(2, counts, 3)
    s.publish(t2)
    print(f"build v2: old counts halved + day 2 added -> {t2.queries} queries; now serving v{s.trie.version}")
    for p in ["d", "dhaka ", "e"]:
        show(s, p)
    print(f"cache: {cache.hits} hits, {cache.misses} misses; trie lookups: {s.lookups}")


if __name__ == "__main__":
    main()

Scaling strategy

These stages are illustrative; real thresholds depend on hardware and traffic shape.

StageTrafficWhat breaks firstWhat changes
1. 100 usersA few suggestions a minuteNothingNo special system: LIKE 'dha%' ORDER BY count LIMIT 10 on a small query_counts table with an index, counts recomputed nightly.
2. 10K usersTens per secondShort prefixes like d scan many rowsPrecompute a prefix → top 10 table nightly and read it by primary key; add Cache-Control so browsers cache answers.
3. 100K usersHundreds per secondThe database on every keystroke; nightly freshnessA small suggest service with the trie in memory, built hourly from logs and loaded from a file; client debounce.
4. 1M usersThousands per secondOne server's CPU; risky deploys of new dataSeveral replicas behind an LB, a CDN for short prefixes, a Kafka log, a batch job, snapshot validation and versioned rollout with rollback.
5. 10M+ usersTens of thousands per second, several languages and regionsThe trie outgrows one machine; far users get slow answers; hourly is too slow for newsShard by prefix range (ranges sized by traffic, so d may be its own shard while x-z share one), a trie per language, snapshots copied to every region, a stream job for trends.

Notice that stages 1 and 2 are perfectly good designs for small products. A trie service, Kafka and a stream job are stage 4 and 5 tools; building them for a site with 100 users is cost and on-call pain with no benefit.

Why shard by prefix range and not by hash? A lookup is always for one exact prefix, and the prefix decides which part of the trie it needs. With range shards (a-c, d-k, l-z), the router looks at the first character(s) and sends the request to the one shard that holds that subtree. Hashing the prefix string also works if you store a flat prefix → list table (every prefix is its own key), at the cost of more storage, since each prefix's list is stored separately. The catch with ranges is skew: far more queries start with d or s than with x, so ranges must be cut by measured traffic and size, not by alphabet.

Failure scenarios

FailureWhat happensDetectionRecoveryData safetyUser impact
Suggest server crashIts in-flight requests failLB health checksLB stops routing to it; a new server loads the current snapshot, warms up, then reports readyNothing to lose: the trie is a copy of a fileA few failed dropdowns; the next keystroke works
Bad build (empty, huge, or poisoned trie)Garbage or no suggestionsValidation before going live: size vs last build, golden prefixes, blocklist scan; canary on one serverNever mark it live; if it slipped through, set the previous version live again (one row)Old snapshots kept for daysNone if caught by validation; minutes of odd suggestions otherwise
Batch pipeline or log downNo new buildsAge of the live version (alert if over 3 hours)Fix and rerun; the log keeps events (Kafka retention) so nothing is skippedEvents are retained; counts catch upSuggestions get stale but keep working
Cache failureEvery request walks the trieHit rate dropNothing urgent: the in-process cache refills; a failed Redis is bypassedNo unique data in cachesSlightly higher CPU, same answers
Network partition between regionsA region can't fetch new snapshotsVersion skew between regionsEach region serves its last good snapshot until the link returnsRead-only data, nothing to corruptSlightly staler suggestions in one region
Duplicate log eventsA search is counted twiceDuplicate event_ids in the batchBatch drops duplicates by event_idCounts stay correctNone
Traffic spike (breaking news)Many users type the same short prefixRequests per second, CPUCDN absorbs repeated prefixes; autoscale replicas (they only need the snapshot file)Read-only, no riskNone if the edge absorbs it
Manipulation (bots searching one phrase)A fake query climbs into suggestionsSpike from few users or IPs; distinct-user thresholdCount distinct users, not raw searches; rate limit; block termBlocklist and threshold applied at build timeOne bad suggestion until removed

The common thread: the serving side is read-only copies of a file, so almost every failure ends with "serve the last good version" and the user notices little. Autocomplete should also fail open on the client: if the request takes more than ~300 ms, the browser just doesn't show a dropdown, and search still works.

Reliability and observability

  • SLIs: suggest latency (p50, p99) at the server and as measured by real browsers; success rate; freshness (age of the live trie version); fraction of requests answered by the edge.
  • SLOs: 99.9% of suggest requests succeed and 99% finish under 10 ms at the server, per 30 days; the live trie is never older than 3 hours.
  • SLA: usually none; this is an internal feature. If sold as an API, keep it looser than the SLO.
  • Metrics: requests by status, cache hit rate (in-process and CDN), trie version per server (to catch skew), build duration and size, number of queries removed by the blocklist and threshold, zero-result rate per prefix length.
  • Product metrics: suggestion click-through rate and how many characters people type before picking one. These tell you whether ranking is any good.
  • Alerts: page on error-budget burn and on "no successful build for 3 hours"; ticket on version skew across servers over 15 minutes and on build size changing more than 20%.
  • Logs: sample suggest requests (prefixes can be personal, so keep them short-lived and never join them to user IDs); full logs for builds and swaps.
  • Tracing: sample traces through CDN → LB → server; mostly useful when latency comes from the network rather than our code.
  • Health checks: readiness only after the trie is loaded and a self-test of golden prefixes passes; liveness checks the process.
  • Disaster recovery: snapshots are files in object storage (versioned, copied to a second region); a new region can serve as soon as it downloads one. Counts can be rebuilt from the retained log.

Security

  • Content safety is the main risk. Anything people type can be shown to millions. Apply the blocklist at build time, and also at serving time for emergency removals (a small deny-set checked on every answer, so a takedown works in minutes without a rebuild).
  • Privacy: never suggest a query unless at least N different users (e.g. 20) searched it; this keeps out phone numbers, names and addresses people typed once. Also filter patterns that look like phone numbers, emails or ID numbers. Search logs store a hashed user ID, have short retention, and aren't joined to accounts for this pipeline.
  • Manipulation: count distinct users rather than raw searches, rate limit search submission per user and IP, and watch for sudden spikes from few sources.
  • Input validation: limit prefix length (50), strip control characters, normalise Unicode before lookup, and allow only known lang values.
  • Rate limiting: per IP and per client on /v1/suggest; caching at the edge also shields us from floods of repeated prefixes. Junk prefixes are cached as empty answers.
  • Authorization: only staff can edit blocked terms or roll versions; every change is audited.
  • Encryption: TLS everywhere; snapshots and logs encrypted at rest.

Trade-off analysis

DecisionChosenAlternativeWhy chosenWhen to switch
Serving structureTrie in RAM with top-K per nodeElasticsearch/OpenSearch completion suggester (FST)Simplest fastest path, full control of ranking, data fits in memoryYou also need fuzzy matching, many fields, or don't want to run a custom service
When to rankPrecompute top-K offlineRank at query time from the subtreeShort prefixes would scan millions of nodes per keystrokeTiny datasets, or highly personalised ranking that can't be precomputed
FreshnessHourly batch + small trend streamFully real-time updates to the trieImmutable snapshots are simple, lock-free and easy to roll backWhen minutes of delay really cost money (e.g. live sports, stock tickers)
Data layoutTrie snapshots per range shardFlat prefix → list table in a key-value storeCompact (shared prefixes), one file to shipYou want easy hash sharding and per-key updates more than compact storage
CachingBrowser + CDN (60 s) + in-process LRU, versioned keysNo CDN; or a shared Redis firstShort prefixes repeat hugely; versioned keys need no purgesPersonalised answers can't use the CDN; merged multi-shard answers justify Redis
CountingDistinct-user threshold + decayRaw counts, all timePrivacy, manipulation resistance and trends in one ruleVery small sites where thresholds would leave nothing

Operational cost grows as you move right: FST services, real-time tries and per-key stores each add a system to run. Each is the right call under a different requirement, and saying which requirement is what interviewers want to hear.

Final architecture

UsersCDN edge60 s cacheLBshard a-c3 replicasshard d-k3 replicasshard l-z3 replicasRedismerged answersQuery logKafka topicAggregatorhourly batchTrie builderone per shardSnapshotsversionedTrendingstream, 15 minddhakcountsv2boostshort popular prefixes are answered at the edgethe rest go to the shard that owns the prefixbatch builds a trie per shard; a stream job adds fast trendstwo paths: serve precomputed lists fast, rebuild them in the background
Press play. Short popular prefixes like "d" are answered at the CDN edge. The rest go through the load balancer to the shard that owns the prefix range; each shard is a group of replicas with the trie in memory. Offline, the query log feeds an hourly aggregator and a builder that makes one trie per shard and stores versioned snapshots; a small stream job boosts fast-rising trends between builds.

This design fits the requirements because each part answers a number or a rule: 63,000 requests a second and a 10 ms budget are met by in-memory lookups with no database on the read path; the huge repetition of short prefixes is absorbed by the CDN; "fresh within an hour" allows a batch pipeline, and immutable versioned snapshots make that pipeline safe to run every hour with instant rollback; content and privacy rules are enforced where the data is built, with a serving-time deny-set for emergencies. Sharding and the trend stream are drawn because they're where the design goes at stage 5, not because a new product needs them.

Interview discussion

1. Why not just run LIKE 'prefix%' against the database?

Answer

An index finds the matching rows, but for a short prefix like "d" there are millions of them, and you still have to sort them by popularity to take the top 10. At tens of thousands of requests a second that's impossible. Precomputing the top 10 for every prefix turns each request into a key lookup. For a small site, the database query is fine, and you should say so.

2. How big is the trie, and does it fit in memory?

Answer

Estimate: 10 million suggestible queries × ~20 characters gives at most 200 million nodes, about 60 million with shared prefixes. With 10 four-byte query IDs per node and some overhead, that's roughly 5 GB, which fits on one server, so every replica can hold the whole thing. If it didn't, compress (radix tree, FST), keep lists only near the top of the trie, or shard by prefix range.

3. How do suggestions stay fresh for breaking news?

Answer

Two paths. The hourly batch with decayed scores handles normal trends. For minutes-level freshness, a stream job counts the last 15 minutes, detects queries rising far above their usual rate, and pushes a small boost list that servers merge into answers until the next batch. Keeping it separate means the main build stays simple and safe.

4. How do you update the trie without stopping traffic?

Answer

Never modify it in place. Build a complete new trie offline, ship it as a versioned file, load it next to the old one, then swap one atomic pointer. Requests that started on the old version finish on it. Cache keys contain the version, so no stale answer is served after the swap, and rollback is swapping back.

5. How would you handle typos like "dhkaa"?

Answer

Exact-prefix tries don't. Options: add common misspellings as aliases pointing to the right query (learned from logs where users corrected themselves), or use fuzzy matching with a bounded edit distance (Levenshtein automata over an FST, as in Lucene). Fuzzy lookups are more expensive, so they're typically used only when the exact prefix returns few results.

6. How would you personalise suggestions?

Answer

Keep the global trie for popularity and fetch a small per-user list (recent searches, stored per user) in parallel; merge them on the server with a simple scoring rule. The merged answer can't be cached at the CDN anymore, so personalise only after a few characters or only for logged-in users, and cache the global part separately.

7. Our users type in Bangla and Banglish. What changes?

Answer

Normalise Unicode (NFC) so the same word typed on different keyboards matches, build a trie per language or script, and consider transliteration so "dhaka" can also suggest "ঢাকা" queries. The prefix length limit should count characters, not bytes. The rest of the design is unchanged.

8. How does the client help?

Answer

Debounce (wait ~50–100 ms after the last key), cancel in-flight requests when a new key arrives, cache answers for the session, and ignore responses that arrive out of order. Together these often cut requests by more than half.

Common mistakes: querying the database on every keystroke; counting keystrokes instead of submitted searches; updating the trie in place under a lock; forgetting the CDN and Cache-Control; no plan for offensive or personal suggestions; using raw all-time counts so trends never appear; adding Kafka and stream processing before estimating.

What interviewers look for: a latency budget with numbers, the insight that short prefixes make query-time ranking impossible, a clear split between offline building and online serving, a safe way to roll out new data, caching at the right layers with a staleness argument, and honest attention to safety and privacy.

Designed it yourself and understood the solution? Mark it to track your progress.