Skip to content

Instantly share code, notes, and snippets.

@jfmdev
Last active November 1, 2023 23:10
Show Gist options
  • Save jfmdev/b5332dd2dc34f3e790304d3929a12210 to your computer and use it in GitHub Desktop.
Save jfmdev/b5332dd2dc34f3e790304d3929a12210 to your computer and use it in GitHub Desktop.
Javascript functions to create cancelable Promises

Cancelable promises

Sometimes it's need to cancel a promise, i.e. to stop the execution of the underlying action (and force the associated handlers to be invoked).
However, by design, promises don't offer this feature because most asynchronous actions can't be canceled nor stopped once started (for example, although you can abort an AJAX request, there is no guarantee that the server won't execute the request anyway).

Nevertheless, a common workaround used to achieve a cancel-like behavior is to invoke the promise's handlers before the associated asynchronous action has finished, and later (when the action finish) discard the action's result.
This workaround is useful mainly for read actions (like GET requests), that don't modifies data (since ignoring the result of write actions might produce unexpected results).

For example, let's say there is a single-page application that displays tabs whose content is loaded dynamically using AJAX. If an user clicks on a tab and then, before the tab's content is fetched, clicks on another tab, the result of the first tab's request could be discarded, since isn't need anymore.

This file provides some simple implementations to achieve cancel-like behaviors on promises.

Wrappers

One way to simulate the cancellation of a promise is to use a wrapper: you define a promise that will listen for the result of another promise and, depending if the promise was canceled or not, will re-transmit that value or return an empty response.

For example:

/**
 * Creates an new promise that can be canceled.
 * Note that if the promise is canceled, the execution of the 'base' promise won't be halted, but his result 
 * will be discarded. 
 * 
 * @param {Promise} basePromise - A promise object.
 * 
 * @return {Promise} A new Promise with a "cancel" method and a "canceled" flag.
 */
function CancelablePromise(basePromise) {
  var ignoreBase = false;
  var resolveRef;

  var newPromise = new Promise(function(resolve, reject) {
    basePromise.then(function(data) {
      if(!ignoreBase) resolve(data);
    }).catch(function(err) {
      if(!ignoreBase) reject(err);
    })

    resolveRef = resolve;
  });

  newPromise.canceled = false;
  newPromise.cancel = function() {
    ignoreBase = true
    newPromise.canceled = true;
    resolveRef();
  };

  return newPromise;
}

// --- Usage example --- //

var basePromise = new Promise(function(resolve) { 
  setTimeout(function() { resolve(23); }, 2000);
});
var newPromiseA = CancelablePromise(basePromise);
var newPromiseB = CancelablePromise(basePromise);

basePromise.then(function(result) {
 console.log("Base promise finished at", new Date(), "| Result:", result);
});
 
newPromiseA.then(function(result) {
 console.log("Promise A finished at", new Date(), "| Result:", result, "| Canceled: ", newPromiseA.canceled);
});

newPromiseB.then(function(result) {
  console.log("Promise B finished at", new Date(), "| Result:", result, "| Canceled: ", newPromiseB.canceled);
});
newPromiseB.cancel();

In this code, the "Promise B" will be resolved 2 seconds before "Promise A" because it won't wait for the base promise to finish (although it won't have access to his result).

The previous implementation assumes that canceling a promise is a happy path, that's why the resolve handler is called. However an alternative could be to invoke the reject handler, passing him a custom error:

/**
 * Custom error to use when canceling asynchronous actions.
 */
class CancelationError extends Error {
  constructor(message) {
    super(message);
  }
}

/**
 * Creates an new promise that can be canceled.
 * Note that if the promise is canceled, the execution of the 'base' promise won't be halted, but his result 
 * will be discarded. 
 * 
 * @param {Promise} basePromise - A promise object.
 * 
 * @return {Promise} A new Promise with an "cancel" method and that might throw a 'CancelationError'.
 */
function CancelablePromise(basePromise) {
  var ignoreBase = false;
  var rejectRef;

  var newPromise = new Promise(function(resolve, reject) {
    basePromise.then(function(data) {
      if(!ignoreBase) resolve(data);
    }).catch(function(err) {
      if(!ignoreBase) reject(err);
    })

    rejectRef = reject;
  });

  newPromise.cancel = function() {
    ignoreBase = true
    rejectRef(new CancelationError('Promise canceled'));
  };

  return newPromise;
}

// --- Usage example --- //

var basePromise = new Promise(function(resolve) { 
  setTimeout(function() { resolve(23); }, 2000);
});
var newPromise = CancelablePromise(basePromise);

basePromise.then(function(result) {
 console.log("Base promise finished", result);
});
 
newPromise.then(function(result) {
 console.log("New Promise finished", result);
}).catch(function(err) {
  console.log(`New Promise failed due ${err instanceof CancelationError ? 'a cancelation' : 'an unexpected error'}`);
});
newPromise.cancel();

Deferred promises

Another alternative is to use the Promise.race function to combine an standard promise with a deferred promises (promises whose resolution can triggered manually), then use the deferred promise to force the resolution.

For example:

/**
 * Creates a promise whose resolution must be triggered programmatically.
 * 
 * @return {Promise} A new Promise with methods "resolve" and "reject".
 */
