Introduction
Since the introduction of async/await in ES2017, writing asynchronous JavaScript has become far more intuitive. It allows developers to express asynchronous logic in a synchronous-looking structure, drastically improving codebase readability compared to nested Promise chains (.then().catch()).
However, this clean synchronous-like syntax can hide performance pitfalls and error-handling bugs. In this article, we’ll dive into common async/await antipatterns and learn how to refactor them into high-performance, resilient code.
Antipattern 1: Unnecessary Serialization (Sequential Awaiting)
The most common mistake is executing independent asynchronous tasks sequentially, missing opportunities for parallel execution.
Code to Avoid
// Fetching three independent datasets sequentially
async function getUserProfile(userId) {
const user = await fetchUser(userId); // takes 1.0s
const posts = await fetchPosts(userId); // takes 1.5s
const followers = await fetchFollowers(userId); // takes 0.8s
return { user, posts, followers };
}
Here, fetchPosts doesn’t start until fetchUser completes, and fetchFollowers is blocked by both. The total execution time is 1.0 + 1.5 + 0.8 = 3.3 seconds. Since these requests do not depend on each other, they should be requested concurrently.
Refactored Code (Using Promise.all)
async function getUserProfile(userId) {
// Fire requests concurrently and wait for all to resolve
const [user, posts, followers] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchFollowers(userId)
]);
return { user, posts, followers };
}
Using Promise.all fires all three fetches at the same time. The total wait time drops to the duration of the longest request: 1.5 seconds.
Antipattern 2: Nested try-catch Blocks
When handling potential API failures, some developers revert to deep nesting patterns inside async functions, mimicking the legacy callback hell.
Code to Avoid
async function processData() {
try {
const rawData = await fetchData();
try {
const parsedData = await parseData(rawData);
try {
await saveToDatabase(parsedData);
} catch (dbError) {
console.error("Database save failed", dbError);
}
} catch (parseError) {
console.error("Parsing failed", parseError);
}
} catch (fetchError) {
console.error("Fetch failed", fetchError);
}
}
Deep nesting defeats the purpose of async/await, making the function hard to scan and maintain.
Refactored Code
If you want to catch any error during the pipeline, wrap the entire flow in a single, top-level try-catch block. If you must handle specific errors uniquely, classify them within the catch block or attach .catch() handlers to individual promises.
async function processData() {
try {
const rawData = await fetchData();
const parsedData = await parseData(rawData);
await saveToDatabase(parsedData);
} catch (error) {
// Handle or categorize errors centrally
handleProcessingError(error);
}
}
Antipattern 3: Using async callbacks inside Array.prototype.forEach()
Using forEach with an async callback will compile without syntax errors, but the async tasks will run out of order.
Code to Avoid
// Iterating and sending emails asynchronously
async function sendEmails(users) {
users.forEach(async (user) => {
await mailer.send(user.email);
});
console.log("All emails queued or sent!"); // <- Executes before mails actually finish sending!
}
Array.prototype.forEach does not await the resolution of promises returned by its callback function. It fires all callbacks synchronously. As a result, the subsequent log statement prints immediately before any email actually reaches the recipient.
Refactored Code (Using for…of or Promise.all)
If tasks must be executed sequentially (one by one):
async function sendEmailsSerially(users) {
for (const user of users) {
await mailer.send(user.email); // awaits each send
}
console.log("All emails sent successfully.");
}
If tasks should run concurrently:
async function sendEmailsInParallel(users) {
const promises = users.map(user => mailer.send(user.email));
await Promise.all(promises); // waits for all promises to resolve
console.log("All emails sent successfully.");
}
Antipattern 4: Async Functions without an await Keyword
Declaring a function as async when it contains only synchronous operations is an unnecessary performance drain.
Code to Avoid
// Simple synchronous math calculation
async function calculateSum(a, b) {
return a + b;
}
When you prefix a function with async, the runtime engine automatically wraps the return value in a resolved Promise. This adds minor runtime overhead (microtask queue scheduling and memory allocation). If there is no await inside the function, keep it synchronous.
Conclusion
Async/await provides elegant syntax for writing asynchronous JavaScript, but it is still built on Promises under the hood. Keep these rules in mind:
- Combine independent tasks using
Promise.all. - Never use
forEachwith asynchronous callbacks; usefor...oforPromise.allinstead. - Avoid nested
try-catchblocks to keep code readable and easy to debug.
