TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

How to Fix JavaScript Async Race Conditions

Practical insights, clear explanations, and useful ideas for understanding the web.

October 26, 20266 min read5 topics
How to Fix JavaScript Async Race Conditions
Explore the details
#JavaScript#Async JavaScript#Race Conditions#AbortController#Fetch API
October 26, 20266 min read

A search box can show results for an old query even after a newer query has finished loading. This is a JavaScript async race condition: multiple operations run concurrently, and their completion order differs from their start order. Learn how to reproduce the problem and prevent stale results with AbortController, request identifiers, and controlled-delay tests.

Key Takeaways

  • Async requests can finish in a different order from the order they were started.
  • Use AbortController to cancel work that supports cancellation, such as a fetch request.
  • Use a request ID or stale-result guard to stop outdated work from updating the UI—even if it cannot be cancelled.
  • Debouncing reduces how many requests start, but does not guarantee they finish in order.

How an out-of-order search response happens

Suppose someone types ca and then quickly changes the query to cat. The app starts both requests. If the cat request finishes first, its results appear. If the slower ca request then finishes and updates the same results area, the interface shows results for the old query.

The issue is not that JavaScript executes two callbacks simultaneously. Rather, each operation can pause while waiting for a response, and its continuation runs when that response arrives. The order of those continuations may not match the order in which the operations began.

A controlled-delay example makes the problem visible:

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

async function fakeSearch(query) {
  // Simulate the older query taking longer than the newer one.
  await wait(query === "ca" ? 250 : 40);
  return `Results for ${query}`;
}

async function runBrokenExample() {
  let displayedResults = "";

  async function searchAndRender(query) {
    const results = await fakeSearch(query);
    displayedResults = results;
    console.log("Rendered:", displayedResults);
  }

  void searchAndRender("ca");
  void searchAndRender("cat");

  await wait(300);
  console.log("Final display:", displayedResults);
}

runBrokenExample();

The cat result appears first, but the slower ca result overwrites it. The final display is Results for ca, even though cat was the most recent query.

Fix 1: Abort the previous fetch

For requests made with fetch, an AbortController can signal that a request should be aborted. Create a new controller for each request, and abort the previous one when a new query arrives. The following browser example also checks a request ID so an outdated result cannot update the UI if other work finishes after cancellation.

const input = document.querySelector("#search");
let activeController;
let latestRequestId = 0;

async function search(query) {
  const requestId = ++latestRequestId;

  activeController?.abort();
  activeController = undefined;

  if (!query) {
    renderResults([]);
    return;
  }

  const controller = new AbortController();
  activeController = controller;

  try {
    const url = new URL("/api/search", window.location.origin);
    url.searchParams.set("q", query);

    const response = await fetch(url, { signal: controller.signal });
    if (!response.ok) {
      throw new Error(`Search failed: HTTP ${response.status}`);
    }

    const results = await response.json();

    if (requestId !== latestRequestId) return;
    renderResults(results);
  } catch (error) {
    if (requestId !== latestRequestId) return;
    if (error.name === "AbortError") return;
    showSearchError(error);
  } finally {
    if (activeController === controller) {
      activeController = undefined;
    }
  }
}

input.addEventListener("input", () => {
  search(input.value.trim());
});

This example assumes the page provides #search, renderResults, and showSearchError, and that /api/search returns JSON. Check response.ok: fetch does not reject just because the server returned an HTTP error status. The request ID also guards the error path, preventing an older failure from replacing the state for a newer query.

An abort signal is useful for stopping supported client-side work, but it is not a guarantee that the server stopped processing a request that it already received. Give each request its own controller: an aborted signal cannot be reused for a new fetch. Handle AbortError separately from genuine failures so expected cancellations do not appear as user-facing errors.

Fix 2: Ignore stale results with a request ID

If the operation cannot be cancelled, keep a counter and update the UI only when the completing operation still has the latest ID. This also works for promises from libraries or calculations that do not accept an abort signal.

let latestRequestId = 0;

async function searchAndRender(query) {
  const requestId = ++latestRequestId;
  const results = await fakeSearch(query);

  if (requestId !== latestRequestId) return;
  console.log("Rendered:", results);
}

async function runGuardedExample() {
  void searchAndRender("ca");
  void searchAndRender("cat");
  await wait(300);
}

runGuardedExample();

With the same controlled delays, only Results for cat is rendered. The ID check does not cancel the older operation; it prevents that operation from changing the UI. Apply the guard anywhere a result, error, loading indicator, or other shared state could be updated by stale work.

Test race conditions with controlled delays

Avoid relying on random network timing to reproduce an ordering bug. Use a test double that returns promises after known delays, as in fakeSearch, or use manually controlled promises in a test. Make the older request slower than the newer one, start both, then check that the final rendered state belongs to the latest request.

Also test these cases:

  • A request is aborted while waiting for its response.
  • The latest request fails after an older request has been superseded.
  • The input is cleared while a request is in flight.
  • A response body is still being read when cancellation occurs.
  • A non-cancellable operation completes after a newer operation.

The fake-delay example demonstrates ordering logic, not actual network cancellation. Test the AbortController behavior separately with your application’s fetch or request layer.

Common Mistakes

  • Assuming completion order matches start order. Network and other asynchronous work can take different amounts of time.
  • Treating debounce as a race-condition fix. Debouncing can reduce request volume, but two requests that do start may still complete out of order.
  • Aborting without guarding UI updates. Cancellation is not supported by every operation, and it does not guarantee that work already received by a server stops. Use a stale-result guard when correctness depends on showing only the latest result.
  • Sharing an AbortController across requests. Once its signal is aborted, it stays aborted. Create a fresh controller for each request.
  • Ignoring stale errors or cleanup. An older request can overwrite a newer error or clear a loading state at the wrong time. Guard all shared UI updates, including those in catch and finally blocks.

Best Practices

  • Choose cancellation when it is supported and avoiding unnecessary work matters; choose a request ID when the operation cannot be cancelled. Combining both is appropriate for search interfaces.
  • Treat clearing the input as a new state: invalidate or abort pending work and clear displayed results.
  • Keep request-specific state updates together, and check that the request is still current before applying them.
  • Use controlled delays or deferred promises to test ordering deterministically.

Conclusion

JavaScript async race conditions occur when overlapping work completes out of order and stale work updates shared state. Abort obsolete fetches where possible, and use request IDs or stale-result guards to protect UI updates. Test the behavior with deliberately controlled completion times rather than hoping a race appears on a live network.

References