Designing Resilient JavaScript Systems: Handling Race Conditions in Asynchronous Code

Learn how to identify and handle race conditions in asynchronous JavaScript code to build more reliable and resilient applications.

Asynchronous JavaScript allows your programs to be non-blocking and efficient. However, it can also introduce issues like race conditions, which occur when two or more asynchronous operations try to update or use the same data concurrently, leading to unpredictable results.

In this article, we'll explain what race conditions are and provide beginner-friendly examples and techniques to avoid them in your JavaScript code.

### What is a Race Condition?

A race condition happens when multiple pieces of asynchronous code access and modify shared data at the same time, and the final outcome depends on the order in which they complete. Because JavaScript is single-threaded but can run async tasks concurrently, race conditions often happen when dealing with APIs, timers, or other async operations.

### Example of a Race Condition

Imagine you want to fetch user data from two different sources and update the UI with the first response you get:

javascript
let userData;

fetch('/api/user')
  .then(response => response.json())
  .then(data => {
    userData = data;
    console.log('User data updated from /api/user:', userData);
  });

fetch('/backup-api/user')
  .then(response => response.json())
  .then(data => {
    userData = data;
    console.log('User data updated from /backup-api/user:', userData);
  });

Because both fetch requests run asynchronously, whichever finishes last will overwrite the value of `userData`. This is a race condition because the result depends on network speed or server response time, causing unpredictable final data.

### How to Prevent Race Conditions

One effective way to handle race conditions is to "cancel" or ignore outdated asynchronous operations when a newer one starts or completes first.

#### 1. Use a Token or Versioning Approach

Keep track of the latest request version and ignore results of outdated requests:

javascript
let lastRequestId = 0;
let userData;

function fetchUserData() {
  const currentRequestId = ++lastRequestId;

  fetch('/api/user')
    .then(response => response.json())
    .then(data => {
      if (currentRequestId === lastRequestId) {
        userData = data;
        console.log('User data updated:', userData);
      } else {
        console.log('Discarded outdated response');
      }
    });
}

// Call fetchUserData multiple times, only latest response is used
fetchUserData();
fetchUserData();

#### 2. Use Async/Await with `Promise.race`

You can also use `Promise.race` to only handle the fastest response:

javascript
async function fetchFastestUserData() {
  const fetch1 = fetch('/api/user').then(res => res.json());
  const fetch2 = fetch('/backup-api/user').then(res => res.json());

  try {
    const userData = await Promise.race([fetch1, fetch2]);
    console.log('Fastest user data:', userData);
  } catch (err) {
    console.error('Fetch failed:', err);
  }
}

fetchFastestUserData();

This approach ensures you take the first completed fetch, preventing race conditions between the two requests.

#### 3. Use Mutex or Locking (Advanced)

More complex systems might require mutex (mutual exclusion) to ensure only one async operation modifies shared state at a time. JavaScript libraries like `async-mutex` can help with this for advanced use cases.

### Summary

Race conditions in JavaScript asynchronous code are a common source of bugs, but by understanding when and how they occur, you can design systems that handle async data safely. Start with simple techniques like version checks or using `Promise.race`, and for complex needs, consider locking mechanisms.

By writing resilient asynchronous code, your JavaScript applications will be more predictable, stable, and easier to maintain.