Async/Await Tips to be Aware Of At All Times

Christopher T.

August 21st, 2022

Share This Post

Asynchronous programming in JavaScript has long been a challenge for developers over the years. The problem is that it's difficult to write asynchronous code.

Before Promises were introduced the most efficient way to write asynchronous code was to use the callback pattern. However, implementing this pattern sometimes had its drawbacks. When code grows larger in size we would sometimes be caught in a tangle of unmaintainable code which often meant dealing with callback hell. This would especially be relevant in code that would rely on many asynchronous operations to complete in sequential order.

Eventually, Promises were introduced to solve issues that developers often endured with callbacks. Even with Promises however there was still confusion. Promises need to implement callbacks that return a new or existing promise that must implement another callback that returns the next value (which can be another Promise, and so on). This may eventually bring up previous issues again such as callback hell.

The way we're taught to express our code (and writing in general) is a little different than the way JavaScript code is executed during the runtime. There is nothing wrong with JavaScript (or even other programming languages) nor is it an issue of abstraction. It's human nature we read text from top to bottom and left to right.

JavaScript needed a proper way for developers to be able to write asynchronous code intuitively and eventually introduced a solution with the ECMAScript standard of async functions and the await keyword as expressions. In short, this is commonly referred to as async/await

In this post we will be going over a couple of important tips that every JavaScript developer must know to prevent difficult bugs from occurring in your applications.

Is the Promise You Are Returning Going to Get Caught In The Same Function Block?

One tricky gotcha when dealing with errors in async/await functions is returning promises that reject to the caller of the current function. When doing this, we're actually forwarding our rejection handling to the caller.

Let's take a look into the code example below:

function delay(ms, value) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(value), ms)
  })
}

async function getRandomItem(arr) {
  try {
    const result = await delay(200, arr[Math.floor(Math.random() * arr.length)])

    if (typeof result === 'number') {
      throw new Error(`Received a number`)
    }

    return result
  } catch (error) {
    throw error instanceof Error ? error : new Error(String(error))
  }
}

async function start() {
  try {
    return getRandomItem(['a', 1, 3, 'ccc'])
  } catch (error) {
    window.alert(`You picked out number. Please try again for a string`)
  }
}

// The caller
start().catch((err) => {
  console.error(`[${err.name}]: ${err.message}`)
})

Inside our start function we return the result of the function getRandomItem and expect our rejections to be caught in the local try/catch block because we wan't our JavaScript runtime to be able to continue running. We would be able to accomplish that while displaying an alert window to the user. However, when we run the code it actually doesn't display a popup at all and instead the JavaScript runtime crashes after executing our Throw we put in our getRandomItem function:

returning-a-promise-handler-in-javascript

This is because our Promise being returned by getRandomItem is actually not being awaited in the same function block:

promise-not-being-awaited-in-same-function-in-javascript

To fix this, all we need to do is add a simple await keyword so that it becomes awaited in the same function block:

async function start() {
  try {
    return await getRandomItem(['a', 1, 3, 'ccc'])
  } catch (error) {
    window.alert(`You picked out number. Please try again for a string`)
  }
}

It's important to understand that this only occurs when returning an entirely new promise, so returning regular values like numbers aren't affected:

async function start() {
  try {
    return 3
  } catch (error) {
    window.alert(`You picked out number. Please try again for a string`)
  }
}

Avoid Writing Asynchronous Code Inside forEach

Newer JavaScript developers are more susceptive to this "gotcha" when writing asynchronous code.

Here is an example:

async function start() {
  const arr = ['1', 2, 3, 'hello'].map((item) => Promise.resolve(item))
  const newArr = []

  arr.forEach(async (item) => {
    const result = await item
    newArr.push(result)
  })

  console.log(newArr)
}

start()

Logging this to the console gives us an empty array[] which might not have been what we expected. forEach functions ignore promises that are returned on each loop!

In other words this is technically the same as:

function forEach(arr, callback) {
  for (let index = 0; index < arr.length; index++) {
    const item = arr[index]
    // The promise ends up here. But nothing else happens
    callback(item)
  }
}

Iteration functions like the map may work when collecting and resolving promises because they return the result of invoking the callback each loop. In JavaScript, any function that returns a promise becomes a thenable (an object with a .then method). Promises are also thenables, and since map returns each result of our callback this means we can use an asynchronous version of a callback function rather than the synchronous version if we wanted to.

This is technically the same as:

function map(arr, callback) {
  const results = []

  for (let index = 0; index < arr.length; index++) {
    const item = arr[index]
    // The promise ends up here. But this time we save the result inside our final collection that is being returned after the loop is finished
    const result = callback(item)
    results.push(result)
  }

  return results
}

In the previous example we can make use of Promise.all on the final result that map returns (Which can ultimately become a collection of thenables or promises if we returned them from our callback handler):

function map(arr, callback) {
  const results = []

  for (let index = 0; index < arr.length; index++) {
    const item = arr[index]
    // The promise ends up here. But this time we save the result inside our final collection that is being returned after the loop is finished
    const result = callback(item)
    results.push(result)
  }

  return results
}

async function start() {
  const arr = ['1', 2, 3, 'hello'].map((item) => Promise.resolve(item))
  const newArr = await Promise.all(map(arr, (item) => item))

  console.log(newArr)
}

start()

Result:

["1", 2, 3, "hello"]

The Neverending Cycle of a Promise Resolution Chain

Promises can eventually crash our app if they become stuck in a never ending cycle of recursion. And no, I'm not talking about these types of recursions:

async function start() {
  while (true) {
    await delay(200)
  }
}

I'm talking about promises that get caught in a neverending recursive promise chain that never actually resolves from invoking itself.

For example in this code snippet below we have an asynchronous function that returns a thenable which resolves and proceeds to returnsthe invocation of the original promise call (start) which proceeds to return another thenable (delay), which returns another invocation of the original promise call, and so on:

async function start() {
  return delay(100).then(() => start())
}

The issue with this is that the original call to the start function never actually has a way to find a resolution to the original promise call. This causes a memory leak in our programs that eventually causes it to crash.

So how can we avoid this?

We can leverage the concept similarly to forEach we discussed earlier and instead of returning the promise result we can make the function ignore the promise result (just like what forEach does) so that every invocation reaches the end of their function block after running through them:

async function start() {
  delay(100).then(() => start())
  // This invocation's resolution is here
}

Although this solves the memory leak it completely removes the ability to capture inner promise rejections occurring from new invocations.

We can make start catch any of its deeply nested rejections with a simple wrapper which solves our issue:

function wrapper() {
  return new Promise((resolve, reject) => {
    ;(function innerWrapper() {
      start()
        .then(() => innerWrapper())
        .catch((error) =>
          reject(error instanceof Error ? error : new Error(String(error))),
        )
    })()
  })
}

Conclusion

And that concludes the end of this post! I hope you found this to be valuable and look out for more in the future!


Top online courses in Web Development

Tags


Read every story from jsmanifest (and thousands of other writers on medium)

Your membership fee directly supports jsmanifest and other writers you read. You'll also get full access to every story on Medium.

Subscribe to the Newsletter

Get continuous updates

Mediumdev.toTwitterGitHubrss

© jsmanifest 2023