DEV Community

Cover image for Practice Frontend Typeahead Interviews With AbortController and ARIA
Karuha
Karuha

Posted on • Originally published at aceround.app

Practice Frontend Typeahead Interviews With AbortController and ARIA

Frontend interviews rarely fail because you forgot the name of one API. They fail when a small component turns into five hidden requirements: async state, stale requests, keyboard behavior, accessibility, and a clear explanation of trade-offs. A typeahead search box is a compact way to practice all five in one sitting.

Frontend typeahead interview drill: code, state, cancellation, and accessibility

Why use a typeahead as an interview drill?

A typeahead looks harmless: input in, suggestions out. That is exactly why interviewers like it. In 30-45 minutes they can watch whether you build the happy path only, or whether you notice the edge cases that show up in production.

A realistic typeahead has at least seven decisions:

Requirement What the interviewer is really testing
Debounce input Do you reduce needless work without making the UI feel slow?
Cancel stale requests Do you know why out-of-order responses create bugs?
Loading and empty states Do you model UI states instead of sprinkling booleans?
Keyboard navigation Do you think beyond mouse users?
ARIA roles Do you know the accessibility shape of a combobox?
Caching Can you explain latency vs freshness?
Error handling Do you degrade clearly instead of silently failing?

The point of the drill below is not to memorize a perfect answer. It is to rehearse the explanation while you build.

The interview prompt

Use this prompt with a timer:

Build a user search typeahead. It should debounce input, cancel stale requests, show loading/empty/error states, support arrow-key navigation, and explain which production details you would add later.

That sounds like a lot, so start with a small state model before writing code.

idle -> loading -> ready
idle -> loading -> empty
idle -> loading -> error
ready -> loading      # query changed
loading -> loading    # previous request aborted, new request started
Enter fullscreen mode Exit fullscreen mode

This state model gives you a clean way to talk while coding: "I am not adding a spinner boolean yet; I am modeling the component as a small state machine because loading, empty, and error are mutually exclusive."

A dependency-free implementation

Paste this into an index.html file and open it in a browser. It uses a fake API so you can practice the component behavior without creating a backend.

<label for="user-search">Search teammates</label>
<input
  id="user-search"
  type="text"
  role="combobox"
  aria-autocomplete="list"
  aria-controls="user-results"
  aria-expanded="false"
  autocomplete="off"
/>
<p id="search-status" aria-live="polite"></p>
<ul id="user-results" role="listbox"></ul>

<script>
const USERS = [
  'Ada Lovelace', 'Alan Turing', 'Grace Hopper', 'Katherine Johnson',
  'Margaret Hamilton', 'Edsger Dijkstra', 'Barbara Liskov', 'Donald Knuth',
  'Radia Perlman', 'Ken Thompson', 'Dennis Ritchie', 'Frances Allen'
];

const state = {
  query: '',
  status: 'idle',
  results: [],
  activeIndex: -1,
  controller: null,
  cache: new Map()
};

const input = document.querySelector('#user-search');
const list = document.querySelector('#user-results');
const status = document.querySelector('#search-status');

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function searchUsers(query, signal) {
  await wait(250 + Math.random() * 350);

  if (signal.aborted) {
    throw new DOMException('Request aborted', 'AbortError');
  }

  return USERS
    .filter((name) => name.toLowerCase().includes(query))
    .slice(0, 8);
}

function debounce(fn, delay = 220) {
  let timerId;

  return (...args) => {
    clearTimeout(timerId);
    timerId = setTimeout(() => fn(...args), delay);
  };
}

async function runSearch(rawQuery) {
  const query = rawQuery.trim().toLowerCase();
  state.query = query;
  state.activeIndex = -1;

  if (state.controller) {
    state.controller.abort();
  }

  if (!query) {
    state.status = 'idle';
    state.results = [];
    render();
    return;
  }

  if (state.cache.has(query)) {
    state.results = state.cache.get(query);
    state.status = state.results.length ? 'ready' : 'empty';
    render();
    return;
  }

  const controller = new AbortController();
  state.controller = controller;
  state.status = 'loading';
  state.results = [];
  render();

  try {
    const results = await searchUsers(query, controller.signal);

    if (controller !== state.controller) {
      return;
    }

    state.cache.set(query, results);
    state.results = results;
    state.status = results.length ? 'ready' : 'empty';
  } catch (error) {
    if (error.name === 'AbortError') {
      return;
    }

    state.results = [];
    state.status = 'error';
  }

  render();
}

function render() {
  input.setAttribute('aria-expanded', String(state.results.length > 0));
  input.setAttribute(
    'aria-activedescendant',
    state.activeIndex >= 0 ? `user-result-${state.activeIndex}` : ''
  );

  const messages = {
    idle: 'Type at least one character.',
    loading: 'Searching...',
    ready: `${state.results.length} result${state.results.length === 1 ? '' : 's'} found.`,
    empty: 'No matching teammates.',
    error: 'Search failed. Try again.'
  };

  status.textContent = messages[state.status];

  list.innerHTML = state.results
    .map((name, index) => `
      <li
        id="user-result-${index}"
        role="option"
        aria-selected="${index === state.activeIndex}"
      >${name}</li>
    `)
    .join('');
}

const debouncedSearch = debounce(runSearch);

input.addEventListener('input', (event) => {
  debouncedSearch(event.target.value);
});

input.addEventListener('keydown', (event) => {
  if (!state.results.length) return;

  if (event.key === 'ArrowDown') {
    event.preventDefault();
    state.activeIndex = (state.activeIndex + 1) % state.results.length;
    render();
  }

  if (event.key === 'ArrowUp') {
    event.preventDefault();
    state.activeIndex =
      (state.activeIndex - 1 + state.results.length) % state.results.length;
    render();
  }

  if (event.key === 'Enter' && state.activeIndex >= 0) {
    input.value = state.results[state.activeIndex];
    state.status = 'idle';
    state.results = [];
    render();
  }
});

render();
</script>
Enter fullscreen mode Exit fullscreen mode

How to explain the key decisions

Why debounce at 220ms?

Debounce is a product trade-off, not a magic number. Under 100ms, you still fire too many requests while someone types normally. Above 400ms, the input starts to feel sluggish. I usually say I would begin around 200-300ms, then measure real typing behavior and API latency.

That answer is stronger than "I would debounce it" because it names the user experience cost.

Why use AbortController?

Without cancellation, you can render stale results. Type a, then quickly type ad. If the a request returns after the ad request, the UI can show results for the wrong query.

The example defends against that in two ways:

  1. It aborts the previous request when a new query starts.
  2. It checks controller !== state.controller before committing results.

The second check is intentionally defensive. In real code, not every async task respects cancellation. The identity check makes the latest request the only one allowed to update the UI.

Why not start with React?

You can implement this in React, Vue, Svelte, or plain DOM. In an interview, plain JavaScript proves the underlying behavior without hiding it behind framework state updates.

If the interviewer asks for React, translate the same model:

  • status, results, and activeIndex become state.
  • controller and cache belong in refs.
  • The debounced function needs stable identity.
  • Cleanup should abort an in-flight request when the component unmounts.

That translation is a good senior-level discussion because it shows you understand both the browser primitive and the framework lifecycle.

Follow-up questions to practice

After you finish the first pass, ask yourself these follow-ups out loud:

  1. What changes if the API returns 10,000 users?
  2. Where would you put analytics without making the component noisy?
  3. How would you test cancellation deterministically?
  4. Should cached results expire after 30 seconds, 5 minutes, or never?
  5. What is missing for full WAI-ARIA combobox compliance?
  6. How would you support IME input for Japanese, Chinese, or Korean users?
  7. What should happen when the network is offline?

Those questions matter because frontend interviews are increasingly about systems thinking inside small UI surfaces. A typeahead is not just a typeahead; it is async control flow, accessibility, product latency, and failure handling in one component.

A 25-minute practice plan

Use this schedule if you are preparing for a frontend screen this week:

Time Task
0-3 min Restate requirements and draw the state model
3-10 min Build input, fake API, debounce, and rendering
10-15 min Add cancellation and stale-response protection
15-20 min Add keyboard navigation and ARIA attributes
20-25 min Explain production upgrades and trade-offs

Record yourself once. The uncomfortable part is not the code; it is hearing whether your explanation is precise or vague. If you want another way to rehearse follow-up pressure, the related AceRound technical interview prep guide pairs well with this drill because it separates practice tools from live interview pressure and focuses on explaining trade-offs, not memorizing trivia. AceRound is an AI interview assistant, but the useful habit here is tool-agnostic: practice turning implementation choices into clear spoken reasoning.

Sources and further reading

  • MDN: AbortController and abortable async work.
  • WAI-ARIA Authoring Practices Guide: combobox pattern.
  • React documentation: effect cleanup and state synchronization.

AI disclosure

I used AI assistance to draft and edit this article, then manually reviewed the code path, DEV.to fit, links, and claims before publishing.

Top comments (0)