Debug the production bug where async requests resolve out of order and overwrite newer UI state. The fix is cancellation, request identity, shared-controller ownership, or takeLatest-style guards that stop stale results from winning.
Use this JavaScript interview question to rehearse a quick answer, common mistake, follow-up, and production pitfall.
Async Race Conditions and Stale UI UpdatesFrontend interview answer
This JavaScript interview question tests whether you can explain Debug async race conditions: stale UI, cancellation, and latest-result guards, connect it to production trade-offs, and handle common follow-up questions.
- Debug async race conditions: stale UI, cancellation, and latest-result guards explanation without falling back to memorized docs wording
- Async and Concurrency reasoning, edge cases, and production failure modes
- How you would answer the most likely JavaScript interview follow-up
The core issue
This is a classic production debug problem in search, filters, autosave flows, and route-driven detail pages. Request A starts, then request B starts, B finishes first, and the UI briefly looks correct. When A finishes later, it overwrites newer state with stale data unless your code cancels or guards the older request.
Step | What happens |
|---|---|
User types 'rea' | Request A is sent |
User types 'react' | Request B is sent |
Request B returns first | UI shows results for 'react' |
Request A returns later | UI is overwritten with stale 'rea' results |
How to prevent it
You need either real cancellation or a stale-result guard. Both prevent old responses from winning the race.
Technique | How it works | Notes |
|---|---|---|
AbortController | Cancel the previous request so it never resolves | Best when the API supports AbortSignal (e.g., fetch) |
Request id guard | Only apply results if the id matches the latest | Works even if the API cannot be cancelled |
takeLatest / switchMap | Wrap calls to auto-cancel or ignore stale results | Common in RxJS or custom utilities |
let requestId = 0;
async function search(query) {
const id = ++requestId;
const res = await fetch(`/api?q=${query}`);
const data = await res.json();
if (id !== requestId) return; // stale result
render(data);
}
let controller;
async function search(query) {
if (controller) controller.abort();
controller = new AbortController();
const res = await fetch(`/api?q=${query}`, { signal: controller.signal });
const data = await res.json();
render(data);
}
When the async work cannot be aborted
AbortController only helps when the underlying task listens to AbortSignal. Autosave pipelines, IndexedDB work, and some library promises still need a latest-version guard so stale completions cannot win.
let latestDraftVersion = 0;
async function autosave(draft) {
const version = ++latestDraftVersion;
const saved = await saveDraftToLocalAndRemote(draft); // not abortable
if (version !== latestDraftVersion) return;
showSavedAt(saved.updatedAt);
}
Shared-controller follow-up
If one route transition starts several fetches, a single AbortController can own the whole screen load. Aborting on route leave cancels profile, permissions, and related requests together instead of leaking old work into the next screen.
Pitfalls
- Promise.race does not cancel the losing promises.
- Debounce reduces request count but does not prevent out-of-order responses.
- Always clean up abort listeners or timers to avoid leaks.
Use this as one explanation rep, then continue with the JavaScript interview questions cluster or a guided prep path.