You probably have a race condition

Published on

Intro

I've been working on a project that uses react hooks and I've been using the useEffect hook to fetch data from an API. The API call contains a customerId that the user selects from a dropdown. The effect looks something like this:

useEffect(() => {
  const fetchData = async () => {
    const result = await fetch(
      `https://example.com/api/data?id=${customerId}`
    );
    const data = await result.json();
    setData(data);
  };
  fetchData();
}, [customerId]);

This works great on localhost with a stable and fast wifi connection, but the real world is a bit messier. Users’ networks are jittery and inconsistent. A cloud may pass over a satellite, a user may walk between two access points, or a user may just have a slow connection. On the server side we might have a noisy-neighbor, a blip requiring a retry, or some other random source of latency. In these cases, the user may change the dropdown selection before the API call has finished. This has potential to cause a race condition!

Race conditions overview

diagram of race condition

A race condition happens when two requests are made and they come back in a different order than we expect. For example, request 1 is made and there is 500ms of latency due to some environmental reason. Request 2 is made and this one is much faster, only 150ms. Request 2 will resolve, then a short time later request 1 will resolve. If we don't handle this case in our code we will end up with the wrong data. Depending on the situation, this could be a minor annoyance or a major security issue, for example sending an invoice to the wrong customer on behalf of your user.

In the UI it looks like this:

Solving the race condition

When the user changes the dropdown, we should halt (abort) the in-flight request. The browser makes this easy by using the new AbortController() constructor. An AbortController allows us to programmatically halt any fetch requests, whenever we want. In this case, we want to stop the request for customer 1 since the user has changed the dropdown to customer 2. We can modify the code snippet to do this:

useEffect(() => {
  const controller = new AbortController();

  const fetchData = async () => {
    try {
      const result = await fetch(
        `https://example.com/api/data?id=${customerId}`, {
          signal: controller.signal
        }
      );
      const data = await result.json();
      setData(data);
    } catch (error) {
      if (error.name !== 'AbortError') {
        // Handle error, if needed
        console.error('Error:', error);
      }
    }
  };

  fetchData();

  return () => controller.abort();
}, [customerId]);

Notice how we’re constructing the controller, passing the result as a signal to the fetch call, and then using the same controller in the effect’s clean up function. Calling the controller.abort() method in the clean up function will abort the in-flight request for customer 1 🎉.

After making these changes, the UI is now fixed. The app state will now stay in sync with the user’s expectations:

Considerations

When we make use of the AbortController the browser will throw a specific error. We can handle these errors by excluding those with name AbortError. That will allow us to handle only true errors like 400…500 level errors (ex. bad or unauthorized requests, or server unavailable) and avoid mistakenly marking this service as degraded.

Conclusion

Using only a few more lines of code we can make our apps more resilient. Now when a user has a network issue or our server hits unexpected latency we can make sure the UI reflects the user’s expectations. We will now have a better user experience and a more secure app!

Useful links