3 Surprising Async/Await Pitfalls Every Web Developer Must Avoid!

Let’s look at 3 practical limitations of async/await in JavaScript

Lakindu Hewawasam
Bits and Pieces

--

If you’re working with Node.js, chances are you’ve worked with asynchronous operations in JavaScript. But if you’re unfamiliar with it, an asynchronous task is an operation executed independently of the main thread in the JavaScript engine. Essentially, this is what lets the application function without a blocking UI.

This is extremely important due to the single-threaded nature of Node.js.

Behind the scenes, Node.js utilizes the event loop to handle all asynchronous operations, keeping the primary thread available for compute functions. This is illustrated below:

Figure: The Node.js Event Loop

Suppose you have a fair knowledge of the event loop. In that case, you’d understand that when Node.js discovers an async operation in the call stack, it will offshore it onto the thread pool, which will execute it asynchronously via the Libuv library. Hereafter, the libuv will execute the operation and push it into the “Event Queue.” The Event Queue will be continuously monitored, and an event from the event queue will be taken and executed on the callback (A.K.A — The Callback Function), which handles the response of the asynchronous operation. That’s essentially how Node.js processes asynchronous operations.

But, how does this look on code?

Well, you’d build asynchronous operations in JavaScript using Promises. A Promise is an object returned by an asynchronous operation representing its progress. For example, consider the snippet below:

// Function that returns a Promise
function fetchData() {
return new Promise((resolve, reject) => {
// Simulating asynchronous operation using setTimeout
setTimeout(() => {
const data = 'Sample Data';
// Simulating a condition for success or failure
const success = true;

if (success) {
resolve(data); // Resolving the promise with data
} else {
reject('Error: Unable to fetch data'); // Rejecting the promise with an error message
}
}, 2000); // Simulated delay of 2 seconds
});
}

// Using the Promise
const fetchDataPromise = fetchData();
fetchDataPromise.then(data => {
console.log('Data received:', data);
})
.catch(error => {
console.error(error);
});

As you can see, the function fetchData returns a Promise - fetchDataPromise that contains two methods - then and catch that lets its consumers listen to an eventual response that's going to be returned by the fetchData function.

This makes JavaScript a lot more powerful and enables you to build apps like real-time chat apps and APIs; however, using asynchronous operations in JavaScript has some common pitfalls that you must consider when designing your app so that you can implement ways to mitigate such issues.

Note: these pitfalls apply to any framework in which you work with JavaScript. For example, you could work with Node.js, React, Angular, Vue.js, and you’d be at risk of these pitfalls.

Pitfall 01: Callback Hell

One of the key issues in using Promise-based asynchronous operations is the callback hell. The callback is hell is a situation that you run into in which your callbacks keep invoking Promises that result in a chain of callbacks. For example, consider the snippet below:

function performAsyncOperation(delay: number, message: string): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => {
console.log(message);
resolve(message);
}, delay);
});
}


export async function callbacks() {
const delay = 1000;
const message = 'Hello World';
return performAsyncOperation(delay, message).then((value) => {
performAsyncOperation(delay, value).then((secondValue) => {
performAsyncOperation(delay, secondValue).then((thirdValue) => {
performAsyncOperation(delay, thirdValue).then(() => {
console.log('End The Callback');
}).catch(() => {
console.log('Error');
});;
}).catch(() => {
console.log('Error');
});;
}).catch(() => {
console.log('Error');
});
});
}

As you can see in the callbacks() function, it uses a function performAsyncOperation and keeps on adding more asynchronous operations to it. Now, this will work perfectly fine in production. But, it will be a mess when you consider the aspect of maintainability. For instance, it's hard to see what callback applies at what level.

So, how do we avoid this?

To fix the callback hell issue, you can convert this to an async/await approach. So, let's see the updated code for this:

function performAsyncOperation(delay: number, message: string): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => {
console.log(message);
resolve(message);
}, delay);
});
}

export async function asyncAwait() {
try {
const delay = 1000;
let message = 'Hello World';

message = await performAsyncOperation(delay, message);
message = await performAsyncOperation(delay, message);
message = await performAsyncOperation(delay, message);

await performAsyncOperation(delay, message);

console.log('End The Callback');
} catch (error) {
console.log('Error:', error);
}
}

As you can see, we’ve successfully refactored our callback hell onto a cleaner approach, which does the same thing using async/await. This lets you execute the same asynchronous code that you executed earlier but with a cleaner approach. The term await means that each line of code will wait until it receives a response. If it returns a successful response, it will proceed to the next, but if it runs into an error, it will jump to the common catch block. By doing so, you avoid the need to maintain multiple error handlers and use a single error handler.

Pitfall 02: Synchronous Function Chaining

Okay, we’ve refactored our code to use async/await blocks to handle the multiple asynchronous invocations, right? Now, you might notice a new issue over here. For instance, consider the snippet below:

function performAsyncOperation(delay: number, message: string): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => {
console.log(message);
resolve(message);
}, delay);
});
}

export async function issueAsyncAwait() {
try {
const delay = 1000;
let message = 'Hello World';

await performAsyncOperation(delay, message);
console.log('Phase 01');
await performAsyncOperation(delay, message);

console.log('End The Callback');
} catch (error) {
console.log('Error:', error);
}
}

In this case, we want to print console.log('Phase 1'). Since performAsyncOperation is executed in a separate process, our log message should ideally be printed even before performAsyncOperation should finish right? Let's investigate this further and see the output:

Figure: Console Output

Upon inspection, we can see that this isn’t what we expected. So what’s happening here?

As the name implies, it “waits” the entire code block until the asynchronous operation returns a response. So this makes your code “synchronous” and creates a waterfall invocation pattern, where your code invokes one after the other.

So, if you have events that are not dependent on each other, and if your event is not dependent on the output of the async operation, you don’t have to necessarily wait until the async operation completes, right?

So, in these cases only, consider using callbacks:

function performAsyncOperation(delay: number, message: string): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => {
console.log(message);
resolve(message);
}, delay);
});
}

export async function asyncAwaitFix() {
try {
const delay = 1000;
let message = 'Hello World';

performAsyncOperation(delay, message).then((resp) => console.log(`Process the resp: ${resp}.`));
console.log('Phase 01');
await performAsyncOperation(delay, message);

console.log('End The Callback');
} catch (error) {
console.log('Error:', error);
}
}

Okay, as you can see, we’ve refactored the first invocation of performAsyncOperation to use the .then() callback. Doing so lets the callback be executed as a true callback and will not create any "waiting" in the code. To test our theory out, let's examine the output:

Figure: Console Output

As you can see, Phase 01 has been printed first, showing that the code no longer waits till the async operation has finished its execution.

But use this cautiously, as you might create callback hells!

Pitfall 03: Performance Issues in Loops

Next, let’s talk about loops. We’ve all written loops in JavaScript, right:

for (let i = 0; i < 5; i++) {
console.log('Iteration number:', i);
}

You loop through a set of elements, and you do some compute on it. But what if we had to perform something asynchronous here? Let’s say that we’re given a bunch of user IDs and asked to fetch information of all IDs (Note: Our API doesn’t support batch reads.). You’d likely do something like this right:

function getUserInfo(id: number) {
return new Promise((resolve) => {
// Simulate asynchronous operation, like fetching data from an API
setTimeout(() => {
resolve({ userId: id, name: `User_${id}`, email: `user${id}@example.com` });
}, 1000);
});
}

export async function asyncForLoopIssue(userIds: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) {
const usersInfo: any[] = [];

for (let i = 0; i < userIds.length; i++) {
const userInfo = await getUserInfo(userIds[i]);
usersInfo.push(userInfo);
}

console.log({ usersInfo });

return usersInfo;
}

Now, once again, this code has no issue. It will work as expected in production. But, you’re bounded to a synchronous loop over here. This means that the next iteration of your loop will begin once a single user information as been collected. So, based on this method, this function will take 10 seconds to execute, and you'd get a synchronous output like this:

As you can see, this happens sequentially, one by one. Now, one way to avoid creating such issues in your code is to use tools like Bit, like I have, to build your components. When you build your code with Bit, it automatically uses linting services like ESLint to lint your code. I did the same thing when I first built this component and ran into this error:

Figure: A Linting Error with Bit

As you can see, Bit automatically noticed an error in this code (the user of synchronous loops), and it did not let me build the component.

But how do we fix this?

Well, you can perform this loop using an async map function as shown below:

function getUserInfo(id: number) {
return new Promise((resolve) => {
// Simulate asynchronous operation, like fetching data from an API
setTimeout(() => {
resolve({ userId: id, name: `User_${id}`, email: `user${id}@example.com` });
}, 1000);
});
}

export async function loopAsyncFix(userIds: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) {
const promises = userIds.map(async (id) => {
const userInfo = await getUserInfo(id);
return userInfo;
})

const usersInfo = await Promise.all(promises);

console.log({ usersInfo });

return usersInfo;
}

Now, this approach will create the same response. However, it does this a bit differently.

  • In approach 01, each iteration began once the current async operation finished its execution.

The term asynchronous means that it should execute without interfering with the main thread.

  • The second approach adheres to the true asynchronous approach as it returns promise objects that will eventually be executed. So, rather than running it sequentially, it will return random invocations where each is independent and execute at its own pace with no relevant order.

Wrapping Up

Well, there we have it. You know three pitfalls you should be aware of when working with JavaScript. It’s important to understand that these issues exist so that you can take the proper measures when you’re designing your application.

If you’d like to explore the code we’ve played around with, check out my Bit Scope.

I hope you found this article helpful. Let me know if you’ve seen any other pitfalls I’ve not covered here!

Thank you for reading.

--

--