AGS Logo AGS Logo

JavaScript Promises Demystified

Worth the wait

Photo by Hannah Busing on Unsplash

Being a front end developer, I only recently realized that promises are not universal. I had never given them much thought and figured like variables or other common code concepts, all languages had them, but apparently no. Recently, I have been mentoring some backend developers and was surprised to learn that their back end code doesn't have them. With these developers in mind, I figured it would nice to have a cheat sheet on how promises work and how to use them.

Before we get into the nitty gritty about how promises work, let's first define them:

The Promise object represents the eventual completion (or failure) of an asynchronous operation and its resulting value. (mdn web docs)

Promises are special because they allow us to perform an asynchronous call and then wait for their results such as when using fetch to get data from an API as we do in Listing 1 which returns the weather forecast for Indianapolis.

Fetching the weather forecast for Indianapolis
  fetch('http://www.7timer.info/bin/api.pl?lon=-86.158068&lat=39.768403&product=civil&output=json')
    .then(result => result.json())

If I want to continue building on my result, I can daisy chain the .then(), for example listing 2 converts and returns the results from the fetch into JSON and the second .then() logs the resulting JSON to the console.

Fetching the weather forecast for Indianapolis
  fetch('http://www.7timer.info/bin/api.pl?lon=-86.158068&lat=39.768403&product=civil&output=json')
    .then(result => result.json())
    .then(data => console.log(data))

But what happens if our call fails? To handle errors we can add a catch() to our promise chain. A catch will allow us to decide what to do with the error and potentially give us an opportunity to recover from the error or at least handle the error gracefully.

Using catch() to handle errors
  fetch('http://www.7timer.info/bin/api.pl?lon=-86.158068&lat=39.768403&product=civil&output=json')
    .then(result => result.json())
    .then(data => console.log(data))
    .catch(error => console.error(error))

The catch in this context, would trigger regardless of where in our chain the error happened. If we want code to execute regardless of a success or failure, we can use finally(). In the getForecast() function bellow, regardless of whether the fetch is successful, loading will be set to false once the chain is finished executing.

Using finally()
function getForecast() {
  let loading = true
  fetch('http://www.7timer.info/bin/api.pl?lon=-86.158068&lat=39.768403&product=civil&output=json')
    .then(result => result.json())
    .then(data => console.log(data))
    .catch(error => console.error(error))
    .finally(() => loading = false)
}

Let's refactor our code to use the equivalent async/await and the built in try/catch/finally error handling pattern. Depending on your goal, the number of promises, and personal preference one may be easier to read then the other. Furthermore it is worth noting that they can be used together.

async/await

Refactoring using async/await
async function getForecast() {
  try {
    let loading = true
    const result = await fetch('http://www.7timer.info/bin/api.pl?lon=-86.158068&lat=39.768403&product=civil&output=json')
    const data = result.json()
    console.log(data)
  } catch (error) {
    console.error(error)
  } finally {
    loading = false
  }
}

The functions from Listing 4 and 5 are functionally identical. Let's expand our example to use 2 promises and get the weather from Indianapolis and from Chicago.

Getting 2 different forecasts
function getForecast(latitude, longitude) {
  return fetch(`http://www.7timer.info/bin/api.pl?lon=${longitude}&lat=${latitude}&product=civil&output=json`)
    .then(response => response.json())
}

(async () => {
  try {
    const indianapolisForecast = await getForecast(39.768403, -86.158068)
    const chicagoForecast = await getForecast(41.878114, -87.629798)
    console.log(indianapolisForecast, chicagoForecast)
  } catch (error) {
    console.error(error)
  }
})()

Although the code in listing 6 will return both weather forecasts, it's not very efficient. We first wait until we get Indianapolis' forecast before we get Chicago's. Written using .then() it would look as follows:

Getting 2 different forecasts
function getForecast(latitude, longitude) {
  return fetch(`http://www.7timer.info/bin/api.pl?lon=${longitude}&lat=${latitude}&product=civil&output=json`)
    .then(response => response.json())
}

(() => {
  let indianapolisForecast
  getForecast(39.768403, -86.158068)
  .then(indianapolisForecastResult => {
    indianapolisForecast = indianapolisForecastResult
    return getForecast(41.878114, -87.629798)
  }).then(chicagoForecast => {
    console.log(indianapolisForecast, chicagoForecast)
  }).catch(error => { console.error(error) })
})()

We don't want to wait for the first in order to fetch the second. We can therefore use Promise.all() in order to get both in parallel.

Promise.all()

Using Promise.all() to fetch in parallel
function getForecast(latitude, longitude) {
  return fetch(`http://www.7timer.info/bin/api.pl?lon=${longitude}&lat=${latitude}&product=civil&output=json`)
    .then(response => response.json())
}

(async () => {
  try {
    const [indianapolisForecast, chicagoForecast] = await Promise.all([
      getForecast(39.768403, -86.158068),
      getForecast(41.878114, -87.629798)
    ])
    console.log(indianapolisForecast, chicagoForecast)
  } catch(error) {
    console.error(error)  
  }
})()

Our parameter is an array of promises, and Promise.all() returns an array of values equal in length to the number of promises supplied.

Although we have made the code more efficient, if either of the forecast calls fails, we won't get any values for any of our promises in the Promise.all() even if some were successful. To prevent the entire Promise.all() from failing if one fails, we can add a catch to each promise. Listing 7 returns null if the promise throws an error.

