Ch. 12 · System Design

Frontend System Design: Build an Autocomplete

A structured walkthrough of the autocomplete design round: requirements, architecture, race-free fetching, caching, rendering, the ARIA combobox and metrics.

~7 min readadvanced

Autocomplete is a favorite frontend system design question because it fits in 45 minutes yet touches every layer: network, caching, rendering, accessibility and measurement. Interviewers want to see you structure the problem, make trade-offs explicit and go deep where it matters. This note follows a framework that works for most frontend design rounds: requirements, architecture and data, then deep dives.

Requirements and scope

Spend the first few minutes asking, and write the answers down.

  • Functional: suggestions after a minimum length (say 2 characters); mouse and keyboard selection that fills the input or navigates; the matching part highlighted; maybe recent searches.
  • Non-functional: a latency target (say p95 under 300 ms from last keystroke to rendered suggestions); typing that never lags; resilience on flaky networks; keyboard and screen reader support; a pluggable data source.

Interview tip

Ask “where does the data live?” early. A few hundred static options can be filtered in memory; millions of products need a search API. That answer shapes everything.

Architecture and data flow

Piece Responsibility
Autocomplete (controller) Owns query, items, activeIndex, open and status
ComboboxInput The text input; emits input and key events; owns the ARIA attributes
SuggestionList The listbox popup; renders options, virtualizes long lists
SuggestionItem One option with its highlighted label
SuggestionService Normalizes, debounces, fetches, cancels, retries
Cache LRU cache with a TTL, keyed by the normalized query
LiveRegion Announces “5 suggestions available” to screen readers

What happens on one keystroke:

  1. The input updates query immediately; the field never waits.
  2. The service normalizes the query (trim, lowercase) and checks the cache.
  3. On a miss, it debounces (about 200–300 ms), then fetches with an AbortSignal.
  4. The response is cached, and rendered only if it belongs to the latest keystroke.
  5. Arrow keys move activeIndex, which drives aria-activedescendant; Enter or a click calls onSelect and closes the popup.

API and data model

The endpoint is a cacheable GET, for example GET /api/suggest?q=rea&limit=8:

interface Suggestion {
  id: string;
  label: string;
  type: 'query' | 'product' | 'user';
  url?: string;
  matches?: Array<[start: number, end: number]>; // ranges to highlight
}

interface SuggestResponse {
  query: string; // echoed back, so stale responses are easy to spot
  items: Suggestion[];
}
TypeScript

Echoing the query lets the client discard mismatched responses; match ranges keep the smarts (typos, accents) on the server. Send Cache-Control so a CDN can serve popular prefixes (private for personalized results).

Fetching without race conditions

Three problems: too many requests (debounce), wasted requests (abort) and out-of-order responses. The last is the classic bug: the user types “re” then “rea”, the slower “re” request arrives last and overwrites the correct results.

function createAutocomplete({ fetchSuggestions, render, cache, delay = 200, minChars = 2 }) {
  let timer = null;
  let controller = null;
  let latest = 0;

  return function onInput(value) {
    const query = value.trim().toLowerCase();
    const ticket = ++latest; // every keystroke invalidates older work
    clearTimeout(timer);
    controller?.abort(); // cancel the in-flight request, if any

    if (query.length < minChars) return render({ query, items: [] });
    const cached = cache.get(query);
    if (cached) return render({ query, items: cached });

    timer = setTimeout(async () => {
      controller = new AbortController();
      try {
        const items = await fetchSuggestions(query, controller.signal);
        cache.set(query, items);
        if (ticket === latest) render({ query, items }); // drop stale responses
      } catch (error) {
        if (error.name !== 'AbortError' && ticket === latest) render({ query, items: [], error });
      }
    }, delay);
  };
}
JavaScript
  • Debounce sends one request per pause instead of one per keystroke (see debounce and throttle from scratch).
  • Abort frees the connection; the old fetch rejects with an AbortError, which is ignored.
  • The ticket check is the real race fix. A response may already be resolved when you abort, and some data sources ignore signals, but a stale ticket never renders.

For a timeout, pass AbortSignal.any([signal, AbortSignal.timeout(3000)]) to fetch. It rejects with a TimeoutError, not an AbortError, so a slow server surfaces as an error while user-driven aborts stay silent.

Gotcha

Debounce does not fix races. Two short pauses still mean two overlapping requests, and the slower one can land last. You need the stale check.

Caching with LRU and TTL

Users backspace and retype constantly. A small cache makes repeats instant and cuts server load.

class LruCache {
  constructor({ max = 100, ttl = 60_000, now = Date.now } = {}) {
    this.max = max;
    this.ttl = ttl;
    this.now = now;
    this.map = new Map(); // iterates in insertion order: oldest first
  }
  get(key) {
    const entry = this.map.get(key);
    if (!entry) return undefined;
    this.map.delete(key);
    if (this.now() > entry.expires) return undefined; // expired: a miss
    this.map.set(key, entry); // re-insert: now the most recently used
    return entry.value;
  }
  set(key, value) {
    this.map.delete(key);
    this.map.set(key, { value, expires: this.now() + this.ttl });
    if (this.map.size > this.max) {
      this.map.delete(this.map.keys().next().value); // evict the least recently used
    }
  }
}
JavaScript

A Map remembers insertion order, so its first key is always the least recently used, and re-inserting on every hit moves an entry to the end; both operations are O(1). The TTL keeps suggestions fresh, max caps memory in long sessions, and injecting now makes expiry testable without waiting.

Beyond memory, an HTTP or CDN cache shares popular, non-personalized prefixes across users, and a service worker or IndexedDB keeps recent queries offline. And if the cached “rea” list was complete (fewer items than the limit), “reac” can be filtered locally.

