Beliebte Suchanfragen
//

Cancelable Asynchronous Operations with Promises in JavaScript

11.3.2015 | 9 minutes of reading time

Last week my colleague Bastian Krol put up an interesting question on Twitter about JavaScript Promises and the ability to cancel asynchronous (async) operations.

What's the idiomatic way (API) to give clients a way to abort some action represented by an #AngularJS promise? /cc @robinboehm @BenRipkens

— Bastian Krol (@bastiankrol) March 2, 2015

The question is not easy to answer because of its subtle components:

  1. What is a common way to cancel asynchronous operations?
  2. How does this common idiom map to Promised based APIs?
  3. Should a client cancel Promises or the operation?
  4. Does an example for cancelable async operations exist in the Angular environment? Can we follow the same idiom?

This blog post uses ECMAScript 6 (ES6), the next version of JavaScript, for all code examples. If you don’t know ES6 yet, you should definitely read up on it!

Promises, a short reminder

Promises model the eventual result of asynchronous operations. You can think of Promises as objects to which you can add fulfillment and error handlers that are to be invoked once an operation has settled, i.e. completed or failed. Instead of registering callbacks immediately when asynchronous operations are initiated, Promises allow eventual results to be passed around, returned and combined. If you are a Java programmer, think of Promises as asynchronous java.util.concurrent.Futures (where get never blocks).

Promises are simple state machines as the following diagram shows.

Possible states of a JavaScript promise.

Refer to the Promises A+ spec and the HTML5 rocks article for more information.

Also: Did I mention that Promises are monads?

Existing APIs

This section provides a very short overview about existing APIs for alternative asynchronous control flow management and how Bluebird, a popular JavaScript Promise library, is doing this.

Node.js ReadStream

ReadStreams are part of the Node.js filesystem module . As the name implies, ReadStreams are about filesystem access with streams. Streams are a valid and proven alternative for Promises and are highly versatile. They can be used in combination or as replacement for Promises.

Unfortunately Node.js ReadStreams do not have a documented API to close open streams, even though this is possible. The filesystem module and specifically the ReadStream objects have a close method which closes an open ReadStream (source ). Closing a ReadStream effectively means that the data flow stops and a close event is propagated.

XMLHttpRequest

XMLHttpRequest (XHR) is the in-browser API for the interaction with HTTP endpoints. The API allows HTTP requests to be sent, responses to be received and files to be uploaded. XHR is one of the core technologies associated with Web 2.0 and crucial to every web application. Most important for us right now: It allows requests to be aborted via a method on the request object.

1const xhr = new XMLHttpRequest();
2xhr.open('GET', 'http://blog.codecentric.de');
3xhr.send();
4 
5// some time later...
6xhr.abort();

Bluebird

Bluebird is a Promise library with native support for cancelable async operations . Cancelation needs to be explictly opted in by calling cancellable() on a Bluebird Promise. The following listing shows a basic example and tests whether descendant Promises stay cancelable.

1const expect = chai.expect;
2 
3function sendCancelableRequest() {
4  return new Promise((resolve, reject) => {})
5  .cancellable()
6  .catch(
7    Promise.CancellationError,
8    (e) => console.log('Canceled: ', e)
9  );
10}
11 
12const promise = sendCancelableRequest();
13expect(promise.isCancellable()).to.equal(true);
14 
15const descendantPromise = promise.then(() => {});
16expect(descendantPromise.isCancellable()).to.equal(true);
17 
18descendantPromise.cancel();

The Bluebird library is doing something very interesting. The descendantPromise knows that it is cancelable even though the cancelation property is defined on the parent Promise. The documentation states the following about this feature:

.cancellable() -> Promise

Marks this promise as cancellable. Marking a promise as cancellable is infectious and you don't need to remark any descendant promise.

.cancel([Error reason]) -> Promise

Cancel this promise with the given reason. The cancellation will propagate to farthest cancellable ancestor promise which is still pending.

Infectious cancelation is great because it allows then(onFulfilled, onRejected) to be used. Other implementations, like Angular's $timeout, do not share this property and are thus not generally applicable. Cancelation is not part of the Promises A+ spec and as such is not part of the ECMAScript 6 (ES6) Promise interface. This has a serious consequence.

Most Promise implementations are interoperable, i.e. you can mix and match Promise implementations. The base contract is that Promises have a then(onFulfilled, onRejected) function to respond to settlement. With Bluebird's cancelable Promises, this contract is extended with infectious cancelation. This means that there are limitations with respect to the usefulness to Bluebird's cancelable Promises. Developers which are not well versed in Promises may not understand why they cannot mix Bluebird and ES6 Promises.

The Angular $timeout service

We have seen examples for cancelable async operations with Streams, Promises and classic XHRs. Angular, which is part of Bastian's question, also ships with a cancelable async operation: The $timeout service.

The Angular $timeout service is a wrapper around window.setTimeout. $timeout improves testability via dependency injection and it ensures that the delayed functions are executed as part of the scope life cycle, i.e. watch execution and exception handling. As with window.setTimeout, $timeout also provides the ability to cancel async execution via $timeout.cancel(...). Example:

