DEV Community 👩‍💻👨‍💻

Cover image for Effective Promise Concurrency in JavaScript
Steve Sewell for Builder.io

Posted on • Originally published at builder.io

Effective Promise Concurrency in JavaScript

Did you know that we can optimize this function to complete in as little as half of the time?

async function getPageData() {
  const user = await fetchUser()
  const product = await fetchProduct()
}
Enter fullscreen mode Exit fullscreen mode

In this function, we’re awaiting a fetch for a user, and then a fetch for a product, sequentially.

But one doesn’t depend on the other, so we don’t have to wait for one to complete before we fire off the request for the next.

Instead, we could fire both requests together, and await both concurrently.

Using Promise.all

One way to do this is utilizing Promise.all, like so:

async function getPageData() {
  const [user, product] = await Promise.all([
    fetchUser(), fetchProduct()
  ])
}
Enter fullscreen mode Exit fullscreen mode

Nifty!

And now if we imagine that each of those requests took 1 second to respond each, whereas in our original function we would wait for both in a row totaling 2 seconds for our function to complete, in this new function we wait for both concurrently so our function completes in 1 second — half the time!

But… theres just one problem

Here’s the problem...

First, we’re not handling errors at all here.

So you could say “sure, I’ll put this in a big ole try-catch block”.

async function getPageData() {
  try {
    const [user, product] = await Promise.all([
      fetchUser(), fetchProduct()
    ])
  } catch (err) {
    // 🚩 this has a big problem...
  }
}
Enter fullscreen mode Exit fullscreen mode

But this actually has a major issue.

Let's say fetchUser completes first with an error. That will trigger our catch block and then continue on with the function.

But here is the kicker - if fetchProducts then errors afterward, this will not trigger the catch block. That is because our function has already continued. The catch code has run, the function has completed — we’ve moved on.

So this will instead result in an unhandled promise rejection. Ack.

So if we have some kind of handling logic, that prompts the user or saves to an error logging service, like so:

// ...
} catch (err) {
  handle(err)
}
// ...

function handle(err) {
  alertToUser(err)
  saveToLoggingService(err)
}
Enter fullscreen mode Exit fullscreen mode

We will sadly only be made aware of the first error. The second error will be lost in the ether - with no user feedback, not being captured in our error logs - it’s effectively invisible (besides a little noise in the browser console).

Solving with .catch()

One solution to our above issue is to pass a function to .catch(), for instance like this:

function onReject(err) {
  handle(err)
  return err
}

async function getPageData() {
  const [user, product] = await Promise.all([
    fetchUser().catch(onReject), // ⬅️
    fetchProduct().catch(onReject), // ⬅️
  ])

  if (user instanceof Error) {
    handle(user) // ✅
  }
  if (product instanceof Error) {
    handle(product) // ✅
  }
}
Enter fullscreen mode Exit fullscreen mode

In this case, if we get an error, we return handle the error and return it. So now our resulting user and product objects are either an Error, which we can check with instanceof, or otherwise our actual good result.

This ain't so bad, and solves our prior issues.

But, the main drawback here is we need to make sure we are always providing that .catch(onReject) , religiously, throughout our code. This is sadly quite easy to miss, and also not the easiest to write a bullet proof eslint rule for.

Awaiting separately after instantiation

As a side note, it’s useful to keep in mind that we don’t always need to immediately await a promise after creating it. Another technique that we can use here that is virtually the same is this this:

async function getPageData() {
  // Fire both requests together
  const userPromise = fetchUser().catch(onReject)
  const productPromise = fetchProduct().catch(onReject)

  // Await together
  const user = await userPromise
  const product = await productPromise

  // Handle individually
  if (user instanceof Error) {
    handle(user)
  }
  if (product instanceof Error) {
    handle(product)
  }
}
Enter fullscreen mode Exit fullscreen mode

Because we fire off each fetch before we await for either one, this version has the same performance benefits as our examples above that use Promise.all .

Additionally, in this format, we can safely use try/catch if we like without the issues we had previously:

async function getPageData() {
  const userPromise = fetchUser().catch(onReject)
  const productPromise = fetchProduct().catch(onReject)

  // Try/catch each
  try {
    const user = await userPromise
  } catch (err) {
    handle(err)
  }
  try {
    const product = await productPromise
  } catch (err) {
    handle(err)
  }
}
Enter fullscreen mode Exit fullscreen mode

Between these three, I personally like the Promise.all version, as it feels more idiomatic to say “wait for these two things together”. But that said, I think this just comes down to personal preference

Solving with Promise.allSettled

Another solution, that is built into JavaScript, is to use Promise.allSettled.

With Promise.allSettled, instead of getting the user and product back directly, we get a result object that contains the value or error of each promise result.

async function getPageData() {
  const [userResult, productResult] = await Promise.allSettled([
    fetchUser(),
    fetchProduct(),
  ])
}
Enter fullscreen mode Exit fullscreen mode

The result objects have 3 properties:

  • status - Either "fulfilled" or "rejected"
  • value - Only present if status is "fulfilled". The value that the promise was fulfilled with
  • reason - Only present if status is "rejected". The reason that the promise was rejected with.

So we can now read what the status of each promise was, and process each error individually, without losing any of this critical information:

async function getPageData() {
  // Fire and await together
  const [userResult, productResult] = await Promise.allSettled([
    fetchUser(),
    fetchProduct(),
  ])

  // Process user
  if (userResult.status === "rejected") {
    const err = userResult.reason
    handle(err)
  } else {
    const user = userResult.value
  }

  // Process product
  if (productResult.status === "rejected") {
    const err = productResult.reason
    handle(err)
  } else {
    const product = productResult.value
  }
}
Enter fullscreen mode Exit fullscreen mode

But, that is kind of a lot of boilerplate. So let’s abstract this down:

async function getPageData() {
  const results = await Promise.allSettled([
    fetchUser(), fetchProduct()
  ])

  // Nicer on the eyes