RelevantSearch.AI
Pattern · Volume 07 · Section A --- Autocomplete patterns · Updated May 2026

Hybrid autocomplete with query suggestions, instant results, and personalization

Source: Production methodology at major e-commerce and content search teams; WAI-ARIA combobox pattern; modern autocomplete libraries (Algolia Autocomplete, Downshift, Headless UI)

Classification — The pattern for production autocomplete combining multiple source types with proper accessibility, low-latency interaction, and personalization.

Intent

Build an autocomplete component that meets sub-100ms latency requirements, blends multiple suggestion sources appropriately for the workload, handles keyboard and screen reader interactions correctly, and shapes user queries toward those the backend can satisfy well.

Motivating Problem

Default autocomplete implementations are either too simple (just prefix-matching against a static list) or too complex (every keystroke hits the backend, producing laggy interactions and high backend load). Production autocomplete needs: multiple source types to address different user needs (completions for incremental discovery, instant results for navigational queries, categories for structured navigation, recent searches for return users); fast response (sub-100ms typical, sub-50ms ideal); proper accessibility for keyboard and screen reader users; failure handling when backend is slow or unavailable. The pattern documented here addresses these requirements.

How It Works

Source blending. The autocomplete dropdown shows results from multiple sources, each in its own section. Query completions appear first — prefix matches against the historical query corpus. Instant results appear next (or interleaved) — actual matching documents, retrieved live. Categories appear when the prefix matches a category name or when intent classification suggests categorical browsing. Recent searches appear for logged-in users or browser-cookie-identified sessions. The blending logic is workload-specific; e-commerce typically emphasizes instant results (users want to see products) while content search emphasizes query completions (users are still formulating their question).

Debouncing and throttling. Sending a request on every keystroke is wasteful and produces choppy results. Debouncing waits a short period (typically 50–150ms) after the last keystroke before sending the request — a user typing quickly produces only one request when they pause. Throttling limits the rate of requests regardless of typing (e.g., at most one request every 100ms) — prevents overload during sustained typing. Production patterns combine both; the specifics depend on the typical user behavior and the backend capacity.

Caching. Common prefixes ("run", "ni", "kid") are queried thousands of times per day. The autocomplete should cache results aggressively: in-memory cache in the browser for recently-typed prefixes; CDN-level cache for popular prefixes globally; backend cache for the prefix lookup. Each cache layer cuts latency for cache hits. The cache invalidation discipline is light — autocomplete results don't need to be perfectly fresh; minutes-old data is fine for query completions and categories.

Async loading and skeletons. Even with caching, some keystrokes will produce cache misses that need backend round-trips. The UI should show a loading state during these gaps — skeleton placeholders for instant results, an indicator that more is coming. The skeleton pattern feels faster than spinners; users perceive progress rather than just waiting.

Keyboard navigation. Up/Down arrows navigate suggestions; Enter selects the focused suggestion; Escape dismisses the dropdown; Tab inserts the focused suggestion into the input without submitting (allows further refinement). Production implementations need to handle these consistently; the WAI-ARIA combobox pattern specifies the expected behaviors. The detail matters: small inconsistencies (e.g., Enter sometimes selects, sometimes submits) frustrate power users.

Screen reader support. The dropdown needs proper ARIA roles: role=\"combobox\" on the input; role=\"listbox\" on the dropdown; role=\"option\" on each suggestion; aria-activedescendant on the input pointing to the currently-focused option; aria-expanded indicating whether the dropdown is open. Screen readers announce the focused option as the user navigates. Production deployments test with NVDA, JAWS, and VoiceOver to verify the patterns work across screen readers.

Touch and mobile. On touch devices, autocomplete behavior differs. Suggestions must be large enough to tap reliably (44px minimum, per WCAG); selection should be on tap, not hover (hover is unreliable on touch); the keyboard should support search-as-you-type without aggressive autocorrect (which can interfere with longer or unusual queries). iOS and Android have different input behaviors that the implementation needs to accommodate.

Backend integration. The autocomplete backend typically has a dedicated endpoint optimized for low-latency prefix queries: an in-memory trie or sorted-suffix structure rather than the full search index; pre-computed popularity scores; limited result sizes (typically 5–10 completions per source); deduplication and filtering. The backend may be a separate service or a dedicated mode of the main search service; the operational characteristics (sub-50ms p99 latency, very high request rate) are different from the main search backend.

When to Use It

Every consumer-facing search system benefits from autocomplete. Enterprise search systems benefit when the workload includes substantial repeat or pattern-based querying. The investment is moderate (the patterns are well-established) and the returns are substantial (autocomplete shapes the queries the backend ever sees).

Alternatives — no autocomplete is appropriate for very limited workloads (a few hundred items where browse is sufficient) or for specialized interfaces (command-line search tools). Search-as-you-type without autocomplete (showing instant results without query suggestions) is a simpler variant for some use cases.

