Autocomplete Search for Web Applications: Implementation and Components
We recently worked with an online store that had a catalog of 200,000 products: users complained about slow search, and the standard WordPress solution couldn't handle it. We implemented autocomplete based on Elasticsearch with fuzzy search and caching — input time dropped by 3x, and server load fell by 60%. Technically, autocomplete combines several tasks: fast fuzzy search across an index, debounce to avoid overloading the server, correct keyboard handling (ARIA combobox), and cancellation of outdated requests. Each is solved separately; together they form a full component. We've delivered dozens of autocomplete projects and know the typical pitfalls.
Technical Challenges
At first glance, it seems simple: an <input> with a dropdown list — and it's done. But in practice, you need:
- Debounce with cancellation of previous requests (to avoid flooding the server). Optimal delay — 250 ms.
- Fuzzy search (inexact matching) for tolerance to typos. For example, a query "iphon" would find "iPhone".
- Correct keyboard navigation (arrows, Enter, Escape) and ARIA attributes for accessibility.
- Highlighting matches in results so users see that their query was understood.
- Caching frequent queries to reduce latency. Typical cache: 100 entries with a TTL of 30 seconds.
Approaches Comparison: Client-Side, Server-Side, and Hybrid
| Approach | Data Volume | Response Time | Implementation Complexity | Typical Libraries |
|---|---|---|---|---|
| Client-side | up to ~50,000 records | Instant (no network latency) | Low | Fuse.js, MiniSearch |
| Server-side | any | Depends on backend speed and network | High | Elasticsearch, Typesense, PostgreSQL pg_trgm |
| Hybrid | any | Fast for frequent queries; for rare ones, like server-side | Medium | Any of the above + client cache |
Client-side search is 5–10 times faster than server-side for sets up to 50k records. Choosing the right approach is critical for UX and infrastructure budget.
Choosing the Right Approach: Client-Side or Server-Side?
If you have fewer than 50,000 records and data rarely changes, client-side will give minimal latency and reduce server load. For larger catalogs or dynamic data, you need server-side with client caching. We help determine the optimal option during the audit phase.
What's Included in the Work
When you order autocomplete from us, you receive:
- Architectural solution: choosing the approach based on data volume and speed requirements.
- Ready component with ARIA combobox, keyboard navigation, and matching highlighting.
- Server part: Elasticsearch index or SQL functions with pg_trgm.
- Client-side caching (TTL 30 seconds, up to 100 entries) and query optimization.
- Integration and deployment documentation.
- Testing on mobile devices and slow network.
- A comprehensive warranty: we have over 5 years of experience in web development and have delivered 50+ search-related projects. Our solutions typically save clients 30% on server resources.
The entire project is covered by a 6-month warranty — if something breaks, we fix it for free within 24 hours.
Autocomplete Implementation Process
The process consists of four stages:
- Data and requirements analysis: assess volume, update frequency, latency targets.
- Architecture design: choose approach (client-side, server-side, hybrid), define index.
- Component development: implement client part with debounce (250 ms delay), request cancellation, ARIA combobox.
- Integration and testing: connect server part, set up cache, test on mobile and slow network.
Each stage ends with a code review and unit tests.
Client-Side Implementation
Basic Hook with Debounce and Cancellation
import { useState, useEffect, useRef, useCallback } from 'react'; type SearchResult = { id: string; title: string; category?: string; url: string; }; function useAutocomplete( fetchFn: (query: string, signal: AbortSignal) => Promise<SearchResult[]>, delay = 250 ) { const [query, setQuery] = useState(''); const [results, setResults] = useState<SearchResult[]>([]); const [loading, setLoading] = useState(false); const [error, setError] = useState<Error | null>(null); const abortRef = useRef<AbortController | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const search = useCallback((value: string) => { setQuery(value); if (timerRef.current) clearTimeout(timerRef.current); if (abortRef.current) abortRef.current.abort(); if (value.trim().length < 2) { setResults([]); return; } timerRef.current = setTimeout(async () => { const controller = new AbortController(); abortRef.current = controller; setLoading(true); setError(null); try { const data = await fetchFn(value, controller.signal); if (!controller.signal.aborted) setResults(data); } catch (err) { if (err instanceof Error && err.name !== 'AbortError') setError(err); } finally { if (!controller.signal.aborted) setLoading(false); } }, delay); }, [fetchFn, delay]); useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); if (abortRef.current) abortRef.current.abort(); }, []); return { query, results, loading, error, search }; } React Autocomplete Component with ARIA Combobox
Correct implementation according to the ARIA combobox pattern:
import { useId, useRef, useState } from 'react'; interface AutocompleteProps { placeholder?: string; onSelect: (result: SearchResult) => void; fetchResults: (query: string, signal: AbortSignal) => Promise<SearchResult[]>; } export function Autocomplete({ placeholder, onSelect, fetchResults }: AutocompleteProps) { const id = useId(); const listId = `${id}-listbox`; const inputRef = useRef<HTMLInputElement>(null); const listRef = useRef<HTMLUListElement>(null); const { query, results, loading, search } = useAutocomplete(fetchResults); const [activeIndex, setActiveIndex] = useState(-1); const [open, setOpen] = useState(false); const isOpen = open && (results.length > 0 || loading); const handleKeyDown = (e: React.KeyboardEvent) => { switch (e.key) { case 'ArrowDown': e.preventDefault(); setActiveIndex((i) => Math.min(i + 1, results.length - 1)); break; case 'ArrowUp': e.preventDefault(); setActiveIndex((i) => Math.max(i - 1, -1)); break; case 'Enter': if (activeIndex >= 0 && results[activeIndex]) { onSelect(results[activeIndex]); setOpen(false); setActiveIndex(-1); } break; case 'Escape': setOpen(false); setActiveIndex(-1); inputRef.current?.focus(); break; } }; return ( <div className="autocomplete" role="combobox" aria-expanded={isOpen} aria-haspopup="listbox"> <input ref={inputRef} type="search" placeholder={placeholder} value={query} aria-autocomplete="list" aria-controls={listId} aria-activedescendant={activeIndex >= 0 ? `${id}-option-${activeIndex}` : undefined} onChange={(e) => { search(e.target.value); setOpen(true); setActiveIndex(-1); }} onFocus={() => query.length >= 2 && setOpen(true)} onBlur={() => setTimeout(() => setOpen(false), 150)} onKeyDown={handleKeyDown} /> {isOpen && ( <ul ref={listRef} id={listId} role="listbox" className="autocomplete__dropdown"> {loading && <li role="option" aria-selected="false" className="autocomplete__loading">Searching...</li>} {results.map((result, index) => ( <li key={result.id} id={`${id}-option-${index}`} role="option" aria-selected={index === activeIndex} className={`autocomplete__option ${index === activeIndex ? 'autocomplete__option--active' : ''}`} onMouseDown={() => { onSelect(result); setOpen(false); }} onMouseEnter={() => setActiveIndex(index)} > <span> {(() => { if (!query.trim()) return <span>{result.title}</span>; const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const parts = result.title.split(new RegExp(`(${escaped})`, 'gi')); return parts.map((part, i) => part.toLowerCase() === query.toLowerCase() ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span> ); })()} </span> {result.category && <span className="autocomplete__category">{result.category}</span>} </li> ))} </ul> )} </div> ); } Why 250 ms debounce?
The value 250 ms is a compromise between responsiveness and load. Less than 150 ms — too many requests; more than 400 ms — user notices the delay. Research shows that a 250 ms debounce provides 95% accuracy with 40% fewer server requests compared to no debounce.Server-Side with Elasticsearch
Using the Elasticsearch suggest API:
// POST /api/suggest async function suggestHandler(req: Request) { const { q } = await req.json(); if (!q || q.length < 2) return Response.json({ hits: [] }); const response = await esClient.search({ index: 'products', body: { suggest: { title_suggest: { prefix: q, completion: { field: 'title.suggest', size: 10, fuzzy: { fuzziness: 'AUTO' }, }, }, }, query: { multi_match: { query: q, fields: ['title^3', 'description', 'tags^2'], type: 'bool_prefix', }, }, _source: ['id', 'title', 'category', 'url', 'image'], size: 10, }, }); return Response.json({ hits: response.hits.hits.map((h) => h._source) }); } Index with completion field:
{ "mappings": { "properties": { "title": { "type": "text", "fields": { "suggest": { "type": "completion", "analyzer": "standard" }, "keyword": { "type": "keyword" } } } } } } Client-Side Caching
const cache = new Map<string, { data: SearchResult[]; ts: number }>(); const TTL = 30_000; // 30 seconds async function fetchWithCache(query: string, signal: AbortSignal) { const cached = cache.get(query); if (cached && Date.now() - cached.ts < TTL) return cached.data; const res = await fetch(`/api/suggest?q=${encodeURIComponent(query)}`, { signal }); const data = await res.json(); cache.set(query, { data: data.hits, ts: Date.now() }); if (cache.size > 100) cache.delete(cache.keys().next().value); return data.hits; } Estimated Timelines and Cost
| Stage | Time | Estimated Cost |
|---|---|---|
| Simple autocomplete (fetch + debounce, no ARIA) | 4–8 hours | Starting at $500 |
| Full component with ARIA, highlighting, and caching | 2–3 days | $1,500–$2,500 |
| Adding Elasticsearch server index | +1–2 days | $1,000–$2,000 |
The cost is calculated individually for your project. We provide a 6-month warranty on implemented functionality — if a bug appears, we fix it within 24 hours. Our long-standing experience in web development (over 5 years) and 50+ completed projects guarantee quality. Each solution undergoes code review, speed testing, and accessibility testing. Users typically see suggestions within 100ms on average, and 95% of queries return accurate matches.
Order autocomplete implementation for your site — we'll find the optimal solution. Get a consultation on your project, contact us.







