Build blazing-fast autocomplete with Redis sorted sets. Show search suggestions as users type.
import Redis from 'ioredis'; const redis = new Redis(process.env.ARCTICKEY_URL); class Autocomplete { private key: string; constructor(namespace: string = 'autocomplete') { this.key = namespace; } // Add a searchable term with a score (popularity) async addTerm(term: string, score: number = 1) { const normalized = term.toLowerCase().trim(); await redis.zadd(this.key, score, normalized); } // Increment score when term is searched/selected async incrementTerm(term: string, increment: number = 1) { const normalized = term.toLowerCase().trim(); await redis.zincrby(this.key, increment, normalized); } // Get suggestions for a prefix async suggest(prefix: string, limit: number = 10): Promise<string[]> { const normalized = prefix.toLowerCase().trim(); if (!normalized) return []; // Get all terms (we'll filter by prefix) // For large datasets, use ZRANGEBYLEX instead const all = await redis.zrevrange(this.key, 0, -1); return all .filter(term => term.startsWith(normalized)) .slice(0, limit); } } // Usage const search = new Autocomplete('products'); // Add products await search.addTerm('iPhone 15 Pro', 1000); await search.addTerm('iPhone 15', 800); await search.addTerm('iPhone 14', 500); await search.addTerm('iPad Pro', 600); await search.addTerm('iPad Air', 400); // Get suggestions const suggestions = await search.suggest('iph'); // ['iphone 15 pro', 'iphone 15', 'iphone 14'] // Boost when user selects a result await search.incrementTerm('iPhone 15 Pro');
For large datasets, use ZRANGEBYLEX with prefix indexing:
class PrefixAutocomplete { private key: string; constructor(namespace: string = 'ac') { this.key = namespace; } // Add term with all its prefixes async addTerm(term: string, data?: Record<string, any>) { const normalized = term.toLowerCase().trim(); const pipeline = redis.pipeline(); // Store full term data if (data) { pipeline.hset(`${this.key}:data`, normalized, JSON.stringify(data)); } // Add term to main sorted set pipeline.zadd(`${this.key}:terms`, 0, normalized); // Add all prefixes pointing to this term for (let i = 1; i <= normalized.length; i++) { const prefix = normalized.slice(0, i); pipeline.zadd(`${this.key}:prefix:${prefix}`, 0, normalized); } await pipeline.exec(); } // Get suggestions using prefix index async suggest(prefix: string, limit: number = 10): Promise<Array<{ term: string; data?: Record<string, any>; }>> { const normalized = prefix.toLowerCase().trim(); if (!normalized) return []; // Get terms matching prefix const terms = await redis.zrange( `${this.key}:prefix:${normalized}`, 0, limit - 1 ); if (terms.length === 0) return []; // Get data for each term const pipeline = redis.pipeline(); terms.forEach(term => { pipeline.hget(`${this.key}:data`, term); }); const results = await pipeline.exec(); return terms.map((term, i) => ({ term, data: results?.[i]?.[1] ? JSON.parse(results[i][1] as string) : undefined, })); } // Remove a term async removeTerm(term: string) { const normalized = term.toLowerCase().trim(); const pipeline = redis.pipeline(); pipeline.hdel(`${this.key}:data`, normalized); pipeline.zrem(`${this.key}:terms`, normalized); for (let i = 1; i <= normalized.length; i++) { const prefix = normalized.slice(0, i); pipeline.zrem(`${this.key}:prefix:${prefix}`, normalized); } await pipeline.exec(); } } // Usage with product data const products = new PrefixAutocomplete('products'); await products.addTerm('MacBook Pro 16"', { id: 'prod_123', price: 2499, category: 'laptops', image: '/images/macbook-pro.jpg', }); const results = await products.suggest('mac'); // [{ term: 'macbook pro 16"', data: { id: 'prod_123', ... } }]
Combine prefix matching with popularity scores:
class PopularAutocomplete { private key: string; constructor(namespace: string = 'search') { this.key = namespace; } async addTerm(term: string, initialScore: number = 0) { const normalized = term.toLowerCase().trim(); // Store with score await redis.zadd(`${this.key}:scores`, initialScore, normalized); // Index prefixes for (let i = 1; i <= Math.min(normalized.length, 10); i++) { const prefix = normalized.slice(0, i); await redis.sadd(`${this.key}:idx:${prefix}`, normalized); } } async recordSearch(term: string) { const normalized = term.toLowerCase().trim(); // Increment popularity score await redis.zincrby(`${this.key}:scores`, 1, normalized); } async suggest(prefix: string, limit: number = 10): Promise<Array<{ term: string; score: number; }>> { const normalized = prefix.toLowerCase().trim(); if (!normalized) return []; // Get all terms matching prefix const terms = await redis.smembers(`${this.key}:idx:${normalized}`); if (terms.length === 0) return []; // Get scores for all matching terms const pipeline = redis.pipeline(); terms.forEach(term => { pipeline.zscore(`${this.key}:scores`, term); }); const scores = await pipeline.exec(); // Combine and sort by score const results = terms .map((term, i) => ({ term, score: parseFloat(scores?.[i]?.[1] as string || '0'), })) .sort((a, b) => b.score - a.score) .slice(0, limit); return results; } } // Track searches to improve suggestions app.get('/api/search', async (req, res) => { const { q } = req.query; // Record that this term was searched await autocomplete.recordSearch(q as string); // Return search results const results = await searchProducts(q); res.json(results); });
Show user's recent search history:
class RecentSearches { private maxHistory = 10; async addSearch(userId: string, query: string) { const key = `recent:${userId}`; const normalized = query.trim(); await redis.pipeline() // Remove if exists (to update position) .lrem(key, 0, normalized) // Add to front of list .lpush(key, normalized) // Trim to max length .ltrim(key, 0, this.maxHistory - 1) // Expire after 30 days .expire(key, 60 * 60 * 24 * 30) .exec(); } async getRecent(userId: string): Promise<string[]> { return redis.lrange(`recent:${userId}`, 0, -1); } async clearHistory(userId: string) { await redis.del(`recent:${userId}`); } async removeSearch(userId: string, query: string) { await redis.lrem(`recent:${userId}`, 0, query.trim()); } } // Usage const recent = new RecentSearches(); // When user searches await recent.addSearch('user:123', 'wireless headphones'); // Show recent + suggestions const [recentSearches, suggestions] = await Promise.all([ recent.getRecent('user:123'), autocomplete.suggest(prefix), ]);
Track what's popular right now:
class TrendingSearches { private key = 'trending:searches'; private windowMinutes = 60; async recordSearch(query: string) { const normalized = query.toLowerCase().trim(); const bucket = Math.floor(Date.now() / (60 * 1000)); // Minute bucket // Increment in current time bucket await redis.pipeline() .zincrby(`${this.key}:${bucket}`, 1, normalized) .expire(`${this.key}:${bucket}`, this.windowMinutes * 60) .exec(); } async getTrending(limit: number = 10): Promise<Array<{ term: string; count: number; }>> { const now = Math.floor(Date.now() / (60 * 1000)); const buckets: string[] = []; // Get last N minutes of buckets for (let i = 0; i < this.windowMinutes; i++) { buckets.push(`${this.key}:${now - i}`); } // Union all buckets with weights (recent = higher weight) const tempKey = `trending:temp:${Date.now()}`; if (buckets.length > 0) { // Weight recent buckets more heavily const weights = buckets.map((_, i) => Math.max(1, this.windowMinutes - i)); await redis.zunionstore(tempKey, buckets.length, ...buckets, 'WEIGHTS', ...weights); } const results = await redis.zrevrange(tempKey, 0, limit - 1, 'WITHSCORES'); await redis.del(tempKey); const trending: Array<{ term: string; count: number }> = []; for (let i = 0; i < results.length; i += 2) { trending.push({ term: results[i], count: Math.round(parseFloat(results[i + 1])), }); } return trending; } }
Combine all features:
class SearchSuggestions { private autocomplete: PopularAutocomplete; private recent: RecentSearches; private trending: TrendingSearches; constructor() { this.autocomplete = new PopularAutocomplete(); this.recent = new RecentSearches(); this.trending = new TrendingSearches(); } async getSuggestions(userId: string | null, prefix: string): Promise<{ recent: string[]; suggestions: Array<{ term: string; score: number }>; trending: Array<{ term: string; count: number }>; }> { const [recent, suggestions, trending] = await Promise.all([ userId ? this.recent.getRecent(userId) : [], prefix ? this.autocomplete.suggest(prefix) : [], !prefix ? this.trending.getTrending(5) : [], ]); // Filter recent to match prefix if provided const filteredRecent = prefix ? recent.filter(r => r.toLowerCase().startsWith(prefix.toLowerCase())) : recent; return { recent: filteredRecent.slice(0, 3), suggestions: suggestions.slice(0, 7), trending: !prefix ? trending : [], }; } async recordSearch(userId: string | null, query: string) { await Promise.all([ this.autocomplete.recordSearch(query), this.trending.recordSearch(query), userId ? this.recent.addSearch(userId, query) : null, ]); } }
import { useState, useEffect, useCallback } from 'react'; import { useDebounce } from 'use-debounce'; function SearchInput() { const [query, setQuery] = useState(''); const [debouncedQuery] = useDebounce(query, 150); const [suggestions, setSuggestions] = useState<{ recent: string[]; suggestions: Array<{ term: string }>; trending: Array<{ term: string }>; }>({ recent: [], suggestions: [], trending: [] }); const [isOpen, setIsOpen] = useState(false); useEffect(() => { if (debouncedQuery || isOpen) { fetch(`/api/suggestions?q=${encodeURIComponent(debouncedQuery)}`) .then(r => r.json()) .then(setSuggestions); } }, [debouncedQuery, isOpen]); const handleSelect = useCallback((term: string) => { setQuery(term); setIsOpen(false); // Navigate to search results window.location.href = `/search?q=${encodeURIComponent(term)}`; }, []); return ( <div className="relative"> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} onFocus={() => setIsOpen(true)} placeholder="Search..." className="w-full px-4 py-2 border rounded-lg" /> {isOpen && ( <div className="absolute top-full left-0 right-0 mt-1 bg-white border rounded-lg shadow-lg"> {suggestions.recent.length > 0 && ( <div className="p-2"> <div className="text-xs text-gray-500 px-2 mb-1">Recent</div> {suggestions.recent.map(term => ( <button key={term} onClick={() => handleSelect(term)} className="block w-full text-left px-2 py-1 hover:bg-gray-100 rounded" > 🕐 {term} </button> ))} </div> )} {suggestions.suggestions.length > 0 && ( <div className="p-2 border-t"> {suggestions.suggestions.map(({ term }) => ( <button key={term} onClick={() => handleSelect(term)} className="block w-full text-left px-2 py-1 hover:bg-gray-100 rounded" > 🔍 {term} </button> ))} </div> )} {suggestions.trending.length > 0 && ( <div className="p-2 border-t"> <div className="text-xs text-gray-500 px-2 mb-1">Trending</div> {suggestions.trending.map(({ term }) => ( <button key={term} onClick={() => handleSelect(term)} className="block w-full text-left px-2 py-1 hover:bg-gray-100 rounded" > 🔥 {term} </button> ))} </div> )} </div> )} </div> ); }
Autocomplete & Search Suggestions
Build blazing-fast autocomplete with Redis sorted sets. Show search suggestions as users type.
Why Redis for Autocomplete?#
Basic Autocomplete with Sorted Sets#
import Redis from 'ioredis'; const redis = new Redis(process.env.ARCTICKEY_URL); class Autocomplete { private key: string; constructor(namespace: string = 'autocomplete') { this.key = namespace; } // Add a searchable term with a score (popularity) async addTerm(term: string, score: number = 1) { const normalized = term.toLowerCase().trim(); await redis.zadd(this.key, score, normalized); } // Increment score when term is searched/selected async incrementTerm(term: string, increment: number = 1) { const normalized = term.toLowerCase().trim(); await redis.zincrby(this.key, increment, normalized); } // Get suggestions for a prefix async suggest(prefix: string, limit: number = 10): Promise<string[]> { const normalized = prefix.toLowerCase().trim(); if (!normalized) return []; // Get all terms (we'll filter by prefix) // For large datasets, use ZRANGEBYLEX instead const all = await redis.zrevrange(this.key, 0, -1); return all .filter(term => term.startsWith(normalized)) .slice(0, limit); } } // Usage const search = new Autocomplete('products'); // Add products await search.addTerm('iPhone 15 Pro', 1000); await search.addTerm('iPhone 15', 800); await search.addTerm('iPhone 14', 500); await search.addTerm('iPad Pro', 600); await search.addTerm('iPad Air', 400); // Get suggestions const suggestions = await search.suggest('iph'); // ['iphone 15 pro', 'iphone 15', 'iphone 14'] // Boost when user selects a result await search.incrementTerm('iPhone 15 Pro');Prefix-Optimized Autocomplete#
For large datasets, use ZRANGEBYLEX with prefix indexing:
class PrefixAutocomplete { private key: string; constructor(namespace: string = 'ac') { this.key = namespace; } // Add term with all its prefixes async addTerm(term: string, data?: Record<string, any>) { const normalized = term.toLowerCase().trim(); const pipeline = redis.pipeline(); // Store full term data if (data) { pipeline.hset(`${this.key}:data`, normalized, JSON.stringify(data)); } // Add term to main sorted set pipeline.zadd(`${this.key}:terms`, 0, normalized); // Add all prefixes pointing to this term for (let i = 1; i <= normalized.length; i++) { const prefix = normalized.slice(0, i); pipeline.zadd(`${this.key}:prefix:${prefix}`, 0, normalized); } await pipeline.exec(); } // Get suggestions using prefix index async suggest(prefix: string, limit: number = 10): Promise<Array<{ term: string; data?: Record<string, any>; }>> { const normalized = prefix.toLowerCase().trim(); if (!normalized) return []; // Get terms matching prefix const terms = await redis.zrange( `${this.key}:prefix:${normalized}`, 0, limit - 1 ); if (terms.length === 0) return []; // Get data for each term const pipeline = redis.pipeline(); terms.forEach(term => { pipeline.hget(`${this.key}:data`, term); }); const results = await pipeline.exec(); return terms.map((term, i) => ({ term, data: results?.[i]?.[1] ? JSON.parse(results[i][1] as string) : undefined, })); } // Remove a term async removeTerm(term: string) { const normalized = term.toLowerCase().trim(); const pipeline = redis.pipeline(); pipeline.hdel(`${this.key}:data`, normalized); pipeline.zrem(`${this.key}:terms`, normalized); for (let i = 1; i <= normalized.length; i++) { const prefix = normalized.slice(0, i); pipeline.zrem(`${this.key}:prefix:${prefix}`, normalized); } await pipeline.exec(); } } // Usage with product data const products = new PrefixAutocomplete('products'); await products.addTerm('MacBook Pro 16"', { id: 'prod_123', price: 2499, category: 'laptops', image: '/images/macbook-pro.jpg', }); const results = await products.suggest('mac'); // [{ term: 'macbook pro 16"', data: { id: 'prod_123', ... } }]Popularity-Weighted Suggestions#
Combine prefix matching with popularity scores:
class PopularAutocomplete { private key: string; constructor(namespace: string = 'search') { this.key = namespace; } async addTerm(term: string, initialScore: number = 0) { const normalized = term.toLowerCase().trim(); // Store with score await redis.zadd(`${this.key}:scores`, initialScore, normalized); // Index prefixes for (let i = 1; i <= Math.min(normalized.length, 10); i++) { const prefix = normalized.slice(0, i); await redis.sadd(`${this.key}:idx:${prefix}`, normalized); } } async recordSearch(term: string) { const normalized = term.toLowerCase().trim(); // Increment popularity score await redis.zincrby(`${this.key}:scores`, 1, normalized); } async suggest(prefix: string, limit: number = 10): Promise<Array<{ term: string; score: number; }>> { const normalized = prefix.toLowerCase().trim(); if (!normalized) return []; // Get all terms matching prefix const terms = await redis.smembers(`${this.key}:idx:${normalized}`); if (terms.length === 0) return []; // Get scores for all matching terms const pipeline = redis.pipeline(); terms.forEach(term => { pipeline.zscore(`${this.key}:scores`, term); }); const scores = await pipeline.exec(); // Combine and sort by score const results = terms .map((term, i) => ({ term, score: parseFloat(scores?.[i]?.[1] as string || '0'), })) .sort((a, b) => b.score - a.score) .slice(0, limit); return results; } } // Track searches to improve suggestions app.get('/api/search', async (req, res) => { const { q } = req.query; // Record that this term was searched await autocomplete.recordSearch(q as string); // Return search results const results = await searchProducts(q); res.json(results); });Recent Searches (Per User)#
Show user's recent search history:
class RecentSearches { private maxHistory = 10; async addSearch(userId: string, query: string) { const key = `recent:${userId}`; const normalized = query.trim(); await redis.pipeline() // Remove if exists (to update position) .lrem(key, 0, normalized) // Add to front of list .lpush(key, normalized) // Trim to max length .ltrim(key, 0, this.maxHistory - 1) // Expire after 30 days .expire(key, 60 * 60 * 24 * 30) .exec(); } async getRecent(userId: string): Promise<string[]> { return redis.lrange(`recent:${userId}`, 0, -1); } async clearHistory(userId: string) { await redis.del(`recent:${userId}`); } async removeSearch(userId: string, query: string) { await redis.lrem(`recent:${userId}`, 0, query.trim()); } } // Usage const recent = new RecentSearches(); // When user searches await recent.addSearch('user:123', 'wireless headphones'); // Show recent + suggestions const [recentSearches, suggestions] = await Promise.all([ recent.getRecent('user:123'), autocomplete.suggest(prefix), ]);Trending Searches#
Track what's popular right now:
class TrendingSearches { private key = 'trending:searches'; private windowMinutes = 60; async recordSearch(query: string) { const normalized = query.toLowerCase().trim(); const bucket = Math.floor(Date.now() / (60 * 1000)); // Minute bucket // Increment in current time bucket await redis.pipeline() .zincrby(`${this.key}:${bucket}`, 1, normalized) .expire(`${this.key}:${bucket}`, this.windowMinutes * 60) .exec(); } async getTrending(limit: number = 10): Promise<Array<{ term: string; count: number; }>> { const now = Math.floor(Date.now() / (60 * 1000)); const buckets: string[] = []; // Get last N minutes of buckets for (let i = 0; i < this.windowMinutes; i++) { buckets.push(`${this.key}:${now - i}`); } // Union all buckets with weights (recent = higher weight) const tempKey = `trending:temp:${Date.now()}`; if (buckets.length > 0) { // Weight recent buckets more heavily const weights = buckets.map((_, i) => Math.max(1, this.windowMinutes - i)); await redis.zunionstore(tempKey, buckets.length, ...buckets, 'WEIGHTS', ...weights); } const results = await redis.zrevrange(tempKey, 0, limit - 1, 'WITHSCORES'); await redis.del(tempKey); const trending: Array<{ term: string; count: number }> = []; for (let i = 0; i < results.length; i += 2) { trending.push({ term: results[i], count: Math.round(parseFloat(results[i + 1])), }); } return trending; } }Full Search Component#
Combine all features:
class SearchSuggestions { private autocomplete: PopularAutocomplete; private recent: RecentSearches; private trending: TrendingSearches; constructor() { this.autocomplete = new PopularAutocomplete(); this.recent = new RecentSearches(); this.trending = new TrendingSearches(); } async getSuggestions(userId: string | null, prefix: string): Promise<{ recent: string[]; suggestions: Array<{ term: string; score: number }>; trending: Array<{ term: string; count: number }>; }> { const [recent, suggestions, trending] = await Promise.all([ userId ? this.recent.getRecent(userId) : [], prefix ? this.autocomplete.suggest(prefix) : [], !prefix ? this.trending.getTrending(5) : [], ]); // Filter recent to match prefix if provided const filteredRecent = prefix ? recent.filter(r => r.toLowerCase().startsWith(prefix.toLowerCase())) : recent; return { recent: filteredRecent.slice(0, 3), suggestions: suggestions.slice(0, 7), trending: !prefix ? trending : [], }; } async recordSearch(userId: string | null, query: string) { await Promise.all([ this.autocomplete.recordSearch(query), this.trending.recordSearch(query), userId ? this.recent.addSearch(userId, query) : null, ]); } }React Component#
import { useState, useEffect, useCallback } from 'react'; import { useDebounce } from 'use-debounce'; function SearchInput() { const [query, setQuery] = useState(''); const [debouncedQuery] = useDebounce(query, 150); const [suggestions, setSuggestions] = useState<{ recent: string[]; suggestions: Array<{ term: string }>; trending: Array<{ term: string }>; }>({ recent: [], suggestions: [], trending: [] }); const [isOpen, setIsOpen] = useState(false); useEffect(() => { if (debouncedQuery || isOpen) { fetch(`/api/suggestions?q=${encodeURIComponent(debouncedQuery)}`) .then(r => r.json()) .then(setSuggestions); } }, [debouncedQuery, isOpen]); const handleSelect = useCallback((term: string) => { setQuery(term); setIsOpen(false); // Navigate to search results window.location.href = `/search?q=${encodeURIComponent(term)}`; }, []); return ( <div className="relative"> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} onFocus={() => setIsOpen(true)} placeholder="Search..." className="w-full px-4 py-2 border rounded-lg" /> {isOpen && ( <div className="absolute top-full left-0 right-0 mt-1 bg-white border rounded-lg shadow-lg"> {suggestions.recent.length > 0 && ( <div className="p-2"> <div className="text-xs text-gray-500 px-2 mb-1">Recent</div> {suggestions.recent.map(term => ( <button key={term} onClick={() => handleSelect(term)} className="block w-full text-left px-2 py-1 hover:bg-gray-100 rounded" > 🕐 {term} </button> ))} </div> )} {suggestions.suggestions.length > 0 && ( <div className="p-2 border-t"> {suggestions.suggestions.map(({ term }) => ( <button key={term} onClick={() => handleSelect(term)} className="block w-full text-left px-2 py-1 hover:bg-gray-100 rounded" > 🔍 {term} </button> ))} </div> )} {suggestions.trending.length > 0 && ( <div className="p-2 border-t"> <div className="text-xs text-gray-500 px-2 mb-1">Trending</div> {suggestions.trending.map(({ term }) => ( <button key={term} onClick={() => handleSelect(term)} className="block w-full text-left px-2 py-1 hover:bg-gray-100 rounded" > 🔥 {term} </button> ))} </div> )} </div> )} </div> ); }Best Practices#