Sources
  • WAI-ARIA Authoring Practices: Combobox pattern (w3.org/WAI/ARIA/apg/patterns/combobox)
  • Algolia Autocomplete documentation (algolia.com/doc/ui-libraries/autocomplete/)
  • Production case studies from major e-commerce and content search teams
  • Downshift / Headless UI / Radix UI documentation for the underlying interaction patterns
Example artifacts

Code

// Accessible autocomplete component (React + TypeScript)
// Implements WAI-ARIA combobox pattern with multi-source blending

import { useState, useEffect, useRef, useId } from \'react\';
import { useDebounce } from \'./hooks\';

type Suggestion =
| { kind: \'completion\'; text: string }
| { kind: \'document\'; id: string; title: string; image?: string }
| { kind: \'category\'; text: string; count: number }
| { kind: \'recent\'; text: string };

interface AutocompleteProps {
fetchSuggestions: (prefix: string) => Promise<{
completions: { text: string }[];
documents: { id: string; title: string; image?: string }[];
categories: { text: string; count: number }[];
}>;
getRecentSearches: () => string[];
onSubmit: (query: string) => void;
onDocumentSelect?: (id: string) => void;
}

export function SearchAutocomplete({
fetchSuggestions, getRecentSearches, onSubmit, onDocumentSelect,
}: AutocompleteProps) {
const [query, setQuery] = useState(\'\');
const [suggestions, setSuggestions] =
useState<Suggestion[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const listboxId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const debouncedQuery = useDebounce(query, 100);
// Fetch and blend suggestions when query changes
useEffect(() => {
if (!debouncedQuery) {
// Empty input: show only recent searches
const recent = getRecentSearches().slice(0, 5);
setSuggestions(recent.map(text => ({ kind: \'recent\', text })));
return;
}
setIsLoading(true);
fetchSuggestions(debouncedQuery).then(({ completions, documents,
categories }) => {
const blended: Suggestion[] = [
...completions.slice(0, 4).map(c => ({ kind: \'completion\' as
const, ...c })),
...documents.slice(0, 3) .map(d => ({ kind: \'document\' as const,
...d })),
...categories.slice(0, 2) .map(c => ({ kind: \'category\' as const,
...c })),
];
setSuggestions(blended);
setIsLoading(false);
}).catch(() => setIsLoading(false));
}, [debouncedQuery]);
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
switch (e.key) {
case \'ArrowDown\':
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, suggestions.length - 1));
break;
case \'ArrowUp\':
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, -1));
break;
case \'Enter\':
e.preventDefault();
if (activeIndex >= 0 && suggestions[activeIndex]) {
selectSuggestion(suggestions[activeIndex]);
} else {
onSubmit(query);
}
break;
case \'Escape\':
setIsOpen(false);
setActiveIndex(-1);
break;
}
}
function selectSuggestion(s: Suggestion) {
setIsOpen(false);
if (s.kind === \'document\' && onDocumentSelect) {
onDocumentSelect(s.id);
} else if (\'text\' in s) {
setQuery(s.text);
onSubmit(s.text);
}
}
return (
<div className="search-autocomplete">
<input
ref={inputRef}
type="search"
role="combobox"
aria-autocomplete="list"
aria-expanded={isOpen}
aria-controls={listboxId}
aria-activedescendant={activeIndex >= 0 ?
`${listboxId}-${activeIndex}` : undefined}
value={query}
onChange={e => { setQuery(e.target.value); setIsOpen(true);
setActiveIndex(-1); }}
onFocus={() => setIsOpen(true)}
onBlur={() => setTimeout(() => setIsOpen(false), 200)} // delay for
click
onKeyDown={handleKeyDown}
placeholder="Search..."
/>
{isOpen && suggestions.length > 0 && (
<ul id={listboxId} role="listbox" className="suggestions">
{suggestions.map((s, i) => (
<li
key={i}
id={`${listboxId}-${i}`}
role="option"
aria-selected={activeIndex === i}
className={`suggestion suggestion\--${s.kind} ${activeIndex === i
? \'active\' : \'\'}`}
onMouseDown={e => { e.preventDefault(); selectSuggestion(s); }}
>
{renderSuggestion(s)}
</li>
))}
</ul>
)}
</div>
);
}

function renderSuggestion(s: Suggestion) {
switch (s.kind) {
case \'completion\': return <span>{s.text}</span>;
case \'document\': return (
<div className="suggestion-doc">
{s.image && <img src={s.image} alt="" aria-hidden="true" />}
<span>{s.title}</span>
</div>
);
case \'category\': return <span>{s.text} <em>({s.count}
items)</em></span>;
case \'recent\': return <span
className="recent">{s.text}</span>;
}
}

Read in context within Volume 07 →