JonathanSvärdén

Debounced fetch with Abort Controller

🕒 3 min read
Debouncing an input that makes network requests while the user's typing is a no-brainer, but dealing with any errant requests that slipped away can be a little trickier. There are three good reasons for doing this:

1. Being a good API consumer

The point of debouncing is to not hammer the endpoint we're calling with tons of requests we're going to discard anyway.

2. Saving on our users' data bills

We want to be respectful of our users' data plans by not having them download data that is not needed.

3. Avoiding race conditions between multiple in-flight requests

Requests made in a certain order are not guaranteed to be received in that same order. If we can avoid dealing with that complexity, our application becomes easier to maintain.

Without further ado, here's a little demo you can try out. Just type anything in the box, then, while it's loading, continue typing. If you're on desktop you can have a look in the devtools as well to see the requests being canceled when you resume typing while the request is still pending.

This is a great use-case for the AbortController browser API. The following code sample is written in React but the same principles apply to any library or framework you're using (if any).

First off, we need to store a stable reference to the debounce timer and the AbortController itself, so bring out useRef.

const debounceTimer = useRef(0);
const controller = useRef();

In our debounce function, we first clear our timer, then proceed to invoke the controller's abort method and immediately assign it to a new AbortController. This is because once abort has been called, that instance of AbortController is used up.

const debouncedFetch = (orderNumber) => {
  clearTimeout(debounceTimer.current);

  controller.current?.abort();
  controller.current = new AbortController();

When making our API call, we send along the signal property in our fetch options, set to the controller's signal. This associates our AbortController with this particular request, and makes it so that the call to abort above actually does something.

  debounceTimer.current = setTimeout(async () => {
    try {
      setLoading(true);
      const order = await fetch(`/api/orders?orderNo=${orderNumber}`, {
        method: 'GET',
        signal: controller.current.signal
      });

      setOrder(order);
    } catch (err) {
      if (err instanceof Error && err.name !== 'AbortError') {
        // Handle any error not caused by the Abort Controller aborting the request
      }
    } finally {
      setLoading(false);
    }
  }, debounceDelay);
};

One thing to note above is that the AbortController's cancellation of the request is technically considered an error, meaning you'll have to inspect the name property of the error in your catch block, to see if the error is an AbortError. You don't want to show any error messages to the user when the request is canceled.

To see it all put together, get the complete code for the demo in this post in this Gist.