Mastering promise cancellation in JavaScript
Megan Lee
Posted on September 11, 2024
Written by Rosario De Chiara✏️
In JavaScript, Promises are a powerful tool for handling asynchronous operations, particularly useful in UI-related events. They represent a value that may not be available immediately but will be resolved at some point in the future.
Promises allow (or should allow) developers to write cleaner, more manageable code when dealing with tasks like API calls, user interactions, or animations. By using methods like .then()
, .catch()
, and .finally()
, Promises enable a more intuitive way to handle success and error scenarios, avoiding the notorious "callback hell.”
In this article, we will use the new (March 2024 Promise.withResolvers()
method that allows you to write cleaner and simpler code by returning an object containing three things: a new Promise and two functions, one to resolve the Promise and the other to reject it. As this is a recent update, you will need a recent Node runtime (v>22) to execute the examples in this article.
Comparing the old and new JavaScript promise methods
In the two following functionally equivalent chunks of code, we can compare the old approach and the new approach of assigning the method to either resolve or reject a Promise:
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
Math.random() > 0.5 ? resolve("ok") : reject("not ok");
In the code above, you can see the most traditional use of a Promise: you instantiate a new promise
object, and then, in the constructor, you have to assign the two functions, resolve
and reject
, that will be invoked when needed.
In the following code snippet, the same chunk of code has been rewritten with the new Promise.withResolvers()
method, and it appears simpler:
const { promise, resolve, reject } = Promise.withResolvers();
Math.random() > 0.5 ? resolve("ok") : reject("not ok");
Here you can see how the new approach works. It returns the Promise, on which you can invoke the .then()
method and the two functions, resolve
and reject
.
The traditional approach to Promises encapsulates the creation and event-handling logic within a single function, which can be limiting if multiple conditions or different parts of the code need to resolve or reject the promise.
In contrast, Promise.withResolvers()
provides greater flexibility by separating the creation of the Promise from the resolution logic, making it suitable for managing complex conditions or multiple events. However, for straightforward use cases, the traditional method may be simpler and more familiar to those accustomed to standard promise patterns.
Real-world example: Calling an API
We can now test the new approach on a more realistic example. In the code below, you can see a simple example of an API invocation:
function fetchData(url) {
return new Promise((resolve, reject) => {
fetch(url)
.then(response => {
// Check if the response is okay (status 200-299)
if (response.ok) {
return response.json(); // Parse JSON if response is okay
} else {
// Reject the promise if the response is not okay
reject(new Error('API Invocation failed'));
}
})
.then(data => {
// Resolve the promise with the data
resolve(data);
})
.catch(error => {
// Catch and reject the promise if there is a network error
reject(error);
});
});
}
// Example usage
const apiURL = '<ADD HERE YOU API ENDPOINT>';
fetchData(apiURL)
.then(data => {
// Handle the resolved data
console.log('Data received:', data);
})
.catch(error => {
// Handle any errors that occurred
console.error('Error occurred:', error);
});
The fetchData
function is designed to take a URL and return a Promise that handles an API call using the fetch
API. It processes the response by checking if the response status is within the 200-299 range, indicating success.
If successful, the response is parsed as JSON, and the Promise is resolved with the resulting data. If the response is not successful, the Promise is rejected with an appropriate error message. Additionally, the function includes error handling to catch any network errors, rejecting the Promise if such an error occurs.
The example demonstrates how to use this function, showing how to manage the resolved data with a .then()
block and handle errors using a .catch()
block, ensuring that both successful data retrieval and errors are managed appropriately.
In the code below, we re-write the fetchData()
function by using the new Promise.withResolvers()
method:
function fetchData(url) {
const { promise, resolve, reject } = Promise.withResolvers();
fetch(url)
.then(response => {
// Check if the response is okay (status 200-299)
if (response.ok) {
return response.json(); // Parse JSON if response is okay
} else {
// Reject the promise if the response is not okay
reject(new Error('API Invocation failed'));
}
})
.then(data => {
// Resolve the promise with the data
resolve(data);
})
.catch(error => {
// Catch and reject the promise if there is a network error
reject(error);
});
return promise;
}
As you can see, the code above is more readable, and the role of the object Promise is clear: the fetchData
function will return a Promise that will be successfully resolved or will fail, invoking – in each case – the proper method. You can find the code above on the repository named api.invocation.{old|new}.js.
Promises cancellation
The following code explores how to implement a Promise cancellation method. As you may know, you cannot cancel a Promise in JavaScript. Promises represent the result of an asynchronous operation and they are designed to resolve or reject once created, with no built-in mechanism to cancel them.
This limitation arises because Promises have a defined state transition process; they start as pending and, once settled, cannot change state. They are meant to encapsulate the result of an operation rather than control the operation itself, which means they cannot influence or cancel the underlying process. This design choice keeps Promises simple and focused on representing the eventual outcome of an operation:
const cancellablePromise = () => {
const { promise, resolve, reject } = Promise.withResolvers();
promise.cancel = () => {
reject("the promise got cancelled");
};
return promise;
};
In the code above, you can see the object named cancellablePromise
, which is a promise
with an additional cancel()
method that, as you can see, simply forces the invocation of the reject
method. This is just syntactic sugar and does not cancel a JavaScript Promise, though it may help in writing clearer code.
An alternative approach is to use an AbortController
and AbortSignal
, which can be tied to the underlying operation (e.g., an HTTP request) to cancel it when needed. From the documentation, you can see that the AbortController
and AbortSignal
approach is a more expressive implementation of what we implemented in the code above: once the AbortSignal
is invoked, the promise just gets rejected.
Another approach is to use reactive programming libraries like RxJS, which offers an implementation of the Observable pattern, a more sophisticated control over async data streams, including cancellation capabilities.
A comparison between Observables and Promises
When speaking about practical use cases, Promises are well-suited for handling single asynchronous operations, such as fetching data from an API. In contrast, Observables are ideal for managing streams of data, such as user input, WebSocket events, or HTTP responses, where multiple values may be emitted over time.
We already clarified that once initiated, Promises cannot be canceled, whereas Observables allow for cancellation by unsubscribing from the stream. The general idea is that, with Observables, you have an explicit structure of the possible interaction with the object:
- You create an Observable, and then all the Observables can subscribe to it
- The Observable carries out its work, changing state and emitting events. All the Observers will receive the updates – this is the main difference with Promises. A Promise can be resolved just once while the Observables can keep emitting events as long as there are Observers
- Once the Observer is not interested in the events from the Observables, it can unsubscribe, freeing resources
This is demonstrated in the code below:
import { Observable } from 'rxjs';
const observable = new Observable(subscriber => {
subscriber.next(1);
subscriber.next(2);
subscriber.next(3);
subscriber.complete();
});
const observer = observable.subscribe({
next(x) { console.log('Received value:', x); },
complete() { console.log('Observable completed'); }
});
observer.unsubscribe();
This code cannot be rewritten with Promises because the Observable
returns three values while a Promise can only be resolved once.
To experiment further with the unsubscribe
method, we can add another Observer that will use the takeWhile()
method: it will let the Observer wait for values to match a specific condition; in the code below, for example, it keeps receiving events from the Observable while the value is not 2
:
import { Observable, takeWhile } from 'rxjs';
const observable = new Observable(subscriber => {
subscriber.next(1);
subscriber.next(2);
subscriber.next(3);
subscriber.complete();
});
const observer1 = observable.subscribe({
next(x) { console.log('Received by 1 value:', x); },
complete() { console.log('Observable 1 completed'); }
});
const observer2 = observable.pipe(
takeWhile(value => value != "2")
).subscribe(value => console.log('Received by 2 value:', value));
In the code above, observer1
is the same as we have already seen: it will just subscribe
and keep receiving all the events from the Observable
. The second one, observer2
, will receive elements from the Observable
while the condition is matched. In this case, this means when the value
is different from 2
.
From the execution, you can see how the two different mechanisms work:
$ node observable.mjs
Received by 1 value: 1
Received by 1 value: 2
Received by 1 value: 3
Observable 1 completed
Received by 2 value: 1
$
Conclusion
In this article, we investigated the new mechanism to allocate a Promise in JavaScript and laid out some of the possible ways to cancel a Promise before its completion. We also compared Promises with Observable objects, which not only offer the features of Promises but extend them by allowing multiple emissions of events and a proper mechanism for unsubscribing.
LogRocket: Debug JavaScript errors more easily by understanding the context
Debugging code is always a tedious task. But the more you understand your errors, the easier it is to fix them.
LogRocket allows you to understand these errors in new and unique ways. Our frontend monitoring solution tracks user engagement with your JavaScript frontends to give you the ability to see exactly what the user did that led to an error.
LogRocket records console logs, page load times, stack traces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!
Posted on September 11, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.