Handle Race Conditions in Async JavaScript Calls

Intermediate15 min interview
Skills tested:
Identifying when a race condition can occur in concurrent async operationsUsing a request token (incremented counter) to ignore stale responsesUsing AbortController to cancel in-flight fetch requests when they are supersededUsing Promise.all when multiple simultaneous requests must complete togetherUnderstanding why the last request to start is not always the last to finish

Advertisement

🧩 Scenario

In a real codebase, race conditions appear most commonly in search-as-you-type inputs, paginated data tables, and tab-switching where each user action triggers a new request. If a slower earlier request resolves after a faster later request, the UI ends up showing stale data that corresponds to an input the user already changed.

Architecture Walkthrough

Why Race Conditions Happen

Network requests take unpredictable time. Two requests started at different times can complete in the reverse order they were started. A search input that fires a new request on every keystroke will issue requests for each intermediate state: "r", "re", "rea", "reac", "react". If the "r" request is slow and resolves after the "react" request, the search results will show results for "r" even though the user typed "react". This stale result appears to jump backward.

The root cause is updating shared mutable state (the UI) inside a callback without verifying that the callback still corresponds to the current intent. Each callback only knows about its own request, not whether a newer one was started after it.

Request Token Pattern

The simplest fix is a monotonically incrementing request counter. Before firing each request, increment the counter and capture the current value in a local variable. After the response arrives, compare the captured value to the current counter. If they match, this is still the most recent request. If they differ, a newer request has been started and this response should be discarded without updating the UI.

This pattern requires zero external dependencies and works in any async context. It does not cancel the in-flight request (the network call still completes), but it prevents the stale data from reaching the UI.

AbortController: Cancel In-Flight Requests

The AbortController API provides actual request cancellation. Before starting a new request, call abort() on the previous controller. The browser will cancel the in-flight fetch, freeing the network connection and preventing the response callback from running. This is more efficient than the token pattern for expensive requests because it saves bandwidth and avoids processing a response that will be discarded.

The controller's signal must be passed to fetch in the options object. When abort() is called, the fetch Promise rejects with an AbortError. You should check err.name === 'AbortError' in the catch block and handle it separately from real errors, since abort is an expected part of the flow.


Key Code Explained

// Pattern 1: Request token — discard stale responses
let requestId = 0;

async function searchWithToken(query) {
  const currentId = ++requestId; // capture this request's ID

  const results = await fetchSearch(query);

  if (currentId !== requestId) {
    return; // a newer request was started — discard this response
  }

  updateSearchResults(results);
}

// Pattern 2: AbortController — cancel in-flight requests
let activeController = null;

async function searchWithAbort(query) {
  if (activeController) {
    activeController.abort(); // cancel the previous in-flight request
  }
  activeController = new AbortController();

  try {
    const res = await fetch(`/api/search?q=${query}`, {
      signal: activeController.signal,
    });
    const results = await res.json();
    updateSearchResults(results);
  } catch (err) {
    if (err.name === 'AbortError') return; // expected — not a real error
    throw err;
  }
}

// React: combine both patterns inside useEffect
function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    let cancelled = false; // token for this effect run
    const controller = new AbortController();

    async function load() {
      try {
        const res = await fetch(`/api/search?q=${query}`, {
          signal: controller.signal,
        });
        const data = await res.json();
        if (!cancelled) setResults(data); // only update if still relevant
      } catch (err) {
        if (err.name !== 'AbortError') throw err;
      }
    }

    load();

    return () => {
      cancelled = true; // prevent stale setState
      controller.abort(); // cancel the network request
    };
  }, [query]);
}

// Pattern 3: Promise.all — when all concurrent requests must complete together
async function loadDashboard(userId) {
  // Both requests run simultaneously; UI only updates when both succeed
  const [user, posts] = await Promise.all([
    fetchUser(userId),
    fetchPosts(userId),
  ]);
  renderDashboard({ user, posts });
}

The React useEffect pattern is the most important real-world form. The cleanup function runs before the next effect (when query changes) and on unmount, aborting the previous request and setting cancelled = true. This ensures neither the stale data nor the stale setResults call can reach the component's state.


Tradeoffs

PatternCancels network requestComplexityWorks outside React
Request tokenNoLowYes
AbortControllerYesModerateYes
React useEffect flagNo (unless combined)LowNo
Both combinedYesModerateBest of both

What Interviewers Actually Check

  • Whether you can explain why network requests do not resolve in the order they were started
  • Whether you can implement the request token pattern correctly
  • Whether you know AbortController and how to pass signal to fetch
  • Whether you know to handle AbortError separately in the catch block
  • Whether you can apply either pattern inside a React useEffect with proper cleanup

Follow-Up Questions

  1. How would you handle a race condition in a data table where clicking different rows fires different requests?
  2. What happens if you call controller.abort() after the fetch has already completed?
  3. How does React Query handle race conditions internally, and does it use AbortController?
  4. If a request is aborted, does the server still process the request or does it stop immediately?
  5. How would you write a custom useFetch hook that handles race conditions for any URL?

Common Candidate Mistakes

  • Assuming requests complete in the order they were started and not accounting for variable network latency
  • Updating UI state in the then callback without checking if the response is still the latest
  • Using a single isLoading boolean as the "fix" when it only prevents multiple simultaneous requests, not the ordering problem
  • Not knowing that AbortController.signal must be passed in the fetch options object
  • Not handling AbortError separately, causing abort-triggered catches to be treated as real errors and displayed to users

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you explain what a race condition is in the context of async data fetching?
  • Can you implement the request token pattern to discard stale responses?
  • Can you implement request cancellation using AbortController?
  • Can you explain when Promise.all is the right solution vs the cancellation token approach?
  • Can you apply either pattern inside a React useEffect with a cleanup function?

Summary

A race condition in async JavaScript occurs when multiple in-flight requests can resolve in any order and each resolution overwrites shared UI state. The result is that an older, slower request can overwrite the result of a newer, faster one, displaying stale data to the user.

Two patterns fix this. The request token pattern increments a counter before each request and checks the counter after the response arrives; if a newer request was started, the stale response is discarded without updating state. The AbortController pattern goes further by actually canceling the in-flight network request, saving bandwidth and connection resources.

In React, the correct implementation uses both a cleanup flag (cancelled = true) and an AbortController inside useEffect, with the cleanup function aborting the request and setting the flag. This ensures that when the dependency changes and the effect re-runs, neither the stale response body nor a stale setState call can reach the component.

Frequently Asked Questions

Does Promise.all fix race conditions?

For simultaneous requests that must all complete before acting, yes. But for sequential search requests where only the latest matters, you need a cancellation token instead.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement