Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: David Bainbridge
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: David Bainbridge ( @redwhitepine )

Canceling A Promise In AngularJS

By Ben Nadel on

I've been thinking a lot about something Kyle Simpson said on Twitter a while back - that Promises should not be cancelable. And, while I tend to agree with this, I think there are situations in which the underlying deferred value does need to be canceled. The one that comes to mind is the AJAX (Asynchronous JavaScript And JSON) request. In a very real way, a calling context does need to be able to cancel an HTTP request even when the calling context only holds a reference to the promised response of said HTTP request.


 
 
 

 
You don't know JavaScript - don't cancel a promise. 
 
 
 

I've touched on this topic a bit in the past, adding an .abort() method to the promise returned by $http in AngularJS. But, in that exploration, the calling context calls .abort() directly on the promise, which makes the promise itself seem "cancelable."

I've been thinking about how to reconcile this pragmatic need with what Kyle is saying. And, it occurs to me that perhaps I just need to invert the dependencies. Rather than having a promise rely on a "cancel" or "abort" method, what if the cancelation happens at a higher level; what if the cancelation happens at the "factory" level (ie, the data services tier) and depends on the promise?

This got me thinking about how AngularJS implements timeouts. It uses promises to encapsulate the native setTimeout() method. And, just as vanilla JavaScript has a way to cancel timers, so does AngularJS. Only, the cancelation doesn't happen on the timer instance - it happens in the timer factory:

  • $timeout.cancel( timer );

In this way, the timer itself isn't altered - the control flow is. Now, what if we think about this generically:

  • PromiseFactory.cancel( promise );

In this way, we're not canceling the promise itself - we're altering something related to the promise workflow. This allows the calling context to be decoupled from the cancelation implementation and even allows the calling context to pass-in invalid or already-resolved promises.