Rendering fast

  • Protect typing. The input updates synchronously; the list may lag. In React, render the list from useDeferredValue(query) or update it in startTransition.
  • Cap the list. Eight to ten suggestions is typical.
  • Virtualize long lists. When the popup can hold thousands of options (a country picker, a “show all” mode), render only the visible rows plus an overscan:
function visibleRange({ scrollTop, viewportHeight, rowHeight, count, overscan = 5 }) {
  const first = Math.floor(scrollTop / rowHeight);
  const last = Math.ceil((scrollTop + viewportHeight) / rowHeight);
  const start = Math.max(0, first - overscan);
  const end = Math.min(count, last + overscan);
  return { start, end, offsetY: start * rowHeight, totalHeight: count * rowHeight };
}
// scrollTop 3200, viewport 320, rows 32px, 10000 rows → { start: 95, end: 115, offsetY: 3040 }
JavaScript

A spacer of totalHeight keeps the scrollbar honest, and the slice is offset by offsetY. Give rendered options aria-setsize and aria-posinset so screen readers still announce “96 of 10,000”, and keep the active option rendered: aria-activedescendant must point to an existing element.

Highlighting. Use the server’s matches when present, or a substring match. Render segments as text nodes with the match in a mark element; never build an HTML string for innerHTML, because labels are often user-generated (XSS).

function highlight(label, query) {
  const start = label.toLowerCase().indexOf(query.toLowerCase());
  if (!query || start === -1) return [{ text: label, match: false }];
  const end = start + query.length;
  return [
    { text: label.slice(0, start), match: false },
    { text: label.slice(start, end), match: true },
    { text: label.slice(end), match: false },
  ].filter((part) => part.text !== '');
}
// highlight('React Hooks', 'hoo') → 'React ', then 'Hoo' (match), then 'ks'
JavaScript

Accessibility: the combobox pattern

Follow the WAI-ARIA Authoring Practices combobox pattern with list autocomplete:

<label for="search">Search products</label>
<input id="search" type="text" role="combobox"
  aria-autocomplete="list" aria-expanded="true"
  aria-controls="search-listbox" aria-activedescendant="search-opt-1"
  autocomplete="off">
<ul id="search-listbox" role="listbox" aria-label="Suggestions">
  <li id="search-opt-0" role="option" aria-selected="false">react</li>
  <li id="search-opt-1" role="option" aria-selected="true">react hooks</li>
</ul>
<div role="status">2 suggestions available</div>
HTML
  • role="combobox" sits on the input itself (ARIA 1.2; the older wrapper with aria-owns is outdated). aria-expanded mirrors the popup’s visibility and aria-controls points to the listbox.
  • DOM focus stays in the input. The active option is conveyed by aria-activedescendant plus aria-selected="true" on that option; remove aria-activedescendant when nothing is active.
  • The role="status" live region must exist in the DOM before its text changes, or it may not be announced.
  • autocomplete="off" keeps the browser’s autofill dropdown from covering yours.
Key Behavior (APG list autocomplete example)
Down Arrow Input: first suggestion. List: next option, wrapping to the first
Up Arrow Input: last suggestion. List: previous option, wrapping to the last
Alt + Down Arrow Opens the list without moving focus
Enter Accepts the active option: fills the input and closes the list
Escape Closes the list; if it is already closed, clears the input
Home, End, Left, Right Back to editing text in the input

Call event.preventDefault() on arrow keys so the caret does not jump, and on an option’s mousedown so the input does not blur and close the list before the click lands.

Errors, offline and metrics

Errors. Never block typing. Show a quiet message in the popup and keep Enter working as a full search. Retry network errors, 5xx and 429 (honor Retry-After) with capped backoff, never other 4xx. One retry is enough: the next keystroke is a retry anyway.

Offline. Treat navigator.onLine and the online/offline events as hints (true does not guarantee a working connection). Offline, serve the in-memory cache and locally saved recent searches, and let a service worker cache popular queries.

Metric Tells you
Last keystroke to rendered suggestions (p50, p95) Perceived speed
Requests per session, cache hit rate Whether debounce and caching work
Selection rate, position of the chosen item Whether the suggestions are any good
Zero-result rate Data gaps or bad normalization
Error, timeout and abort rates Network health; many aborts suggest a short debounce
INP on search pages Typing responsiveness (a Core Web Vital)

Measure with performance.mark() and performance.measure(); send batches with navigator.sendBeacon() so they survive page unload.

The interview answer

“First I’d pin down requirements: where the data lives, the latency target and accessibility needs. The architecture is a controller that owns query, items, active index and status, a combobox input, a listbox, and a suggestion service with a cache. I debounce keystrokes, abort the previous request, and still drop any response that isn’t for the latest keystroke, because abort alone doesn’t prevent races. Results go into an LRU cache with a TTL, so backspacing is instant.

I cap the list, virtualize long ones and highlight with text nodes. Accessibility follows the ARIA combobox pattern: focus stays in the input, aria-activedescendant tracks the active option and a live region announces the count. Errors never block typing, and I’d track keystroke-to-render latency, selection rate, zero-result rate and error rate.”

Keep reading

read ✓JavaScript · mid

Closures, Scope & the Classic setTimeout Loop

What lexical scope and closures really are, why the var + setTimeout loop prints 3 3 3, three ways to fix it, and where closures earn their keep in real code.

~6 min readread →
read ✓JavaScript · hard

The Event Loop: Microtasks vs Macrotasks

How the call stack, task queue and microtask queue fit together, where rendering happens, how async/await schedules work, and output puzzles with answers.

~7 min readread →
esc