SystemCraftSystemCraft
Back to Designs

Typeahead / Search Autocomplete

Design a real-time search autocomplete system that returns the top suggestions as the user types, like Google Search suggestions.

~4 min readeasy difficultysearch
Practice

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.

LLD 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

  1. GET /suggest?q=ap&k=5 hits SuggestionService
  2. Look up pre-computed top-k for prefix ap in Redis
  3. Return JSON array of suggestions

Cold Path (hourly)

  1. TrieBuilder reads Query Log DB — past 7 days of searches
  2. Aggregates frequency per term
  3. For each trie node (prefix), computes top-k children by frequency
  4. Serializes updated prefix→suggestions map → pushes to Redis

Core Abstractions

ClassResponsibility
TrieNodeTrie data structure, prefix traversal
SuggestionServiceHot-path lookups, query logging
TrieBuilderCold-path rebuild, frequency aggregation
PrefixCacheRedis adapter for trie serialization
Expand

Data Model

Data Model

TrieNode (in-memory structure)

ts
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
}
Expand

Redis Key Schema (serialized trie)

Diagram
suggest:{prefix}  →  JSON string
Expand
json
{
  "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
}
Expand

Query Log (MongoDB)

ts
interface QueryLog {
  _id: ObjectId;
  query: string;        // normalized (lowercase, trimmed)
  userId?: string;      // null for anonymous
  sessionId: string;
  timestamp: Date;
  resultCount: number;  // for future relevance tuning
}
Expand

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

Diagram
GET /api/suggest?q={prefix}&k={count}
Expand
ParamTypeDefaultNotes
qstringrequiredRaw prefix (URL-encoded)
knumber5Max suggestions returned (1–10)
Expand

Response 200:

json
{
  "prefix": "ap",
  "suggestions": [
    { "term": "apple",  "score": 9800000 },
    { "term": "app",    "score": 7200000 }
  ],
  "source": "cache"
}
Expand

Response 400: { "error": "prefix too short (min 1 char)" }

SuggestionService Internal API

ts
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[]>
}
Expand

TrieBuilder Internal API

ts
class TrieBuilder {
  async rebuildTrie(windowDays: number): Promise<void>
  async aggregateFrequencies(since: Date): Promise<Map<string, number>>
  async pushToCache(trie: TrieNode): Promise<void>
}
Expand

Sequence Diagrams

Autocomplete Query (Hot Path)

Diagram
Expand

Trie Rebuild (Cold Path, Hourly)

Diagram
Expand

LLD Tradeoffs

Implementation Tradeoffs

DecisionOption AOption BChosenReason
Trie storageIn-memory (per server)Redis serializedRedisShared across servers; hot rebuild w/o deploy
Prefix lookupZRANGEBYLEX sorted setSerialized JSON per prefix keyJSON per prefixO(1) lookup vs range scan; simpler
Query loggingSynchronous (blocking)Async fire-and-forgetAsyncLogging must not add latency to hot path
Rebuild frequencyReal-time streamingHourly batchHourly batchFreshness lag acceptable; streaming adds complexity
Top-k storageComputed at read timePre-computed at each nodePre-computedO(1) read vs O(subtree) traversal per request
PersonalizationGlobal frequency onlyBlend with user historyGlobal only (MVP)User history requires per-user index (phase 2)
Min prefix length1 char2 chars1 charSingle-char prefixes ('a', 't') are valid common cases
Expand

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

typescript
// --- 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);
  }
}
Expand