Featured image of post Patterns to Avoid with JavaScript Async/Await Featured image of post Patterns to Avoid with JavaScript Async/Await

Patterns to Avoid with JavaScript Async/Await

Explore common async/await antipatterns in JavaScript and learn how to optimize your asynchronous execution for better performance and error handling.

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:

  1. Combine independent tasks using Promise.all.
  2. Never use forEach with asynchronous callbacks; use for...of or Promise.all instead.
  3. Avoid nested try-catch blocks to keep code readable and easy to debug.