Typeahead / Search Autocomplete
Design a real-time search autocomplete system that returns the top suggestions as the user types, like Google Search suggestions.
Low Level Design — covers service internals: DB schemas, API contracts, sequence flows, and class interfaces.
These designs are for learning purposes and may not be 100% accurate or production-ready. Always cross-reference with official docs and other resources. Report an issue if you spot something wrong.
Reference Documents
Trie Data Structure — Wikipedia
Comprehensive explanation of the trie (prefix tree) data structure including time complexity analysis and memory optimization techniques.
ArticleDesign Typeahead Suggestion — Educative.io
Step-by-step walkthrough of typeahead system design including trie structure, top suggestions, and data collection pipeline.
ArticleRedis ZRANGEBYLEX for Autocomplete — Redis Docs
Redis sorted set lexicographic range command used as an alternative to in-memory trie for prefix-based autocomplete.
DocsLLD Overview
Low-Level Design: Typeahead Search
The system has two separate paths: the hot serving path (< 100ms, user-facing) and the cold build path (hourly batch, no latency requirement).
Hot Path
- GET
/suggest?q=ap&k=5hits SuggestionService - Look up pre-computed top-k for prefix
apin Redis - Return JSON array of suggestions
Cold Path (hourly)
- TrieBuilder reads Query Log DB — past 7 days of searches
- Aggregates frequency per term
- For each trie node (prefix), computes top-k children by frequency
- Serializes updated prefix→suggestions map → pushes to Redis
Core Abstractions
| Class | Responsibility |
|---|---|
TrieNode | Trie data structure, prefix traversal |
SuggestionService | Hot-path lookups, query logging |
TrieBuilder | Cold-path rebuild, frequency aggregation |
PrefixCache | Redis adapter for trie serialization |
Data Model
Data Model
TrieNode (in-memory structure)
interface TrieNode {
char: string;
children: Map<string, TrieNode>;
isEndOfWord: boolean;
frequency: number; // global search count
topK: Array<{ term: string; score: number }>; // pre-computed, k=10
}
Redis Key Schema (serialized trie)
suggest:{prefix} → JSON string
{
"prefix": "ap",
"topK": [
{ "term": "apple", "score": 9800000 },
{ "term": "app", "score": 7200000 },
{ "term": "apply", "score": 3100000 },
{ "term": "apple tv","score": 2100000 },
{ "term": "apnews", "score": 1900000 }
],
"updatedAt": 1719500000
}
Query Log (MongoDB)
interface QueryLog {
_id: ObjectId;
query: string; // normalized (lowercase, trimmed)
userId?: string; // null for anonymous
sessionId: string;
timestamp: Date;
resultCount: number; // for future relevance tuning
}
Query Log Indexes
{ timestamp: -1 }— for time-windowed aggregation in TrieBuilder{ query: 1, timestamp: -1 }— for per-term frequency queries- TTL index:
{ timestamp: 1 }with expiry = 30 days (storage cap)
API Design
External API
GET /api/suggest
GET /api/suggest?q={prefix}&k={count}
| Param | Type | Default | Notes |
|---|---|---|---|
q | string | required | Raw prefix (URL-encoded) |
k | number | 5 | Max suggestions returned (1–10) |
Response 200:
{
"prefix": "ap",
"suggestions": [
{ "term": "apple", "score": 9800000 },
{ "term": "app", "score": 7200000 }
],
"source": "cache"
}
Response 400: { "error": "prefix too short (min 1 char)" }
SuggestionService Internal API
class SuggestionService {
async getSuggestions(prefix: string, k: number): Promise<Term[]>
async logQuery(query: string, userId?: string, sessionId: string): Promise<void>
async getPersonalized(prefix: string, userId: string): Promise<Term[]>
}
TrieBuilder Internal API
class TrieBuilder {
async rebuildTrie(windowDays: number): Promise<void>
async aggregateFrequencies(since: Date): Promise<Map<string, number>>
async pushToCache(trie: TrieNode): Promise<void>
}
Sequence Diagrams
Autocomplete Query (Hot Path)
Trie Rebuild (Cold Path, Hourly)
LLD Tradeoffs
Implementation Tradeoffs
| Decision | Option A | Option B | Chosen | Reason |
|---|---|---|---|---|
| Trie storage | In-memory (per server) | Redis serialized | Redis | Shared across servers; hot rebuild w/o deploy |
| Prefix lookup | ZRANGEBYLEX sorted set | Serialized JSON per prefix key | JSON per prefix | O(1) lookup vs range scan; simpler |
| Query logging | Synchronous (blocking) | Async fire-and-forget | Async | Logging must not add latency to hot path |
| Rebuild frequency | Real-time streaming | Hourly batch | Hourly batch | Freshness lag acceptable; streaming adds complexity |
| Top-k storage | Computed at read time | Pre-computed at each node | Pre-computed | O(1) read vs O(subtree) traversal per request |
| Personalization | Global frequency only | Blend with user history | Global only (MVP) | User history requires per-user index (phase 2) |
| Min prefix length | 1 char | 2 chars | 1 char | Single-char prefixes ('a', 't') are valid common cases |
Memory Estimate
- 1M distinct terms × avg 8 chars → 8M trie nodes
- Each node: ~200 bytes (char + children map + topK)
- Total in-memory trie: ~1.6 GB
- Redis (serialized): ~50 bytes/prefix avg → top 500K prefixes = 25 MB
Core Classes
Core Classes & Interfaces
// --- Trie Data Structure ---
class TrieNode {
char: string;
children: Map<string, TrieNode> = new Map();
isEndOfWord = false;
frequency = 0;
topK: Array<{ term: string; score: number }> = [];
constructor(char: string) {
this.char = char;
}
}
class Trie {
private root: TrieNode = new TrieNode('');
insert(term: string, frequency: number): void {
let node = this.root;
for (const char of term.toLowerCase()) {
if (!node.children.has(char)) {
node.children.set(char, new TrieNode(char));
}
node = node.children.get(char)!;
}
node.isEndOfWord = true;
node.frequency = frequency;
}
/** O(p) prefix lookup — returns pre-computed topK at the prefix node */
search(prefix: string): Array<{ term: string; score: number }> {
let node = this.root;
for (const char of prefix.toLowerCase()) {
if (!node.children.has(char)) return [];
node = node.children.get(char)!;
}
return node.topK;
}
/** Called after all insertions — compute top-k at each node bottom-up */
computeTopK(k = 10): void {
this._computeTopKDFS(this.root, '', k);
}
private _computeTopKDFS(node: TrieNode, prefix: string, k: number): Array<{ term: string; score: number }> {
let candidates: Array<{ term: string; score: number }> = [];
if (node.isEndOfWord) {
candidates.push({ term: prefix, score: node.frequency });
}
for (const [char, child] of node.children) {
candidates.push(...this._computeTopKDFS(child, prefix + char, k));
}
candidates.sort((a, b) => b.score - a.score);
node.topK = candidates.slice(0, k);
return node.topK;
}
}
// --- Service Layer ---
class SuggestionService {
constructor(
private readonly prefixCache: PrefixCache,
private readonly queryLogRepo: QueryLogRepository,
private readonly trie: Trie, // in-memory fallback
) {}
async getSuggestions(prefix: string, k = 5): Promise<Array<{ term: string; score: number }>> {
const normalized = prefix.toLowerCase().trim();
if (!normalized) return [];
// 1. Try Redis cache (O(1))
const cached = await this.prefixCache.get(normalized);
if (cached) return cached.topK.slice(0, k);
// 2. Fallback to in-memory trie
return this.trie.search(normalized).slice(0, k);
}
async logQuery(query: string, userId?: string, sessionId?: string): Promise<void> {
// Fire-and-forget — do not await in hot path
this.queryLogRepo.insert({
query: query.toLowerCase().trim(),
userId,
sessionId,
timestamp: new Date(),
}).catch(err => logger.error('logQuery failed', { err }));
}
}
// --- Cache Adapter ---
class PrefixCache {
constructor(private readonly redis: RedisClient) {}
async get(prefix: string): Promise<{ topK: Array<{ term: string; score: number }> } | null> {
const raw = await this.redis.get(`suggest:${prefix}`);
return raw ? JSON.parse(raw) : null;
}
async set(prefix: string, data: { topK: Array<{ term: string; score: number }> }, ttlSec = 7200): Promise<void> {
await this.redis.set(`suggest:${prefix}`, JSON.stringify(data), 'EX', ttlSec);
}
}