« See all

Aborting a signal: How to cancel an asynchronous task in JavaScript

Signal abortion

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

# 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. However, discussions quickly became stuck without solving the problem. Due to that WHATWG prepared their own solution and introduced it directly into the DOM in the form of AbortController. The obvious downside of such resolution is the fact that AbortController is not available in Node.js, leaving this environment without any elegant or 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 kind of 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!

But before jumping to it, 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 just call abortController.abort() (4). It will automatically reject the promise of fetch() and the control will be passed to the catch() block (5).

The signal property itself is quite interesting and it is the main star of this show. 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 that will be called when abortController.abort() is called. In other words: AbortController is just a public interface of AbortSignal.

# 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). To keep it simple, the sample function will be simulating 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. And rightly so — they should have such an ability. Add a button that will start 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 will appear (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 there is no reason to panic — it has not become much harder to understand!

Everything is enclosed inside the block (1), which is an equivalent of IIFE. Thanks to this, the abortController variable (2) will not 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 will cause the invocation of the abortController.abort() function (5). This, in turn, will fire 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 finally 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 case, the abort event will not be fired because it happened before passing the signal to the calculate() function. Due to this, you should 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 was moved to the top (1). Thanks to this, you can reuse it in two different parts of the code (yet it would be more elegant to just create a factory of errors, however silly it sounds). 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.

And this is how you can create a fully abortable asynchronous function. The demo is available online. Enjoy!

Share this post

Linkedin Reddit

Discuss on

Reddit
Comments outside editor? CKEditor 5 Collaboration Features v17.0.0 with new comments API and new possibilities
CKEditor 4.14 with Paste from LibreOffice released