Error handling inside of a Promise.all()
function getForecast(latitude, longitude) {
  return fetch(`http://www.7timer.info/bin/api.pl?lon=${longitude}&lat=${latitude}&product=civil&output=json`)
    .then(response => response.json())
}

(async () => {
  try {
    const [indianapolisForecast, chicagoForecast] = await Promise.all([
      getForecast(39.768403, -86.158068).catch(err => null),
      getForecast(41.878114, -87.629798).catch(err => null)
    ])
    console.log(indianapolisForecast, chicagoForecast)
  } catch(error) {
    console.error(error)  
  }
})()

Another option for handling errors when we are calling multiple promises in parallel is through the use of Promise.allSettled().

Promise.allSettled()

Instead of the promise values, Promise.allSettled() returns an array of objects with 2 properties.

If the promise resolved, the returned object for the promise will have a status property with a value of fulfilled, and a value property with the promise's value.

If the promise errored, then it will have a status property with a value of rejected, and a reason property with the reason the promise was rejected.

The benefit of using Promise.allSettled() is that we still get values for the promises that resolved without having to add catches to each individual promise in the array. The downside is that we cannot handle our errors with the use of a catch like the previous examples in this article. We must check each value individually, and handle our successes and errors accordingly as seen in Listing 10.

Using Promise.allSettled() to fetch in parallel
function getForecast(latitude, longitude) {
  return fetch(`http://www.7timer.info/bin/api.pl?lon=${longitude}&lat=${latitude}&product=civil&output=json`)
    .then(response => response.json())
}

(async () => {
  try {
    const forecasts = await Promise.allSettled([
      getForecast(39.768403, -86.158068),
      getForecast(41.878114, -87.629798)
    ])
    const [indianapolisForecast, chicagoForecast] = forecasts.map(forecast => forecast.status === 'fulfilled' ? forecast.value : null)
    console.log(indianapolisForecast, chicagoForecast)
  } catch(error) {
    console.error(error)  
  }
})()

Both listing 9 and 10 achieve the same results, we get Indianapolis' and Chicago's weather forecasts in parallel and if either of the calls fail, we handle the error by returning a null value for that forecast. Which is a better solution depends on your use case is and how you want to handle your errors.

But what if we don't care which one we get? If we are of the opinion that the cities are close enough together and that which ever forecast returns first is good enough we have 2 options: Promise.race() and Promise.any(). Let's look at Promise.race() first.

Promise.race()

When using Promise.race() we will get only 1 result which will be the value of the first promise to resolve. If the first promise to return a value throws an error, the Promise.race() will error.

Using Promise.race() to get data from the fastest promise
function getForecast(latitude, longitude) {
  return fetch(`http://www.7timer.info/bin/api.pl?lon=${longitude}&lat=${latitude}&product=civil&output=json`)
    .then(response => response.json())
}

(async () => {
  try {
    const forecasts = await Promise.race([
      getForecast(39.768403, -86.158068),
      getForecast(41.878114, -87.629798)
    ])
    console.log(forecast)
  } catch(error) {
    console.error(error)  
  }
})()

The downside are:

  1. we don't know which of the 2 promises the data came from
  2. if the first to return fails, then the Promise fails and an error is thrown

If we want to wait until something succeeds (even if there are failures) we can use Promise.any() instead.

Promise.any()

Promise.any() will ignore failures until all of the promises have failed only then will the catch trigger.

Using Promise.any() to get data from the fastest promise
function getForecast(latitude, longitude) {
  return fetch(`http://www.7timer.info/bin/api.pl?lon=${longitude}&lat=${latitude}&product=civil&output=json`)
    .then(response => response.json())
}

(async () => {
  try {
    const forecasts = await Promise.any([
      Promise.reject(0),
      getForecast(39.768403, -86.158068),
      getForecast(41.878114, -87.629798)
    ])
    console.log(forecast)
  } catch(error) {
    console.error(error)  
  }
})()

Even though we have a rejection in listing 12, the rejected promise will be ignored and either the Indianapolis or Chicago forecast will be returned, which ever is fastest.

Callbacks

We've looked at calling promises all sorts of ways but what if the function we are dealing with isn't a promise but a callback? How would we turn a callback into a promise we can await or chain?

Let's look at how we would transform setTimeout() into a promise.

setTimeout callback as a promise
  new Promise(resolve => {
    function timeout(() => {
      resolve('done')
  }, 200)
})

We wrap the function inside of a newly created promise and upon the completion of the code we call resolve() passing in our results as the parameter.

Another common use case is the File Reader callback we often use for file uploads:

File Reader callback
function printFile(file) {
  const reader = new FileReader()
  reader.onload = event => {
    console.log(event.target.result)
  }
  reader.onerror = error => {
    console.log(error)
  }
  reader.readAsText(file)
}

To turn the callback into a promise, we create a new Promise, which resolves when the callback returns its value, or rejects if the callback errors.

File Reader callback made into a promise
function printFile(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = event => resolve(event.target.result)
    reader.onerror = (error) = reject(error)
    reader.readAsText(file)
  })
}

printFile(file)
  .then(value => console.log(value))
  .catch(error => console.error(error))

Now our printFile() function returns a promise and we can use it with any of the techniques presented in this article.

Happy Coding!

Specialties

Overview of our specialties including: accessibility, angular, CSS, design, and Firebase

References

License: CC BY-NC-ND 4.0 (Creative Commons)