Cancelling async tasks with AbortController

AbortController is a built-in DOM class used to cancel fetches, but it can be used in other ways too

· 3 min read · Last updated:
A stop sign on a street

I learned the other day that AbortController can be used to not only abort fetches, but can be used in basically any way you like. Just like promises can be used to represent any future, pending value, AbortController can be used as a controller to stop pending async operations. This was exciting to me, which I realize probably comes off sad sounding and means that I need more excitement in my life but–whatever–that’s besides the point.

The typical usage of AbortController that I was aware of was using it with fetch like this:

let abortController = new AbortController();
fetch('/url', { signal: abortController.signal });
// NOTE that an abortController's signal can associated with multiple fetches too
abortController.abort();

Calling the abort method cancels the HTTP request and rejects the promise. How exactly an AbortController.signal is consumed wasn’t clear to me but it turns out it’s pretty simple.

AbortControllerSignals implement the DOM’s EventTarget interface, or in other words, are an event emitter with an addEventListener method. This allows us to use it like this:

let abortController = new AbortController();
abortController.signal.addEventListener('abort', (event) => {
  // implement your canceling logic here
})

So let’s write a function that does an async task that allows the caller to pass an AbortControllerSignal to it to cancel it. I’ve occasionally had reason to create a function that basically promisifies setTimeout in actual code, so we’ll use that as our example:

Why does this matter?

I think there’s a lot of value in using standardized patterns. For example, Promises gave us a standard way to represent future values. There are alternatives to native Promises, some of those approaches are arguably better, but they’re not standard. Any time you consume code that uses non-standard approaches, there’s some conflict. How do I integrate this into existing code that uses different patterns? Do other people on my team need to learn new things now to properly leverage it? Or if you’re writing a library, there’s the dependency cost to consider. That’s why the idea of a standard way of aborting an asynchronous operation that’s baked into the platform seems valuable to me. Currently AbortController is a DOM only thing, but there’s a proposal to bring fetch into Node.js, which would probably mean bringing it over there as well.

What do you do when the async task can’t be aborted?

Not all async operations are abortable, for example, Notification.requestPermission(), and promises don’t have any sort of way to unsubscribe callbacks you’ve passed to then or catch (I’m not even sure that they should). This could be problematic in a use case like this that I made up:

function requestNotificationPermission(opts = {}) {
  return fetch('/user/settings', { signal: opts.signal })
    .then(res => res.json())
    .then(({ canNotify }) => {
      if (canNotify) {
        // no way to abort this async task
        return Notification.requestPermission();
      } else {
        throw new Error()
      }
    });
}

let abortController = new AbortController();

requestNotificationPermission({ signal: abortController.signal })
  .then(/**/)
  .catch(/**/);

// whenever and where ever this is called, I should be able to expect that
// the promise above will be rejected... but that's not the case
abortController.abort();

In the above example, the abort() will cancel the fetch if it’s in-flight, but if it’s already resolved, then the promise will resolve and go on to the next call, Notification.requestPermission, where there’s no way of canceling it. So the promise returned by requestNotificationPermission might resolve even though I’ve aborted it. Just depends on where you are in that series of async tasks. So how do we fix that?

For this use case, this is the best I can think of:

function requestNotificationPermission(opts = {}) {
  let isAborted = false;
  let onAbort = () => isAborted = true;
  opts.signal.addEventListener('abort', onAbort);
  return fetch('/user/settings', { signal: opts.signal })
    .then(res => res.json())
    .then(({ canNotify }) => {
      if (canNotify) {
        // no way to abort this async task
        return Notification.requestPermission();
      } else {
        throw new Error();
      }
    })
    // but if we got the signal, we can still reject
    // the promise and make our API consistent
    .then(result => {
      if (isAborted) {
        throw new Error();
      } else {
        return result;
      }
    })
    // remember to remove the abort event listener
    // so you don't leak the callback
    .finally(() => opts.signal.removeEventListener('abort', onAbort));
}
// ...

Conclusion

While using AbortController is a bit clunky, I appreciate that it provides a standardized, composable solution to cancel async tasks.