How to Use Async/Await in Node.js

This article explains how to write clean, non-blocking asynchronous code in Node.js using async/await. You will learn how to transition from nested callbacks and promises to cleaner syntax, implement robust error handling, and run asynchronous operations in parallel to keep your applications fast and responsive.

Understanding Async/Await

In Node.js, asynchronous operations prevent the single-threaded event loop from blocking. While callbacks and Promises solved this historically, they often led to complex, hard-to-read code (known as “callback hell”). Introduced in ES2017, async/await is syntactic sugar built on top of Promises. It allows you to write asynchronous code that reads like synchronous code, making it highly readable and easier to maintain.

To use it, you define a function with the async keyword. Inside this function, you use the await keyword before any expression that returns a Promise. The execution of the function pauses at the await line until the Promise resolves or rejects.

Basic Syntax Example

Here is a simple comparison showing how async/await simplifies Promise chains.

Using Promises:

const fs = require('fs').promises;

function getUserData() {
    fs.readFile('user.json', 'utf-8')
        .then(data => {
            console.log(JSON.parse(data));
        })
        .catch(err => {
            console.error(err);
        });
}

Using Async/Await:

const fs = require('fs').promises;

async function getUserData() {
    const data = await fs.readFile('user.json', 'utf-8');
    console.log(JSON.parse(data));
}

Robust Error Handling

One of the greatest advantages of async/await is that you can handle both synchronous and asynchronous errors using standard try...catch blocks. This unifies your error-handling logic.

async function fetchUser(userId) {
    try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) {
            throw new Error('Network response was not ok');
        }
        const user = await response.json();
        return user;
    } catch (error) {
        console.error('Failed to fetch user:', error.message);
        // Handle error or rethrow
    }
}

Keeping Code Non-Blocking with Parallel Execution

A common mistake when using async/await is executing independent operations sequentially, which unnecessarily blocks the execution flow.

For example, if you need to fetch a user’s profile and their post history, executing them sequentially doubles the waiting time:

// Slow: Sequential execution (blocking subsequent calls)
async function getProfileData(userId) {
    const profile = await fetchProfile(userId); // Waits for this to finish...
    const posts = await fetchPosts(userId);     // ...before starting this
    return { profile, posts };
}

Because fetching the profile and fetching the posts do not depend on each other, you should run them concurrently using Promise.all(). This triggers both requests simultaneously and waits for both to resolve:

// Fast: Concurrent execution (non-blocking)
async function getProfileData(userId) {
    try {
        const [profile, posts] = await Promise.all([
            fetchProfile(userId),
            fetchPosts(userId)
        ]);
        return { profile, posts };
    } catch (error) {
        console.error('Error fetching data', error);
    }
}

By using Promise.all(), you maximize the efficiency of the Node.js event loop, ensuring your asynchronous code remains clean, readable, and highly performant.