It would be a glaring omission not to talk about Promise in JavaScript. In fact, there have been many articles written about Promise, which you can find through Google or occasionally come across in a programming-related community. But because Promise is an important concept and everyone has a different way of explaining it, I still decided to write this article.
When I first learned JavaScript, Promise was the most confusing thing. I thought I understood it and knew how to use it, but in reality, there were still many long and painful stumbles that taught me valuable lessons. I read many articles about Promise in both English and Vietnamese, and gradually everything started to make sense, helping me understand and use it correctly.
This article will not go into the concepts of Promise but rather touch on some notes and possible misunderstandings. It aims to provide readers with a general understanding and help them avoid some mistakes when writing code.
What is a Promise?
A Promise is an object that represents a future result. In other words, a Promise represents the result of an asynchronous function.
A Promise has three states corresponding to the three possible outcomes of an asynchronous function:
-
pending
is the initial state, waiting for the result. -
fulfilled
is the successful state, with a result value. -
rejected
is the failure state, with an optional error value.
For example, let's create a Promise that takes a number x
and returns the fulfilled
state if x
is divisible by 2, and the rejected
state otherwise.
function isEven(x) {
return new Promise((resolve, reject) => {
if (x % 2 === 0) {
resolve(true);
} else {
reject(new Error('x is not even'));
}
});
}
In essence, a Promise represents a future result. In the above example, creating a Promise is not necessary because all the actions inside the isEven
function are synchronous. So, when should we use a Promise and what makes a function asynchronous?
What makes a function asynchronous? I have mentioned it in many articles. Some I/O tasks provided by asynchronous functions cannot immediately return a result; they depend on external factors such as hardware speed, network speed, etc. Waiting for these actions can waste a lot of time or cause serious bottlenecks.
For example, when making a GET request to the address https://example.com
, the processing does not simply depend on CPU speed anymore; it also depends on your network speed. The faster the network, the quicker you receive the result. In JavaScript, we have the fetch
function to send the request, and it is asynchronous, returning a Promise.
fetch('https://example.com');
To handle the result of a Promise, we use then
and catch
for success and failure cases, respectively.
fetch('https://example.com')
.then(res => console.log(res))
.catch(err => console.log(err));
If after some processing time, fetch
is fulfilled
, the function inside then
will be activated. Otherwise, if fetch
is rejected
, the function inside catch
will be executed immediately.
Sometimes you may come across questions like predicting the result of the following example:
console.log('1');
fetch('https://example.com')
.then(res => console.log(res))
.catch(err => console.log(err))
.finally(() => console.log('3'));
console.log('2');
This question is actually to test your understanding of asynchronous behavior in JavaScript. The result will be 1, 2, 3 instead of 1, 3, 2. This is because of the nature of asynchronous processing. Since the result of asynchronous behavior will be returned in the future, JavaScript thinks, "OK, this asynchronous function does not have an immediate result yet. Let it be and continue processing the following commands. When everything is done, check if it has a result." For more information on how asynchronous processing works, you can refer to articles on Asynchronous Programming in JavaScript.
Promise has some useful static methods for various use cases, such as all
, allSettled
, any
, and race
.
Promise.all
takes an array of Promises, returns a Promise, and is in the fulfilled
state when all Promises in the array are successful. Otherwise, it is in the rejected
state when at least one Promise in the array fails.
Promise.allSettled
is similar to Promise.all
but always returns the results of all Promises in the array regardless of success or failure. Both Promise.all
and Promise.allSettled
are useful when you need to run multiple asynchronous functions immediately without caring about the order of the results.
On the other hand, Promise.race
returns the result of the Promise that is settled first, regardless of success or failure. Promise.any
returns the first fulfilled Promise in the array. race
and any
are suitable when you have multiple Promises that perform similar actions and need a fallback between the results.
Promise Replaces "Callback Hell"
It is surprising to know that JavaScript used to not have Promise. Yes, you heard it right. This fact led Node.js to also not have Promise in its early days, and that is the biggest regret the creator of Node.js has.
Previously, all asynchronous tasks were handled through callbacks. We would define a callback function to handle the result in the future.
For example, an early asynchronous request function was XMLHttpRequest
. It handled the result through a callback.
function reqListener() {
console.log(this.responseText);
}
const req = new XMLHttpRequest();
req.addEventListener('load', reqListener);
req.open('GET', 'https://example.com');
req.send();
reqListener
is a function that is called when there is a result from the request to https://example.com
. Handling asynchronous behavior with callbacks brought some troubles, including what is commonly known as "callback hell".
For example, an asynchronous function fnA
takes a callback function with two parameters: data
representing the successful result and err
representing the error. Similar functions include fnB
, fnC
, etc. If we want to combine these processing functions together, we need to nest them inside each other.
fnA((data1, err1) => {
fnB((data2, err2) => {
fnC((data3, err3) => {
....
});
});
});
When there are too many callbacks written like this, our code becomes a literal "callback hell". It becomes messy and hinders the reading and understanding process.
Promise was introduced to bring a new approach to handling asynchronous behavior. We still use callbacks, but we are able to limit the "hell" by connecting everything sequentially through then
.
fnA()
.then(data => fnB(data))
.then(data => fnC(data))
...
.catch(err => console.log(err));
After that, many asynchronous callback-based functions were rewritten using new Promise
. In Node.js, we even have a built-in module called util.promisify specifically for this transformation. Callbacks are still supported, but with the benefits that Promise brings, many new libraries are using Promise as the default for handling asynchronous behavior.
Promise in Loops
There are many unfortunate mistakes when using Promise without fully understanding its essence and one of them is handling sequential asynchronous operations in a loop.
Suppose you need to iterate through 5 pages, making paginated API calls from 1 to 5 and concatenate the return values in order into an array.
const results = [];
for (let i = 1; i <= 5; i++) {
fetch(`https://example.com?page=${i}`)
.then(response => response.json())
.then(data => results.push(data));
}
The above code creates a loop to fetch data from page 1 to page 5, and the returned data is pushed into the results
array. At first glance, the result would be an array of data in the order from the first page to the last page. However, in reality, each time it runs, you will find that the data in results
is randomly ordered.
Remember fetch
, it returns a Promise, representing a future result... While for
is trying to loop through as quickly as possible, you can think of it as all 5 fetch
commands being called and started "almost instantly". At this point, CPU speed is less important - it's the network speed that determines which fetch
command has the first result. As soon as it has the result, it immediately pushes it into results
, resulting in the data being added randomly.
To solve this issue, there are several ways. For example:
const results = [];
fetch('https://example.com?page=1')
.then(response => response.json())
.then(data => {
results.push(data);
return fetch('https://example.com?page=2');
})
.then(response => response.json())
.then(data => {
results.push(data);
return fetch('https://example.com?page=3');
})
...
This is crazy, and nobody writes code like this. Imagine what if there were 1000 pages? It's a joke, but the example above attempts to illustrate the idea of how to wait for the previous request to complete before proceeding to the next request.
Bluebird is a very good Promise library that provides many utility functions to make working with asynchronous functions easier.
The above example can be rewritten using the each
function provided by Bluebird.
const results = [];
Promise.each([1, 2, 3, 4, 5], (i) => {
return fetch(`https://example.com?page=${i}`)
.then(response => response.json())
.then(data => results.push(data));
});
Async/Await - The Missing Piece of Promise?
To create a Promise, we use the syntax new Promise
, but with async/await, we simply declare an async
function.
async function isEven(x) {
if (x % 2 === 0) {
return true;
} else {
throw new Error('x is not even');
}
}
While Promise uses then
to handle the return result at some point in the future, async/await is as simple as using await
.
const result = await fetch('https://example.com');
const resultJSON = await result.json();
Starting from Node.js version 14.8, we have the top-level await feature, which means await
calls are no longer limited to inside an async
function; they can be used outside as well. Before that, await
could only be used inside an async
function.
async function getData() {
const result = await fetch('https://example.com');
const resultJSON = await result.json();
}
While Promise uses .catch
to handle errors, async/await uses try...catch
.
async function getData() {
try {
const result = await fetch('https://example.com');
const resultJSON = await result.json();
} catch (err) {
console.log(err);
}
}
It is evident that async/await allows us to write asynchronous code as if it were synchronous, without callbacks and without the need for then
. We simply wait for the result using await
.
However, this can also lead to some dangerous misunderstandings, such as forgetting the await
keyword to wait for the result of an asynchronous behavior or mistakenly thinking that an asynchronous function is a synchronous one.
async function getData() {
try {
const result = await fetch('https://example.com');
const resultJSON = await result.json();
return resultJSON;
} catch (err) {
console.log(err);
}
}
function main() {
const data = getData();
console.log(data);
}
In essence, getData
returns a Promise, so the above program does not work correctly if the intention is to get data from an API call. To fix this, we need to change it.
async function main() {
const data = await getData();
console.log(data);
}
return vs. return await
Sometimes you may come across code that looks like this:
async function fn() {
...
return await asyncFn();
}
The function fn
is returning an await
of the asynchronous function asyncFn
. The author probably intended for fn
to always return the result of asyncFn
, as await
is waiting for the result of the asynchronous function, effectively turning fn
into a synchronous function, or in other words, "not" returning a Promise.
Unfortunately, this is a dangerous misunderstanding. In essence, await
is only meant to be used at the top-level or inside an async
function, and if it is async
, it must return a Promise. Therefore, fn
always returns a Promise. So why use return await asyncFn()
?
Simply using return asyncFn()
can save you a bit of keystrokes, as there is no significant difference compared to return await asyncFn()
. The big change happens when there is a try...catch
in return
.
Let's consider the following two functions:
async function rejectionWithReturnAwait () {
try {
return await Promise.reject(new Error())
} catch (e) {
return 'Saved!'
}
}
async function rejectionWithReturn () {
try {
return Promise.reject(new Error())
} catch (e) {
return 'Saved!'
}
}
The first function, rejectionWithReturnAwait
, is intentionally using return await
, and when this Promise is rejected, the catch
block quickly catches the error and executes the return 'Saved!'
statement. This means the function returns a Promise containing the string 'Saved'.
In contrast, rejectionWithReturn
, without await
, is attempting to reject a Promise.reject(new Error())
, and the catch
block is never executed.
References: