Frontend interview practice question

Async Race Conditions and Stale UI Updates

HighIntermediateJavascript

Interview quick answer

Async race conditions happen when an older request or async task resolves after a newer one and overwrites fresh UI. Fix stale updates with AbortController cancellation, a latest request-id check, or takeLatest-style ownership so only the newest result can render.

Interview focus

Async race conditions happen when older async work resolves after newer work and overwrites UI. Fix them with cancellation, latest-request guards, or takeLatest-style ownership.

  • Why debounce alone does not prevent stale UI
  • When to use AbortController versus a request id guard
  • How to explain shared controller cleanup on route changes
Practice more JavaScript interview questions
Interview answer drill

Use this drill to explain why debounce alone fails, when AbortController is enough, and when a request-id guard is still required.

How to prevent stale async UI

The core issue

Async race conditions happen when an older request or async task resolves after a newer one and overwrites fresh UI. Fix stale updates with AbortController cancellation, a latest request-id check, or takeLatest-style ownership so only the newest result can render.

This shows up in search, filters, autosave flows, and route-driven detail pages.

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

Classic search-as-you-type race condition; autosave and navigation races behave the same way.

How to prevent it

Use either real cancellation or a stale-result guard. Both prevent old responses from winning the race; choose based on whether the underlying async task can actually listen to cancellation.

Technique

How it works

Notes

AbortController

Abort the previous request and ignore its AbortError

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

Three reliable ways to avoid stale updates.

Before / after: stale search UI

The bug is easiest to see in search-as-you-type UI. If the user types rea and then react, the older rea request can still finish last and overwrite the newer screen.

JAVASCRIPT
async function search(query) {
  setStatus('loading');

  const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
  const data = await res.json();

  renderResults(query, data);
  setStatus('idle');
}

search('rea');
search('react');
// If 'rea' resolves last, stale results overwrite the 'react' UI.
                  
JAVASCRIPT
let requestId = 0;

async function search(query) {
  const id = ++requestId;
  setStatus('loading');

  const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
  const data = await res.json();

  if (id !== requestId) return; // stale result
  renderResults(query, data);
  setStatus('idle');
}
                  
JAVASCRIPT
let controller;

async function search(query) {
  if (controller) controller.abort();
  controller = new AbortController();

  setStatus('loading');

  try {
    const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    });
    const data = await res.json();
    renderResults(query, data);
    setStatus('idle');
  } catch (error) {
    if (error.name === 'AbortError') return;
    setStatus('error');
    reportSearchError(error);
  }
}
                  

React useEffect cleanup version

In React, the same race often appears inside useEffect. A cleanup flag prevents stale setState writes after props or state change. It does not cancel the network request by itself; use AbortController too when the fetch should actually be stopped.

JAVASCRIPT
useEffect(() => {
  let ignore = false;
  setBio(null);

  fetchBio(person).then((result) => {
    if (!ignore) setBio(result);
  });

  return () => {
    ignore = true;
  };
}, [person]);
                  

Choosing the right guard

Pick the guard by ownership and cancellation support, not by syntax preference.

Guard

Use it when

Trade-off

AbortController

The async API accepts AbortSignal, especially fetch

Requires AbortError handling; only cancels work that listens to the signal

Request id

You need a framework-agnostic latest-result guard

Old work still runs, but cannot update the UI

useEffect cleanup

React state updates depend on changing props or state

Stops stale setState, but does not cancel the request by itself

takeLatest / switchMap

Stream or action ownership should replace older work

Best with RxJS/state tools; hide the policy in a reusable operator

Latest version guard

Autosave, IndexedDB, or library promises cannot be aborted

Requires a durable version token for every write

Different async ownership problems need different stale-result guards.

When the async work cannot be aborted

AbortController only helps when the underlying task listens to AbortSignal. This is the important production case: autosave pipelines, IndexedDB work, and some library promises may keep running, so they still need a latest-version guard before showing saved state or writing UI metadata.

JAVASCRIPT
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.

Test sketch

Use the same rea/react race from the before/after example: resolve the newer request first, then resolve the older request, and assert that the UI still shows the newer result. This sketch assumes a test helper named createSearchView() with controllable requests and resolveWith() methods. Those helpers are not included; implement them around your application's search flow before running the test. The predefined timelines below illustrate the expected outcomes for each strategy.

JAVASCRIPT
test('ignores an older search response after a newer one wins', async () => {
  const view = createSearchView();

  const older = view.search('rea');
  const newer = view.search('react');

  await newer.resolveWith(['React results']);
  expect(view.results()).toEqual(['React results']);

  await older.resolveWith(['Real estate']);
  expect(view.results()).toEqual(['React results']);
});
                  

Guard every UI state change

Check ownership before updating results, errors, loading state, or save indicators. An older request's catch or finally handler can still clear a newer request's loading state or replace its error message even when the success handler guards its result. Invalidate the previous owner when a new intent or route change makes its work obsolete, and include late failures as well as late successes in your application's tests.

Production debugging standard

Debounce is a traffic optimization, not a correctness guarantee. Treat the bug as fixed only when older completions cannot update state after a newer request, route load, or autosave version has taken ownership.

Async race scenarios

Compare predefined timelines for the same out-of-order responses. Each strategy shows an illustrative outcome and an example assertion to adapt to your tests.

  1. 1
    rea request startsA starts

    The user types 'rea' and request A begins.

  2. 2
    react request startsB starts

    The user types 'react' and request B becomes the newer intent.

  3. 3
    react resolvesfresh result

    Request B resolves first and renders the correct 'react' results.

  4. 4
    rea resolves lastolder completion

    Request A is older, but it still reaches the completion handler.

  5. 5
    UI write allowedstale write

    No ownership check blocks A, so stale 'rea' results overwrite the screen.

Timeline

A: 'rea' starts -> B: 'react' starts -> B resolves -> A resolves last.

Final UI

Stale UI: results for 'rea' overwrite the newer 'react' results.

Why it happens

Nothing checks whether request A is still current, so the older completion can write state after request B.

Example assertion

A failing test would show final results equal to stale data after the older promise resolves.

Predefined request timelinesIllustrative state outcomesExample assertions to adapt

Guides
Preparing for interviews?