1const delayMillis = 500;
2const timeoutPromise = $timeout(() => {}, delayMillis);
3 
4// optionally cancel async execution
5$timeout.cancel(timeoutPromise);

This is an interesting API design. In order to make this possible, $timeout.cancel(...) needs to somehow associate the Promise with the async operation. Angular does this by adding a $$timeoutId property to the Promise (source ). When $timeout.cancel(...) is called, the $$timeoutId property is read, the association with the async operation is made and the timeout can be canceled via the native window.clearTimeout function.

This API design is nice as it resembles the browsers' window.setTimeout and window.clearTimeout functions. JavaScript developers recognize the pattern and feel familiar with the API. It furthermore adds value through the aforementioned integration with the scope life cycle and testability. Unfortunately though, the $timeout service is not a good example for cancelable asynchronous operations.

$timeout returns a Promise on which the then(onFulfilled, onRejected) function can be called. This function always returns a new Promise which resolves to the return value of the called handler. This is where the crux lies. The new Promise does not have a $$timeoutId property and therefore cannot be used to call $timeout.cancel(promise). Remember that Bluebird cancelation is infectious to descendant Promises? Angular's Promises do not have this property! The following will not work:

1const delayMillis = 500;
2const transformedTimeoutPromise = $timeout(() => {}, delayMillis)
3  .then(() => {});
4 
5// fails because transformedTimeoutPromise is lacking a
6// $$timeoutId property.
7$timeout.cancel(transformedTimeoutPromise);

This means that $timeout cannot be considered a good example for idiomatic cancelable asynchronous operations. The transformation of Promises' values via then(onFulfilled, onRejected) is probably the most common Promise interaction pattern. An API which fails to work in the majority of cases is not a good API. On top of that, developers which are new to Promises do not understand why this interaction fails.

Requirements for a good API

Now that we have gathered some experience with cancelable async operations, we can establish a basic set of requirements for a good API. First of, what should be canceled?

When triggering asynchronous operations, we are almost always interested in the results. No matter whether the results are positive or negative, the results are valuable. Canceling the eventual result, i.e. the Promise, actually means not to receive any result (not even a canceled result). Again, what should be canceled? We want to cancel the operation, not the result!

Furthermore, from a semantic point of view, we mostly do not want to cancel because we do not want a result, but because of resource constraints, user instructions, errors or other reasons.

This realization has implications: An API that enables canceling is not concerned with Promises as Promises only model the result! The API should enable canceling of the operation and as such users of the API need to be able to differentiate between operation and result.

At last: Infectious cancelation. Infectious cancelation, as shown in the Bluebird section, results in a seemingly nice API. Unfortunately the API fails when used in combination with ES6 Promises. A good API should not surprise developers and it should not break existing interoperability.

The Angular UI Bootstrap $modal service

Angular UI is a suite of solid components and utilities for Angular projects. Among them are the UI Router, UI Utils and UI Bootstrap. For this article the UI Bootstrap component suite is of special interest as it contains Twitter Bootstrap compatible components that are rewritten in pure Angular. One of these components is the $modal service which is responsible for the creation and administration of modal windows.

Modal windows can be used in different ways and for varying purposes. The $modal service supports many of them. Interestingly, there is (at least) one use case which actually resembles a cancelable asynchronous operation!

The $modal service returns a Promise which can be resolved or rejected. To resolve the Promise, the close function can be used. Use dismiss to reject the Promise. The Promise will settle with whatever value is provided to close or dismiss. A small example can be seen in the following listing.

1const {result, close, dismiss} = $modal.open(...);
2 
3// This is the ECMAScript 6 destructuring feature. It is roughly equivalent to
4// const modalResult = $modal.open(...);
5// const result = modalResult.result;
6// const close = modalResult.close;
7// const dismiss = modalResult.dismiss;

Let us lean back for a second and think about our requirements. At the end of the previous section we identified that we do not want to cancel the eventual result. We want to cancel the operation. To do this, we would need to be able to differentiate between eventual result and operation. The $modal service is doing this! The result property is a plain old Promise and the dismiss function is actually a way to close the modal window and reject the Promise, i.e. a way to cancel an asynchronous operation!

Conclusion

We looked at a few ways to cancel asynchronous operations and also saw how Bluebird solves the issue with the help of infectious cancelation. Next we identified that Promises should not be canceled. Rather, it should be possible to cancel an operation. Most importantly: Cancelation should be predictable and it should not break existing functionality and interoperability.

An example for such an API exists in the Angular environment in the form of the modal dialog service from UI bootstrap. The $modal service differentiates between cancelation and result. As such it may not be as comfortable to use as Bluebird's cancelation API, but it is easy to understand and predictable.

share post

//

More articles in this subject area

Discover exciting further topics and let the codecentric world inspire you.

//

Gemeinsam bessere Projekte umsetzen.

Wir helfen deinem Unternehmen.

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.