How to Prevent Race Conditions in JavaScript with AbortController

Hero image for How to Prevent Race Conditions in JavaScript with AbortController. Image by milan degraeve.
Hero image for 'How to Prevent Race Conditions in JavaScript with AbortController.' Image by milan degraeve.

Race conditions are one of the first JavaScript bugs that make newer frontend developers realise async code is not only about getting data eventually. It is also about getting the right data at the right time.

The classic version is a search box. The user types c, then ca, then cat. Three requests go out. The newest request should win, but the network does not care what feels newest to the user. If the older request finishes last, it can overwrite the better result unless your code guards against it.

That is a race condition in a very ordinary frontend form.

For a long time, developers often solved this by ignoring stale responses after they arrived. That works up to a point. AbortController gives us a cleaner option for fetch: cancel old requests when they are no longer relevant.


The Problem is Not That Requests are Asynchronous

Asynchronous requests are normal and good. The real problem is assuming they will finish in the order you started them.

They often do not.

Imagine this:

const search = async (query: string): Promise<void> => {  const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);  const results = await response.json();  renderResults(results);};

That looks harmless until it is called repeatedly during fast typing. Once several requests are in flight together, whichever one finishes last gets the final say, even if it belongs to an older query.

That is how stale data ends up on screen.


Ignoring Stale Responses is One Fix

A common defensive pattern is to tag requests and ignore old responses.

let latestRequestId = 0;const search = async (query: string): Promise<void> => {  latestRequestId += 1;  const requestId = latestRequestId;  const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);  const results = await response.json();  if (requestId !== latestRequestId) {    return;  }  renderResults(results);};

That works because only the newest request is allowed to update the UI.

The limitation is that the older requests still run to completion. They still consume bandwidth, still use browser resources, and still force your code to process a response only to throw it away.

That is where request cancellation becomes attractive.


What AbortController gives you

AbortController lets you create an AbortSignal and pass it into a fetch request. Later, if you decide that request is no longer relevant, you can abort it.

The shape looks like this:

const controller = new AbortController();fetch('/api/search?q=cat', {  signal: controller.signal,});controller.abort();

Once aborted, the pending fetch rejects rather than continuing as if nothing happened.

That makes it a very practical fit for userdriven interfaces where the newest input should invalidate older work.


A Realistic Search Example

Here is the most common pattern.

Keep the latest controller somewhere outside the search function:

let activeController: AbortController | undefined;const search = async (query: string): Promise<void> => {  activeController?.abort();  const controller = new AbortController();  activeController = controller;  try {    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {      signal: controller.signal,    });    const results = await response.json();    renderResults(results);  } catch (error) {    if (error instanceof DOMException && error.name === 'AbortError') {      return;    }    throw error;  }};

Now, every new search aborts the previous request before starting the next one.

That solves two problems at once:

  • old requests are less likely to waste work
  • stale responses are less likely to update the UI

Handling the Aborted Request Matters

Once you start aborting fetches, you need to handle the fact that aborted requests reject.

That is why the catch block matters in the example above. If you treat an intentional abort as if it were a real failure, you can end up showing the user an error message for a request you meant to cancel.

The general rule is:

  • ignore AbortError
  • handle genuine network or parsing errors normally

That keeps cancellation quiet and intentional instead of noisy and misleading.


AbortController helps with stale UIs, not every async bug

It is worth being precise about what this tool does.

AbortController helps when:

  • you are using fetch
  • older requests should stop mattering
  • the UI should reflect the latest user intent only

It does not automatically solve:

  • race conditions in arbitrary promise chains
  • CPUheavy synchronous work
  • business logic that updates shared state incorrectly

In other words, it is a good tool for request cancellation, not a universal cure for all concurrency problems.


You Still Need to Think About Sequencing

Even with cancellation, it helps to think through the flow of your UI.

For example:

  • what happens to the loading state when a request is aborted?
  • should an empty query abort and clear results?
  • what if a request finishes just before the next one is aborted?

AbortController improves the mechanics, but it does not replace clear state modelling.

That is an important frontend lesson more broadly. Async bugs are often not only about APIs. They are about the shape of the state transitions you allow in response to those APIs.


A Good Fit for Search, Filters, and Rapid Interactions

This pattern is particularly useful when users can trigger several requests quickly:

  • typeahead search
  • live filters
  • autosaving editors
  • tab switching
  • changing sort or pagination controls rapidly

In all of those cases, newer user intent should usually take precedence over older network work.

That is exactly the kind of scenario where request cancellation feels less like an optimisation and more like basic correctness.


Browser Support Deserves a Quick Thought

Because AbortController is newer than some older browser APIs, it is worth featuredetecting or planning a fallback if you support older browsers.

The fallback does not have to be elaborate. In many cases, the older "ignore stale response" pattern is still a sensible backup.

That means you do not need to choose between perfect cancellation and chaos. You can use AbortController where available and still protect the UI from stale results where it is not.


A Simpler Mental Model for Race Conditions

If you are new to this topic, here is the plainEnglish version:

  • several requests can be in flight at once
  • they may finish in any order
  • your UI must not assume oldest in, oldest out

Once you accept that model, tools like AbortController make immediate sense. They are not fancy extras. They are a way of aligning network behaviour with user intent.


Wrapping up

Race conditions in frontend JavaScript often happen when several requests are started close together and older ones arrive after newer ones. AbortController gives fetch a cleaner way to deal with that by cancelling stale requests before they can keep doing unnecessary work or overwrite fresher UI state.

Key Takeaways

  • Async requests do not guarantee completion in the order they were started.
  • Race conditions often show up in live search, filters, and other fast UI interactions.
  • Ignoring stale responses works, but AbortController lets you cancel old fetch requests more directly.
  • Aborted requests reject, so AbortError should usually be handled quietly.
  • AbortController improves request correctness, but you still need sensible UI state logic around it.

If your interface is driven by the user's latest action, older requests should usually stop mattering. AbortController gives you a practical way to enforce that.


Categories:

  1. Development
  2. Front‑End Development
  3. Guides
  4. JavaScript