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.
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
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>
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:
- It aborts the previous request when a new query starts.
- It checks
controller !== state.controllerbefore 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, andactiveIndexbecome state. -
controllerandcachebelong 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:
- What changes if the API returns 10,000 users?
- Where would you put analytics without making the component noisy?
- How would you test cancellation deterministically?
- Should cached results expire after 30 seconds, 5 minutes, or never?
- What is missing for full WAI-ARIA combobox compliance?
- How would you support IME input for Japanese, Chinese, or Korean users?
- 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:
AbortControllerand 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)