Skip to main content

Command Palette

Search for a command to run...

NodeJs : Mastering Asynchronous Patterns (async/await)

Updated
8 min read

The core of modern Node.js is its non-blocking, asynchronous nature. While callbacks were the original pattern and .then() chains were a huge improvement, async/await is the current standard for writing clean, scalable, and maintainable asynchronous code. It provides synchronous-looking syntax on top of the powerful Promise system.


Core Concept: async/await Syntax

At its heart, async/await is syntactic sugar over Promises. It doesn't introduce a new paradigm; it just provides a much better way to work with the existing one.

  • async keyword: When placed before a function declaration, it ensures the function implicitly returns a Promise. If the function returns a value (e.g., return 'hello'), the async function will wrap it in a Promise that resolves with that value (Promise.resolve('hello')).

  • await keyword: This can only be used inside an async function. It pauses the execution of the async function until the awaited Promise is settled (either resolved or rejected). If resolved, it "unwraps" the value from the Promise. If rejected, it throws an error.

Here’s a quick comparison:

Promise .then() Chain

JavaScript

function getUserData() {
  return fetchUser(123)
    .then(user => {
      return fetchUserPosts(user.id)
        .then(posts => {
          user.posts = posts;
          return user;
        });
    })
    .catch(err => {
      console.error('Failed to get user data:', err);
      // Have to re-throw or return a rejected promise to propagate
      throw err;
    });
}

async/await Equivalent

JavaScript

async function getUserData() {
  try {
    const user = await fetchUser(123);
    const posts = await fetchUserPosts(user.id);
    user.posts = posts;
    return user;
  } catch (err) {
    console.error('Failed to get user data:', err);
    // Propagate the error
    throw err;
  }
}

The async/await version is flat, linear, and much easier to read and debug.


Robust Error Handling with try/catch

This is one of the most significant advantages of async/await. You can use the standard try...catch...finally blocks that you're familiar with from synchronous programming.

  • try: You place your await calls inside the try block.

  • catch: If any awaited Promise rejects, execution immediately jumps to the catch block. This single block can handle rejections from multiple await calls, as well as any other synchronous errors that might occur.

  • finally: This block will always execute, whether the try block succeeded or an error was caught. It's perfect for cleanup logic, like closing a database connection or releasing a resource.

JavaScript

async function processUserData(userId) {
  let dbClient;
  try {
    dbClient = await getDbClient(); // Get a client from the pool
    const user = await dbClient.query('SELECT * FROM users WHERE id = $1', [userId]);
    if (!user) {
      throw new Error('User not found'); // Synchronous error
    }
    const permissions = await dbClient.query('SELECT * FROM permissions WHERE userId = $1', [userId]); // Async error potential
    return { ...user, permissions };
  } catch (error) {
    // This single block catches:
    // 1. Rejection from getDbClient()
    // 2. Rejection from dbClient.query()
    // 3. The synchronous "User not found" error
    console.error(`Error processing user ${userId}:`, error);
    throw error; // Re-throw to let the caller know something went wrong
  } finally {
    if (dbClient) {
      dbClient.release(); // Always release the client back to the pool
      console.log('Database client released.');
    }
  }
}

Industry Practice: Unhandled Rejection

In a production Node.js application, you should always have a global handler for unhandled promise rejections. If a promise rejects and there's no .catch() or try/catch block to handle it, your application might be left in an inconsistent state.

JavaScript

// In your main server file (e.g., index.js)
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // Application specific logging, throwing an error, or other logic.
  // It's often recommended to gracefully shut down the server here.
  process.exit(1);
});

This acts as a safety net for any promise rejections you might have missed.


Mastering Promise Combinators

These are essential tools for managing multiple concurrent asynchronous operations. An experienced developer knows exactly when to use each one.

Promise.all

Use this when you have multiple promises that all need to succeed. If any one of them fails, the entire operation is considered a failure. It's an "all or nothing" tool.

  • Behavior: Takes an array of promises. Returns a single promise that resolves with an array of the results when all input promises have resolved. It rejects immediately if any of the input promises reject.

  • Industry Use Case: Initializing an application. You might need to connect to a database, a Redis cache, and a message queue. The app can't start if any of these connections fail.

JavaScript

async function initializeServices() {
  try {
    const [dbConnection, redisClient, mqChannel] = await Promise.all([
      connectToDatabase(),
      connectToRedis(),
      connectToMessageQueue()
    ]);

    console.log('All services connected successfully!');
    return { dbConnection, redisClient, mqChannel };
  } catch (error) {
    console.error('Failed to initialize one or more services:', error);
    process.exit(1); // Exit if initialization fails
  }
}

Promise.allSettled

Use this when you need to run multiple promises in parallel, but you want to know the outcome of every single one, regardless of whether they succeed or fail. The failure of one promise does not affect the others.

  • Behavior: Takes an array of promises. Returns a single promise that always resolves with an array of objects. Each object describes the outcome of a promise, having a status ('fulfilled' or 'rejected') and either a value or a reason.

  • Industry Use Case: Calling multiple independent, non-critical third-party APIs. For example, fetching weather data, currency exchange rates, and a stock price to display on a dashboard. If the stock price API fails, you still want to show the weather and exchange rates.

JavaScript

