'Promise - is it possible to force cancel a promise

I use ES6 Promises to manage all of my network data retrieval and there are some situations where I need to force cancel them.

Basically the scenario is such that I have a type-ahead search on the UI where the request is delegated to the backend has to carry out the search based on the partial input. While this network request (#1) may take a little bit of time, user continues to type which eventually triggers another backend call (#2)

Here #2 naturally takes precedence over #1 so I would like to cancel the Promise wrapping request #1. I already have a cache of all Promises in the data layer so I can theoretically retrieve it as I am attempting to submit a Promise for #2.

But how do I cancel Promise #1 once I retrieve it from the cache?

Could anyone suggest an approach?



Solution 1:[1]

Standard proposals for cancellable promises have failed.

A promise is not a control surface for the async action fulfilling it; confuses owner with consumer. Instead, create asynchronous functions that can be cancelled through some passed-in token.

Another promise makes a fine token, making cancel easy to implement with Promise.race:

Example: Use Promise.race to cancel the effect of a previous chain:

let cancel = () => {};

input.oninput = function(ev) {
  let term = ev.target.value;
  console.log(`searching for "${term}"`);
  cancel();
  let p = new Promise(resolve => cancel = resolve);
  Promise.race([p, getSearchResults(term)]).then(results => {
    if (results) {
      console.log(`results for "${term}"`,results);
    }
  });
}

function getSearchResults(term) {
  return new Promise(resolve => {
    let timeout = 100 + Math.floor(Math.random() * 1900);
    setTimeout(() => resolve([term.toLowerCase(), term.toUpperCase()]), timeout);
  });
}
Search: <input id="input">

Here we're "cancelling" previous searches by injecting an undefined result and testing for it, but we could easily imagine rejecting with "CancelledError" instead.

Of course this doesn't actually cancel the network search, but that's a limitation of fetch. If fetch were to take a cancel promise as argument, then it could cancel the network activity.

I've proposed this "Cancel promise pattern" on es-discuss, exactly to suggest that fetch do this.

Solution 2:[2]

With AbortController

It is possible to use abort controller to reject promise or resolve on your demand:

let controller = new AbortController();

let task = new Promise((resolve, reject) => {
  // some logic ...
  controller.signal.addEventListener('abort', () => {
    reject('oops'));
  }
});

controller.abort(); // task is now in rejected state

Also it's better to remove event listener on abort to prevent memory leaks

Same works for cancelling fetch:

let controller = new AbortController();
fetch(url, {
  signal: controller.signal
});

or just pass controller:

let controller = new AbortController();
fetch(url, controller);

And call abort method to cancel one, or infinite number of fetches where you passed this controller controller.abort();

Solution 3:[3]

I have checked out Mozilla JS reference and found this:

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race

Let's check it out:

var p1 = new Promise(function(resolve, reject) { 
    setTimeout(resolve, 500, "one"); 
});
var p2 = new Promise(function(resolve, reject) { 
    setTimeout(resolve, 100, "two"); 
});

Promise.race([p1, p2]).then(function(value) {
  console.log(value); // "two"
  // Both resolve, but p2 is faster
});

We have here p1, and p2 put in Promise.race(...) as arguments, this is actually creating new resolve promise, which is what you require.

Solution 4:[4]

For Node.js and Electron, I'd highly recommend using Promise Extensions for JavaScript (Prex). Its author Ron Buckton is one of the key TypeScript engineers and also is the guy behind the current TC39's ECMAScript Cancellation proposal. The library is well documented and chances are some of Prex will make to the standard.

On a personal note and coming from C# background, I like very much the fact that Prex is modelled upon the existing Cancellation in Managed Threads framework, i.e. based on the approach taken with CancellationTokenSource/CancellationToken .NET APIs. In my experience, those have been very handy to implement robust cancellation logic in managed apps.

I also verified it to work within a browser by bundling Prex using Browserify.

Here is an example of a delay with cancellation (Gist and RunKit, using Prex for its CancellationToken and Deferred):

// by @noseratio
// https://gist.github.com/noseratio/141a2df292b108ec4c147db4530379d2
// https://runkit.com/noseratio/cancellablepromise

const prex = require('prex');

/**
 * A cancellable promise.
 * @extends Promise
 */
class CancellablePromise extends Promise {
  static get [Symbol.species]() { 
    // tinyurl.com/promise-constructor
    return Promise; 
  }

  constructor(executor, token) {
    const withCancellation = async () => {
      // create a new linked token source 
      const linkedSource = new prex.CancellationTokenSource(token? [token]: []);
      try {
        const linkedToken = linkedSource.token;
        const deferred = new prex.Deferred();
  
        linkedToken.register(() => deferred.reject(new prex.CancelError()));
  
        executor({ 
          resolve: value => deferred.resolve(value),
          reject: error => deferred.reject(error),
          token: linkedToken
        });

        await deferred.promise;
      } 
      finally {
        // this will also free all linkedToken registrations,
        // so the executor doesn't have to worry about it
        linkedSource.close();
      }
    };

    super((resolve, reject) => withCancellation().then(resolve, reject));
  }
}

/**
 * A cancellable delay.
 * @extends Promise
 */
class Delay extends CancellablePromise {
  static get [Symbol.species]() { return Promise; }

  constructor(delayMs, token) {
    super(r => {
      const id = setTimeout(r.resolve, delayMs);
      r.token.register(() => clearTimeout(id));
    }, token);
  }
}

// main
async function main() {
  const tokenSource = new prex.CancellationTokenSource();
  const token = tokenSource.token;
  setTimeout(() => tokenSource.cancel(), 2000); // cancel after 2000ms

  let delay = 1000;
  console.log(`delaying by ${delay}ms`); 
  await new Delay(delay, token);
  console.log("successfully delayed."); // we should reach here

  delay = 2000;
  console.log(`delaying by ${delay}ms`); 
  await new Delay(delay, token);
  console.log("successfully delayed."); // we should not reach here
}

main().catch(error => console.error(`Error caught, ${error}`));

Note that cancellation is a race. I.e., a promise may have been resolved successfully, but by the time you observe it (with await or then), the cancellation may have been triggered as well. It's up to you how you handle this race, but it doesn't hurts to call token.throwIfCancellationRequested() an extra time, like I do above.

Solution 5:[5]

I faced similar problem recently.

I had a promise based client (not a network one) and i wanted to always give the latest requested data to the user to keep the UI smooth.

After struggling with cancellation idea, Promise.race(...) and Promise.all(..) i just started remembering my last request id and when promise was fulfilled i was only rendering my data when it matched the id of a last request.

Hope it helps someone.

Solution 6:[6]

See https://www.npmjs.com/package/promise-abortable

$ npm install promise-abortable

Solution 7:[7]

You can make the promise reject before finishing:

// Our function to cancel promises receives a promise and return the same one and a cancel function
const cancellablePromise = (promiseToCancel) => {
  let cancel
  const promise = new Promise((resolve, reject) => {
    cancel = reject
    promiseToCancel
      .then(resolve)
      .catch(reject)
  })
  return {promise, cancel}
}

// A simple promise to exeute a function with a delay
const waitAndExecute = (time, functionToExecute) => new Promise((resolve, reject) => {
  timeInMs = time * 1000
  setTimeout(()=>{
    console.log(`Waited ${time} secs`)
    resolve(functionToExecute())
  }, timeInMs)
})

// The promise that we will cancel
const fetchURL = () => fetch('https://pokeapi.co/api/v2/pokemon/ditto/')

// Create a function that resolve in 1 seconds. (We will cancel it in 0.5 secs)
const {promise, cancel} = cancellablePromise(waitAndExecute(1, fetchURL))

promise
  .then((res) => {
    console.log('then', res) // This will executed in 1 second
  })
  .catch(() => {
    console.log('catch') // We will force the promise reject in 0.5 seconds
  })

waitAndExecute(0.5, cancel) // Cancel previous promise in 0.5 seconds, so it will be rejected before finishing. Commenting this line will make the promise resolve

Unfortunately the fetch call has already be done, so you will see the call resolving in the Network tab. Your code will just ignore it.

Solution 8:[8]

Using the Promise subclass provided by the external package, this can be done as follows: Live demo

import CPromise from "c-promise2";

function fetchWithTimeout(url, {timeout, ...fetchOptions}= {}) {
    return new CPromise((resolve, reject, {signal}) => {
        fetch(url, {...fetchOptions, signal}).then(resolve, reject)
    }, timeout)
}

const chain= fetchWithTimeout('http://localhost/')
    .then(response => response.json())
    .then(console.log, console.warn);

//chain.cancel(); call this to abort the promise and releated request

Solution 9:[9]

Using AbortController

I've been researching about this for a few days and I still feel that rejecting the promise inside an abort event handler is only part of the approach.

The thing is that as you may know, only rejecting a promise, makes the code awaiting for it to resume execution but if there's any code that runs after the rejection or resolution of the promise, or outside of its execution scope, e.g. Inside of an event listener or an async call, it will keep running, wasting cycles and maybe even memory on something that isn't really needed anymore.

Lacking approach

When executing the snippet below, after 2 seconds, the console will contain the output derived from the execution of the promise rejection, and any output derived from the pending work. The promise will be rejected and the work awaiting for it can continue, but the work will not, which in my opinion is the main point of this exercise.

let abortController = new AbortController();
new Promise( ( resolve, reject ) => {
  if ( abortController.signal.aborted ) return;

  let abortHandler = () => {
    reject( 'Aborted' );
  };
  abortController.signal.addEventListener( 'abort',  abortHandler );

  setTimeout( () => {
    console.log( 'Work' );
    console.log( 'More work' );
    resolve( 'Work result' );
    abortController.signal.removeEventListener( 'abort', abortHandler );
  }, 2000 );
} )
  .then( result => console.log( 'then:', result ) )
  .catch( reason => console.error( 'catch:', reason ) );
setTimeout( () => abortController.abort(), 1000 );

Which leads me to think that after defining the abort event handler there must be calls to

if ( abortController.signal.aborted ) return;

in sensible points of the code that is performing the work so that the work doesn't get performed and can gracefully stop if necessary (Adding more statements before the return in the if block above).

Proposal

This approach reminds me a little about the cancellable token proposal from a few years back but it will in fact prevent work to be performed in vain. The console output should now only be the abort error and nothing more and even, when the work is in progress, and then cancelled in the middle, it can stop, as said before in a sensible step of the processing, like at the beginning of a loop's body

let abortController = new AbortController();
new Promise( ( resolve, reject ) => {
  if ( abortController.signal.aborted ) return;

  let abortHandler = () => {
    reject( 'Aborted' );
  };
  abortController.signal.addEventListener( 'abort',  abortHandler );

  setTimeout( () => {
    if ( abortController.signal.aborted ) return;
    console.log( 'Work' );

    if ( abortController.signal.aborted ) return;
    console.log( 'More work' );
    resolve( 'Work result' );
    abortController.signal.removeEventListener( 'abort', abortHandler );
  }, 2000 );
} )
  .then( result => console.log( 'then:', result ) )
  .catch( reason => console.error( 'catch:', reason ) );
setTimeout( () => abortController.abort(), 1000 );

Solution 10:[10]

Because @jib reject my modify, so I post my answer here. It's just the modfify of @jib's anwser with some comments and using more understandable variable names.

Below I just show examples of two different method: one is resolve() the other is reject()

let cancelCallback = () => {};

input.oninput = function(ev) {
  let term = ev.target.value;
  console.log(`searching for "${term}"`);
  cancelCallback(); //cancel previous promise by calling cancelCallback()

  let setCancelCallbackPromise = () => {
    return new Promise((resolve, reject) => {
      // set cancelCallback when running this promise
      cancelCallback = () => {
        // pass cancel messages by resolve()
        return resolve('Canceled');
      };
    })
  }

  Promise.race([setCancelCallbackPromise(), getSearchResults(term)]).then(results => {
    // check if the calling of resolve() is from cancelCallback() or getSearchResults()
    if (results == 'Canceled') {
      console.log("error(by resolve): ", results);
    } else {
      console.log(`results for "${term}"`, results);
    }
  });
}


input2.oninput = function(ev) {
  let term = ev.target.value;
  console.log(`searching for "${term}"`);
  cancelCallback(); //cancel previous promise by calling cancelCallback()

  let setCancelCallbackPromise = () => {
    return new Promise((resolve, reject) => {
      // set cancelCallback when running this promise
      cancelCallback = () => {
        // pass cancel messages by reject()
        return reject('Canceled');
      };
    })
  }

  Promise.race([setCancelCallbackPromise(), getSearchResults(term)]).then(results => {
    // check if the calling of resolve() is from cancelCallback() or getSearchResults()
    if (results !== 'Canceled') {
      console.log(`results for "${term}"`, results);
    }
  }).catch(error => {
    console.log("error(by reject): ", error);
  })
}

function getSearchResults(term) {
  return new Promise(resolve => {
    let timeout = 100 + Math.floor(Math.random() * 1900);
    setTimeout(() => resolve([term.toLowerCase(), term.toUpperCase()]), timeout);
  });
}
Search(use resolve): <input id="input">
<br> Search2(use reject and catch error): <input id="input2">

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2
Solution 3
Solution 4
Solution 5 Igor S?omski
Solution 6 Devi
Solution 7 Rashomon
Solution 8 Dmitriy Mozgovoy
Solution 9
Solution 10 allenyllee