Let's take a quick look at this in the context of making AJAX requests in AngularJS. In the following demo, I have a friendService that encapsulates AJAX requests and returns promises. The friendService exposes a .cancel() method that will take a promise and abort the underlying AJAX request. In doing so, it doesn't actually cancel the promise - it cancels underlying asynchronous request.

  • <!doctype html>
  • <html ng-app="Demo">
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • Canceling A Promise In AngularJS
  • </title>
  •  
  • <link rel="stylesheet" type="text/css" href="./demo.css"></link>
  • </head>
  • <body ng-controller="AppController">
  •  
  • <h1>
  • Canceling A Promise In AngularJS
  • </h1>
  •  
  • <ul>
  • <li ng-repeat="friend in friends">
  • {{ friend.name }}
  • </li>
  • </ul>
  •  
  • <p>
  • <a ng-click="reload()">Reload friends</a>
  • </p>
  •  
  •  
  • <!-- Load scripts. -->
  • <script type="text/javascript" src="../../vendor/jquery/jquery-2.1.0.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs/angular-1.2.26.min.js"></script>
  • <script type="text/javascript">
  •  
  • // Create an application module for our demo.
  • var app = angular.module( "Demo", [] );
  •  
  •  
  • // -------------------------------------------------- //
  • // -------------------------------------------------- //
  •  
  •  
  • // I control the root of the application.
  • app.controller(
  • "AppController",
  • function( $scope, friendService ) {
  •  
  • // I contain the list of friends to render.
  • $scope.friends = [];
  •  
  • // When the scope is destroyed, we want to clean up the controller,
  • // including canceling any outstanding request to gather friends.
  • $scope.$on(
  • "$destroy",
  • function handleDestroyEvent() {
  •  
  • friendService.cancel( lastRequest );
  •  
  • }
  • );
  •  
  • // Load the list of friends.
  • var lastRequest = loadRemoteData();
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I reload the list of friends.
  • $scope.reload = function() {
  •  
  • loadRemoteData();
  •  
  • }
  •  
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  •  
  • // I load the remote friend data and return the request promise.
  • function loadRemoteData() {
  •  
  • // Cancel any pending request for friends.
  • // --
  • // NOTE: This will not error if lastRequest is null or undefined.
  • friendService.cancel( lastRequest );
  •  
  • // Make the request for friends. It's important that we save this
  • // reference rather than the subsequent .then() result as this is
  • // the one that can be "canceled". The subsequent .then() result
  • // cannot be canceled as it is an entirely different promise.
  • lastRequest = friendService.getFriends();
  •  
  • lastRequest.then(
  • function handleGetFriendsResolve( friends ) {
  •  
  • $scope.friends = friends;
  •  
  • },
  • function handleGetFriendsReject( error ) {
  •  
  • // The request failed or was CANCELED.
  • console.warn( "Friends error:" );
  • console.log( error );
  •  
  • }
  • );
  •  
  • return( lastRequest );
  •  
  • }
  •  
  • }
  • );
  •  
  •  
  • // -------------------------------------------------- //
  • // -------------------------------------------------- //
  •  
  •  
  • // I provide access to the Friends repository.
  • app.service(
  • "friendService",
  • function( $http, $q ) {
  •  
  • // Return the public API.
  • return({
  • cancel: cancel,
  • getFriends: getFriends
  • });
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I cancel the underlying AJAX request associated with the given promise.
  • // If the promise has not yet been resolved, this will cause the promise
  • // to be rejected with status:0.
  • // --
  • // NOTE: This will NOT raise an exception if the promise is invalid or
  • // already resolved / rejected.
  • function cancel( promise ) {
  •  
  • // If the promise does not contain a hook into the deferred timeout,
  • // the simply ignore the cancel request.
  • if (
  • promise &&
  • promise._httpTimeout &&
  • promise._httpTimeout.resolve
  • ) {
  •  
  • promise._httpTimeout.resolve();
  •  
  • }
  •  
  • }
  •  
  •  
  • // I get the full list of friends, returns a promise.
  • function getFriends() {
  •  
  • var httpTimeout = $q.defer();
  •  
  • // When making the HTTP request, pass in the promise for our deferred
  • // timeout. If the deferred timeout is ever resolved, the underlying
  • // AJAX request will be aborted (if not already).
  • var request = $http({
  • method: "get",
  • url: "./api/index.cfm",
  • timeout: httpTimeout.promise
  • });
  •  
  • var promise = request.then( unwrapResolve );
  •  
  • // Store the deferred timeout on the promise so that our .cancel()
  • // method can access it later, if necessary.
  • promise._httpTimeout = httpTimeout;
  •  
  • return( promise );
  •  
  • }
  •  
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  •  
  • // I unwrap the AngularJS $http response, extracting the data from the
  • // more robust response object.
  • function unwrapResolve( response ) {
  •  
  • return( response.data );
  •  
  • }
  •  
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

As you can see, I still need to store a reference to the AJAX-abort timer; but, I'm doing so in such a way that the calling context doesn't need to know about it. Now, that's not to say that the calling context is completely decoupled from the implementation - it still needs to know about the .cancel() method and that the cancel method will only work with the original promise. But, some level of coupling is necessary as the promise is, ultimately, coupled to the underlying AJAX request that needs to be canceled.

Now keep in mind, I'm not "aborting" the promise; meaning, I'm not preventing the promise from completing. I'm simply canceling part of the workflow. Calling .cancel() won't kill the promise - it will reject the step in the workflow associated with the promise. This allows all the touch-points for said promise to remain in tact and to function as expected. The only caveat being that contexts that handle the "reject" event may need to further branch their control flow to handle rejection "reasons" caused by cancelations.

Tweet This Interesting post by @BenNadel - Canceling A Promise In AngularJS Thanks my man — you rock the party that rocks the body!



Reader Comments

@Ben

It seems natural to have service layer functionality to control promises ... Transactional states come to mind as a use case... for instance if a transaction fails cancel 'scheduled' promises... etc...

Reply to this Comment

@Edward,

I am not sure what feels "natural" just yet. Most of the promise usage I have dealt with is in terms of HTTP requests; and, most of the time, I never care about having to cancel the request. But, it's a very real need.

Imagine an auto-suggest combo-box. As you type, the client needs to go back to the server to get partial matches for suggestions; but, as you continue to type, most implementations will .abort() the existing auto-suggest request to get the next one with the updated partial-text.

In this case, the service layer won't know about the View; and the View/Controller won't know about the underlying HTTP request (since it's communicated via promises). So, somehow, someway, the Controller either needs to cancel the HTTP directly (through the promise); or, it needs to have the Service layer kill the HTTP through a request relating to the promise.

So, given the two options, I think asking the Service to do it makes the most sense. Plus, that way, the Controller doesn't have to worry about the logic. Meaning, that it can pass-in a NULL value, or a true promise. That branching is handled in the service for convenience.

Reply to this Comment

This still feels kinda dirty, since we're attaching the httpTimeout promise to the promise itself, thus again exposing the cancelability of the promise.

I tend to use a WeakMap for this (with a polyfill to support older browsers):

function ($http, $q) {

var requests = new WeakMap;

return({
cancel: cancel,
getFriends: getFriends
});

function cancel( promise ) {
var aborter = requests.get(promise);
aborter && aborter.resolve();
}

function getFriends() {

var aborter = $q.defer();

var request = $http({
timeout: aborter.promise
});

requests.set(request, aborter);

return request;
}
}

Reply to this Comment

@Joseph,

I'm not too familiar with weak maps in JavaScript. I see that they are supposed to hold references without creating memory leaks. But, I am not sure how a polyfill would be able to do this without requiring the calling code to explicitly delete the reference (in which case, there's really no advantage).

I tried looking at one of the polyfills, but they seem to be a massive amount of code. Do you have a sense of how a polyfill clears memory properly?

Reply to this Comment

@ben,

I usually use the FT polyfill service: https://cdn.polyfill.io/v1/docs/

The polyfill they use just monkey patches the target object, putting the values onto a randomly generated key: https://github.com/Financial-Times/polyfill-service/blob/master/polyfills/WeakMap/polyfill.js#L20,L36

This is not unlike what you did, but this feels purer. In modern browsers you get real separation of the promise and the aborter, and even in older browsers you still have it conceptually separated.

PS does your commenting system support any sort of formatting, like markdown? How does one post a link, or a block of code?

Reply to this Comment

Ben,
I've done a lot of work with the WinJS platform (for HTML-based Win8 apps and Xbox One apps). Interestingly, the WinJS.Promise implementation DOES support cancellation. And let me say, in my real-world experience, this feature has proven itself INVALUABLE!

Within my Single-Page-Application, each page loads a lot of data async. The page simply stores an array of its promises, and cancels them when the page is left.
My promise chains can be complex: they often contain sequences of data calls, retry mechanisms, animations, timeouts, and can even show a modal dialog.
But as soon as the page is left, even in a partially-loaded state, the promise is cancelled, the modal closes, the auto-retry stops, the subsequent data calls are ignored, and the page is cleaned up very nicely!

So, I'm actually quite upset that most current Promise specs don't support cancellation!. The WinJS.Promise API is almost identical to the ES6 Promise API, with the addition of this cancellation feature. And unfortunately, it's nearly impossible to polyfill. So, the only solution is to implement a "top-down" approach like you mentioned, like `$timeout.cancel( timer )`.

Have you come across any Promise specs / implementations that DO support cancellation?

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.