function DeferredPromise() {
  var methods = {};
  var newPromise = new Promise(function(resolve, reject) {
    this.resolve = resolve;
    this.reject = reject;
  }.bind(methods));

  return Object.assign(newPromise, methods);
}

// --- Usage example --- //

var basePromise = new Promise(function(resolve) { 
  setTimeout(function() { resolve(23) }, 2000);
});
var deferredPromiseA = DeferredPromise();
var deferredPromiseB = DeferredPromise();
var racePromiseA = Promise.race([basePromise, deferredPromiseA]);
var racePromiseB = Promise.race([basePromise, deferredPromiseB]);

basePromise.then(function(result) {
 console.log("Common Promise finished at", new Date(), "|", result);
});

racePromiseA.then(function(result) {
 console.log("Race Promise A finished at", new Date(), "|", result);
});

racePromiseB.then(function(result) {
 console.log("Race Promise B finished at", new Date(), "|", result);
});
deferredPromiseB.resolve(-1);

In this code, the "Promise B" will be resolved 2 seconds before "Promise A" because it won't wait for the base promise to finish (although it won't have access to his result).

Alternatively, you could also do deferredPromiseB.reject(new CancelationError()) to halt the execution using an error.

Alternative you can just define a function that returns the race promise as a wrapper for the base promise:

/**
 * Create an new promise that can be stopped.
 * Note that if the promise is stopped, the execution of the 'base' promise won't be halted, but his result 
 * will be discarded. 
 * 
 * @param {Promise} basePromise - A promise object.
 * 
 * @return {Promise} A new Promise with a "stopper" object, that contains methods 'resolve' and 'reject' 
 * to force the promise to finish.
 */
function StoppablePromise(basePromise) {
  var deferred = DeferredPromise();

  var newPromise = Promise.race([deferred, basePromise]);
  newPromise.stopper = deferred;

  return newPromise;
}

/**
 * Creates a promise whose resolution must be triggered programmatically.
 * 
 * @return {Promise} A new Promise with methods "resolve" and "reject".
 */
function DeferredPromise() {
  var methods = {};
  var newPromise = new Promise(function(resolve, reject) {
    this.resolve = resolve;
    this.reject = reject;
  }.bind(methods));

  return Object.assign(newPromise, methods);
}

// --- Usage example --- //

var basePromise = new Promise(function(resolve) { 
  setTimeout(function() { resolve(23) }, 2000);
});
var newPromiseA = StoppablePromise(basePromise);
var newPromiseB = StoppablePromise(basePromise);

basePromise.then(function(result) {
 console.log("Base Promise finished at", new Date(), "|", result);
});
newPromiseA.then(function(result) {
 console.log("Promise A finished at", new Date(), "|", result);
});
newPromiseB.then(function(result) {
 console.log("Promise B finished at", new Date(), "|", result);
});
newPromiseB.stopper.resolve(-1);

Chaining

It must be pointed that the implementations from above don't support chaining: the original promise will be cancellable, but the promises returned by then(), catch() or finally() won't be.
However this can be easily solved by re-defining these methods, like:

/**
 * Creates an new promise that can be canceled.
 * Note that if the promise is canceled, the execution of the 'base' promise won't be halted, but his result 
 * will be discarded. 
 * 
 * @param {Promise} basePromise - A promise object.
 * 
 * @return {Promise} A new Promise with a "cancel" method.
 */
function CancellablePromise(basePromise) {
  var stopper = DeferredPromise();
  var newPromise = Promise.race([stopper, basePromise])

  return {
    cancel: function() { stopper.reject(); },

    catch: function() {
      return CancellablePromise(
        newPromise.catch.apply(newPromise, arguments),
        stopper
      );
    },

    finally: function() {
      return CancellablePromise(
        newPromise.finally.apply(newPromise, arguments),
        stopper
      );
    },

    then: function() {
      return CancellablePromise(
        newPromise.then.apply(newPromise, arguments),
        stopper
      );
    }
  };
}

/**
 * Creates a promise whose resolution must be triggered programmatically.
 * 
 * @return {Promise} A new Promise with methods "resolve" and "reject".
 */
function DeferredPromise() {
  var methods = {};
  var newPromise = new Promise(function(resolve, reject) {
    this.resolve = resolve;
    this.reject = reject;
  }.bind(methods));

  return Object.assign(newPromise, methods);
}

// --- Usage example --- //

var basePromise = new Promise(function(resolve) { 
  setTimeout(function() { resolve(23) }, 2000);
});
var newPromiseA = CancellablePromise(basePromise);
var newPromiseB = newPromiseA.then(function(number) { return number*2; });
var newPromiseC = newPromiseB.then(function(number) { return number*3; });

basePromise.then(function(result) {
 console.log("Base Promise finished at", new Date(), "|", result);
});
newPromiseA.then(function() {
 console.log("Promise A finished at", new Date());
});
newPromiseB.catch(function() {
 console.log("Promise B was canceled at", new Date());
});
newPromiseC.catch(function() {
 console.log("Promise C was canceled at", new Date());
});
newPromiseB.cancel();

In this code, both "Base Promise" and "Promise A" will be succeed, but "Promise B" and "Promise C" will fail: the first one because it was cancelled, and the second one because his base promise (i.e. "Promise B") was cancelled.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment