stop sign
Pixabay at Pexels

Performing asynchronous tasks can be hard, especially when a particular programming language does not allow for canceling started or no longer needed actions. Fortunately, JavaScript offers a useful piece of functionality for aborting an asynchronous activity. In this article, you can learn how to use it to create your abortable function.

The Abort signal

The need to cancel asynchronous tasks emerged shortly after introducing Promise into ES2015 and the appearance of several Web APIs supporting the new asynchronous solution. The first attempt focused on creating a universal solution that could later become a part of the ECMAScript standard. Discussions quickly became stuck without solving the problem. Due to that, WHATWG prepared its solution and introduced it directly into the DOM in the form of AbortController. The downside of such resolution is the fact that AbortController is not available in Node.js, leaving this environment without any official way to cancel asynchronous tasks.

As you can see in the DOM specification, AbortController is described in a very general way. Thanks to this, you can use it in any asynchronous APIs — even ones that do not exist yet. At the moment, only Fetch API officially supports it, but nothing stops you from using it inside your own code!

Let us spend a moment analyzing how AbortController works:

const abortController = new AbortController(); // 1
const abortSignal = abortController.signal; // 2

fetch( 'http://example.com', {
  signal: abortSignal // 3
} ).catch( ( { message } ) => { // 5
  console.log( message );
} );

abortController.abort(); // 4

Looking at the code above, you can see that at the beginning, you create a new instance of the AbortController DOM interface (1) and bind its signal property to a variable (2). Then you invoke fetch() and pass signal as one of its options (3). To abort fetching the resource, you call abortController.abort() (4). It rejects the promise of fetch(), and the control passes to the catch() block (5).

The signal property itself is quite interesting. The property is an instance of the AbortSignal DOM interface that has an aborted property with information whether the user has already invoked the abortController.abort() method. You can also bind the abort event listener to it when abortController.abort() gets called. In other words: AbortController is just a public interface of AbortSignal.

The Abortable function

Let us imagine that you have an asynchronous function that does some very complicated calculations (for example, it asynchronously processes data from a big array). The sample function simulates the hard work by waiting five seconds before returning the result:

function calculate() {
  return new Promise( ( resolve, reject ) => {
    setTimeout( ()=> {
      resolve( 1 );
    }, 5000 );
  } );
}

calculate().then( ( result ) => {
  console.log( result );
} );

However, sometimes the user would want to abort such a costly operation. Add a button that starts and stop the calculation:

<button id="calculate">Calculate</button>

<script type="module">
  document.querySelector( '#calculate' ).addEventListener( 'click', async ( { target } ) => { // 1
    target.innerText = 'Stop calculation';

    const result = await calculate(); // 2

    alert( result ); // 3

    target.innerText = 'Calculate';
  } );

  function calculate() {
    return new Promise( ( resolve, reject ) => {
      setTimeout( ()=> {
        resolve( 1 );
      }, 5000 );
    } );
  }
</script>

In the code above, you add an asynchronous click event listener to the button (1) and call the calculate() function inside it (2). After five seconds, the alert dialog with the result appears (3). Additionally, script[type=module] is used to force JavaScript code into strict mode — as it is more elegant than the ‘use strict’ pragma.

Now add the ability to abort an asynchronous task:

{ // 1
  let abortController = null; // 2

  document.querySelector( '#calculate' ).addEventListener( 'click', async ( { target } ) => {
    if ( abortController ) {
      abortController.abort(); // 5

      abortController = null;
      target.innerText = 'Calculate';

      return;
    }

    abortController = new AbortController(); // 3
    target.innerText = 'Stop calculation';

    try {
      const result = await calculate( abortController.signal ); // 4

      alert( result );
    } catch {
      alert( 'WHY DID YOU DO THAT?!' ); // 9
    } finally { // 10
      abortController = null;
      target.innerText = 'Calculate';
    }
  } );

  function calculate( abortSignal ) {
    return new Promise( ( resolve, reject ) => {
      const timeout = setTimeout( ()=> {
        resolve( 1 );
      }, 5000 );

      abortSignal.addEventListener( 'abort', () => { // 6
        const error = new DOMException( 'Calculation aborted by the user', 'AbortError' );

        clearTimeout( timeout ); // 7
        reject( error ); // 8
      } );
    } );
  }
}

As you can see, the code has become much longer. But it has not become much harder to understand!

Everything is inside the block (1), which is an equivalent of IIFE. Thanks to this, the abortController variable (2) doesn’t leak into the global scope.

At first, you set its value to null. This value changes at the mouse click on the button. Then you set its value to a new instance of AbortController (3). After that, you pass the instance’s signal property directly to your calculate() function (4).

If the user clicks the button again before five seconds elapsed, it causes the invocation of the abortController.abort() function (5). This fires the abort event on the AbortSignal instance you passed earlier to calculate() (6).

Inside the abort event listener, you remove the ticking timer (7) and reject the promise with an appropriate error (8; according to the specification, it must be a DOMException with an ‘AbortError’ type). The error eventually passes control to catch (9) and blocks (10).

You should also prepare your code to handle a situation like this:

const abortController = new AbortController();

abortController.abort();
calculate( abortController.signal );

In such a case, the abort event doesn’t fire because it happens before passing the signal to the calculate() function. Let’s refactor it a little:

function calculate( abortSignal ) {
  return new Promise( ( resolve, reject ) => {
    const error = new DOMException( 'Calculation aborted by the user', 'AbortError' ); // 1

    if ( abortSignal.aborted ) { // 2
      return reject( error );
    }

    const timeout = setTimeout( ()=> {
      resolve( 1 );
    }, 5000 );

    abortSignal.addEventListener( 'abort', () => {
      clearTimeout( timeout );
      reject( error );
    } );
  } );
}

The error moves to the top (1). You can reuse it in two different parts of the code (yet it would be more elegant to create a factory of errors). Additionally, a guard clause appeared, checking the value of abortSignal.aborted (2). If it equals true, the calculate() function rejects the promise with an appropriate error without doing anything further.