August 21st, 2022
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.
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:
This is because our Promise being returned by getRandomItem
is actually not being awaited in the same function block:
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`)
}
}
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"]
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))),
)
})()
})
}
And that concludes the end of this post! I hope you found this to be valuable and look out for more in the future!
Tags
© jsmanifest 2023