async function getDashboardData() {
  const promises = [
    fetchWeatherAPI(),
    fetchCurrencyAPI(),
    fetchStockPriceAPI() // This one might be flaky
  ];

  const results = await Promise.allSettled(promises);

  const weather = results[0].status === 'fulfilled' ? results[0].value : 'N/A';
  const currency = results[1].status === 'fulfilled' ? results[1].value : 'N/A';
  const stock = results[2].status === 'fulfilled' ? results[2].value : 'Error';

  if (results[2].status === 'rejected') {
    console.warn('Stock price API failed:', results[2].reason);
  }

  return { weather, currency, stock };
}

Promise.race

Use this when you have multiple promises and you only care about the first one that settles (either resolves or rejects).

  • Behavior: Takes an array of promises. Returns a single promise that settles as soon as the first promise in the array settles. The returned promise will resolve or reject with the value or reason of that first promise.

  • Industry Use Case: Implementing a timeout for an asynchronous operation. You "race" your operation against a setTimeout promise. Whichever finishes first determines the outcome.

JavaScript

function promiseWithTimeout(promise, ms) {
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => {
      reject(new Error(`Operation timed out after ${ms}ms`));
    }, ms);
  });

  return Promise.race([
    promise,
    timeoutPromise
  ]);
}

async function fetchDataWithTimeout() {
  try {
    const data = await promiseWithTimeout(fetchDataFromSlowAPI(), 5000); // 5-second timeout
    console.log('Data fetched:', data);
  } catch (error) {
    console.error(error.message); // Will be 'Operation timed out...' if API is too slow
  }
}

Refactoring an Express.js Route

Let's see these concepts in action by refactoring a complex route.

Before: .then() Chain Hell

This route finds an article, then its author, and finally fetches related articles by the same author, excluding the current one. The logic is hard to follow.

JavaScript

app.get('/articles/:id', (req, res, next) => {
  db.articles.findById(req.params.id)
    .then(article => {
      if (!article) {
        const err = new Error('Article not found');
        err.status = 404;
        throw err;
      }
      // Chain to get the author
      return db.users.findById(article.authorId)
        .then(author => {
          article.author = author;
          // Chain to get related articles
          return db.articles.findByAuthorId(author.id)
            .then(allArticles => {
              article.related = allArticles.filter(a => a.id !== article.id);
              res.json(article);
            });
        });
    })
    .catch(err => {
      // A single catch at the end handles all rejections
      next(err);
    });
});

After: Clean async/await

The refactored code is flat, readable, and the business logic is immediately clear. The error handling is also much more explicit.

JavaScript

// A simple async error handling middleware for Express
const asyncHandler = fn => (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
};

app.get('/articles/:id', asyncHandler(async (req, res, next) => {
    const article = await db.articles.findById(req.params.id);

    if (!article) {
        return res.status(404).json({ message: 'Article not found' });
    }

    // Fetch author and related articles concurrently!
    const [author, allArticles] = await Promise.all([
        db.users.findById(article.authorId),
        db.articles.findByAuthorId(article.authorId)
    ]);

    const related = allArticles.filter(a => a.id !== article.id);

    res.json({ ...article, author, related });
}));

Notice we even improved performance by fetching the author and related articles concurrently using Promise.all, which is a common optimization senior developers look for. The asyncHandler wrapper is a common pattern to avoid repeating try/catch in every route.


Questions

"How do you handle errors in a chain of Promises?"

In a traditional .then() chain, you handle errors by attaching a .catch(error => { ... }) block at the end of the chain.

  1. Centralized Catch: A single .catch() at the end of the chain will be triggered if any of the preceding promises in the chain reject.

  2. Propagation: When a promise rejects, the chain skips all subsequent .then() handlers and goes straight to the nearest .catch() handler.

  3. Inline Error Handling: You can also provide a second argument to .then(), onRejected, like so: .then(onFulfilled, onRejected). This allows you to handle an error for a specific step and potentially recover from it, allowing the chain to continue. However, this is less common as it can make the code more complex. Using a .catch() is generally cleaner.

"What are the benefits of using async/await over traditional Promise chains?"

  1. Readability and Maintainability: async/await code looks and behaves like synchronous code. It's linear and avoids the nested "pyramid of doom," making complex logic drastically easier to read, understand, and maintain.

  2. Unified Error Handling: The try...catch block handles both synchronous errors (e.g., JSON.parse on invalid data) and asynchronous errors (promise rejections) in one place. Promise chains require separate mechanisms (.catch() for async, try/catch for sync parts within a .then).

  3. Simpler Debugging: Debugging async/await is far more intuitive. You can step over await lines as if they were normal function calls. The call stack is preserved and makes sense, whereas debugging promise chains can be confusing as you jump between different anonymous functions.

  4. Conditional Logic and Loops: Implementing loops or conditional logic with multiple async steps is trivial with async/await. Doing the same with promise chains often requires complex recursive functions or awkward chaining constructs.

Example: Async logic in a loop

JavaScript

// With async/await - clean and simple
async function processArray(items) {
  const results = [];
  for (const item of items) {
    // Awaits in a loop work sequentially as expected
    const result = await processItem(item); 
    results.push(result);
  }
  return results;
}

Trying to achieve this sequential processing with .then() and a for loop is non-trivial and a common source of bugs for developers new to the paradigm.

5 views

More from this blog

Ashish's Reading List

22 posts

These are some topic i wanted to research on a little so that i learn a little more