Better handling of rejections using Promise.allSettled()

When it comes to executing several Promises concurrently and waiting for them all to finish before using their resolved values elsewhere in your code, Promise.all() is really useful.

The problem is though, that if one of those Promises fails/rejects, all the function calls will still happen, but the return value you'll get will just be the value of the first rejected Promise.

And because of this - in situations where you still want to get those values from the Promises that did resolve, Promise.all() is not the best solution.

There is a way around this though...

A workaround

One way to work around this - while still using Promise.all() - is the following:

async function a() {return 'a'}
async function b() {return 'b'}
async function c() {throw 'fail'}
async function d() {throw 'another fail'}

const results = await Promise.all([
  a().catch(e => { console.error(e) }),
  b().catch(e => { console.error(e) }),
  c().catch(e => { console.error(e) }),
  d().catch(e => { console.error(e) })
])

// NOTE: an alternative way of calling these functions would be something like:
// 
// const promiseArray = [a, b, c, d]
// const results = await Promise.all(promiseArray.map(p => p().catch(e => { console.error(e) })))

console.log(results)

The above will output:

[ 'a', 'b', undefined, undefined ]

So we can still get the values from the resolved Promises, and we get undefined for the rejected ones.

We could even do something like...

const resolvedPromises = results.filter(Boolean)

...to only get the resolved Promises.

One more workaround

Let's look at another potential workaround. What if instead of console.error() in the .catch's we just returned the error, like so:

async function a() {return 'a'}
async function b() {return 'b'}
async function c() {throw 'fail'}
async function d() {throw 'another fail'}

const results = await Promise.all([
  a().catch(e => e),
  b().catch(e => e),
  fail().catch(e => e),
  fail2().catch(e => e)
])

console.log(results)

The output for results would look like:

[ 'a', 'b', 'fail', 'fail2' ]

This is a step forward from the previous solution in that we get the error messages, but a step back in that we don't know they are errors. They're just strings, so we don't know what resolved and what didn't.

And this is really the crux of the problem with these workarounds... we either get the values of what Promises resolved, but no error messages, or we lose context of what resolved and what didn't.

Enter Promise.allSettled()

We can solve this entirely with Promise.allSettled() though.

Promise.allSettled() is a method added somewhat recently to the Promise API (in Browsers and Node), that will wait for all Promises to resolve or reject and will return both types of values.

The difference between it and Promise.all() is:

  • Promise.all() will technically reject as soon as one of the functions passed in the array rejects.
  • Promise.allSettled() will never reject - instead it will wait for all functions passed in the array to either resolve or reject.

Let's look at an example. In this example, we want to load the user account data and the user's activity data, regardless of if either one fails. Imagine that the activity data is an audit log of actions the user has performed in the application. There's a tab on the UI that contains user account info and the user's activity. If the call to activity fails, we still want to load the user account data - there is no reason not to. Once we have that data, the UI can then display it.

And same if the call to fetch the account data fails, but the activity call resolves. We can still show the activity data, and try to fetch the account data later.

Note: pretend the account data is just things like user info and that the user is already logged in.

const getUserAccount = userId => axios.get(`/user/${userId}`)
const getUserActivity = userId => axios.get(`/user/${userId}/activity`)

const id = 3245
await Promise.allSettled([getUserAccount(id), getUserActivity(id)])

Using .all() rather than .allSettled() would mean that TODO

What does .allSettled() return? Let's imagine that the call to the activity endpoint - called by getUserActivity() - fails due to a network blip. The output from .allSettled() would be:

/* 
* [
*     {status: "fulfilled", value: {name: "John Doe", dateAccountCreated: "05-23-2018"}},
*     {status: "rejected", reason: "failed to fetch"}
* ]
/*

Notice that we get an array of objects back, with a status property regardless of if it resolved/fulfilled or rejected. And either a value property if the Promise was fulfilled, or a reason property if it rejected.

This is great because we can still load the user account info, and retry fetching the user activity later. (retries are outside the scope of this post, and there are multiple strategies for that)

Getting values out of .allSettled() vs. all()

Unlike Promise.all(), which returns an array of the values from each resolved Promise (assuming none reject), the shape returned by Promise.allSettled() is a bit different.

A reminder of what it looks like, using our example from above:

/* 
* [
*     {status: "fulfilled", value: {name: "John Doe", dateAccountCreated: "05-23-2018"}},
*     {status: "rejected", reason: "failed to fetch"}
* ]
/*

So if we want to get our values out of the array, we can still destructure them but this also means that we can't simply destructure the response and get the values out in an immediately usable way. They'll still be objects.

const id = 3245
const [userAccountInfo, userActivity] = await Promise.allSettled([getUserAccount(id), getUserActivity(id)])

console.log(userAccountInfo) // {status: "fulfilled", value: {name: "John Doe", dateAccountCreated: "05-23-2018"}} 
console.log(userActivity) // {status: "rejected", reason: "failed to fetch"}

Note: there are many cases where this is a totally valid way of doing it. For example, we might not know what the account info data is, and what the activity data is, so it makes sense to have them in separate variables since we know what they are and can assign appropriate variable names.

If you do want to get them as destructured and "cleaned up" (meaning, just the values), you can do something like:

const id = 3245
const results = await Promise.allSettled([getUserAccount(id), getUserActivity(id)])

// resolved/fulfilled Promises' values
const fulfilled = results.filter(result => result.status === 'fulfilled').map(result => result.value)
console.log(fulfilled) // [{name: "John Doe", dateAccountCreated: "05-23-2018"}]

// rejected Promises' reasons
const rejected = results.filter(result => result.status === 'rejected').map(result => result.reason)
console.log(rejected) // ['failed to fetch']

Unfortunately, you can't destructure the fulfilled array or the rejected array because you don't know what will fail and what won't, meaning you won't know the length of each array.

Still, this makes the resolved and rejected values easier to work with. And you can spread the arrays if you need to later on (using ...fulfilled, for example).

Wrapping up

Next time you need this kind of robust handling around Promise rejections that may result from concurrent function calls, remember that Promise.allSettled() exists (as long as you're using Node 12.9.0+).

It can make your life as a developer much easier.

Sign up for the newsletter!

No spam ever. Unsubscribe any time.