Callbacks, Promises, and Async/Await in Node.js

If you are learning Node.js, one of the most important concepts you need to understand is asynchronous programming. Node.js is designed to handle many operations asynchronously. Instead of waiting for one operation to finish before starting another, Node.js can continue doing other work while waiting for tasks such as file operations, database queries, or API requests to complete. To work with asynchronous operations in Node.js, you will commonly encounter three approaches:
  • Callbacks
  • Promises
  • Async/Await
In this article, we will understand each approach with simple examples and learn why modern Node.js applications generally prefer Promises and async/await.

What is Asynchronous Programming?

Let’s first understand the difference between synchronous and asynchronous programming.

Synchronous Code

In synchronous programming, tasks are generally executed one after another. For example:
console.log("First");

console.log("Second");

console.log("Third");
The output is:
First
Second
Third
Each statement runs in order. If a particular operation takes a long time, the next operation generally has to wait for it.

Asynchronous Code

With asynchronous programming, an operation can start and allow other code to continue while it is waiting to complete. For example:
console.log("First");

setTimeout(() => {
    console.log("Second");
}, 2000);

console.log("Third");
The output is:
First
Third
Second
Why? The setTimeout() operation schedules its callback and allows the rest of the code to continue. After approximately two seconds, the callback executes. This is the basic idea behind asynchronous programming.

Why is Asynchronous Programming Important in Node.js?

Node.js is commonly used for applications that perform many I/O operations, such as:
  • Reading files
  • Writing files
  • Database operations
  • Sending HTTP requests
  • Calling APIs
  • Processing network requests
  • Working with streams
Suppose your server receives a request and needs to read information from a database. If the server had to completely stop and wait for every database operation, handling many users could become inefficient. Node.js uses an event-driven, non-blocking approach so that it can continue handling other work while asynchronous operations are in progress. This is why understanding asynchronous programming is essential for Node.js developers.

What is a Callback?

A callback is a function that is passed to another function and executed later. For example:
function greet(name, callback) {
    console.log(`Hello, ${name}`);

    callback();
}

function finished() {
    console.log("Greeting completed");
}

greet("John", finished);
Output:
Hello, John
Greeting completed
Here:
finished
is passed as a callback to the greet() function. The greet() function calls it using:
callback();

Callbacks in Asynchronous Operations

Callbacks become especially useful when working with asynchronous operations. For example:
setTimeout(() => {
    console.log("Operation completed");
}, 2000);
The function:
() => {
    console.log("Operation completed");
}
is a callback. It runs after the timer completes.

Node.js Callback Example

Node.js provides many APIs that traditionally use callbacks. For example, the File System module can read a file asynchronously:
const fs = require("fs");

fs.readFile("data.txt", "utf8", (error, data) => {
    if (error) {
        console.error(error);
        return;
    }

    console.log(data);
});
The callback receives two common values:
(error, data)
The first value represents an error, if one occurred. The second contains the result when the operation succeeds.

The Error-First Callback Pattern

Node.js commonly uses an error-first callback pattern. For example:
function(error, result) {
    // Handle result or error
}
The first argument represents an error. If the operation succeeds:
error
is usually null. For example:
fs.readFile("data.txt", "utf8", (error, data) => {
    if (error) {
        console.error(error);
        return;
    }

    console.log(data);
});
This pattern was very common in older Node.js applications and is still present in many Node.js APIs.

The Problem with Too Many Callbacks

Callbacks work, but complex applications can become difficult to read when many asynchronous operations depend on each other. For example:
getUser(userId, (error, user) => {
    if (error) {
        return console.error(error);
    }

    getOrders(user.id, (error, orders) => {
        if (error) {
            return console.error(error);
        }

        getProducts(orders, (error, products) => {
            if (error) {
                return console.error(error);
            }

            console.log(products);
        });
    });
});
When callbacks become deeply nested, the code can become harder to read and maintain. This style is sometimes called callback hell or the pyramid of doom. Promises were introduced as a cleaner way to handle asynchronous operations.

What is a Promise?

A Promise represents the eventual result of an asynchronous operation. A Promise can be in one of three states:
  1. Pending
  2. Fulfilled
  3. Rejected

Pending

The operation is still running.

Fulfilled

The operation completed successfully.

Rejected

The operation failed. You can think of a Promise as a value that will be available sometime in the future.

Creating a Promise

You can create a Promise using the Promise constructor:
const promise = new Promise((resolve, reject) => {
    // asynchronous operation

    resolve("Operation successful");
});
Here:
resolve()
indicates that the operation succeeded. And:
reject()
indicates that the operation failed. For example:
const promise = new Promise((resolve, reject) => {
    const success = true;

    if (success) {
        resolve("Task completed");
    } else {
        reject("Task failed");
    }
});

Using a Promise with then()

The .then() method is used to handle a successfully fulfilled Promise.
promise.then((result) => {
    console.log(result);
});
Output:
Task completed

Handling Promise Errors with catch()

The .catch() method is used when a Promise is rejected.
promise
    .then((result) => {
        console.log(result);
    })
    .catch((error) => {
        console.error(error);
    });
This gives you a cleaner way to handle asynchronous success and failure.

Using finally()

Promises also support .finally(). The code inside finally() runs after the Promise settles, regardless of whether it was fulfilled or rejected. For example:
promise
    .then((result) => {
        console.log(result);
    })
    .catch((error) => {
        console.error(error);
    })
    .finally(() => {
        console.log("Operation finished");
    });

A Practical Promise Example

Let’s create a simple function that returns a Promise:
function getUser() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve({
                id: 1,
                name: "John"
            });
        }, 1000);
    });
}
We can use it like this:
getUser()
    .then((user) => {
        console.log(user);
    })
    .catch((error) => {
        console.error(error);
    });
After approximately one second, the user object is returned.

Promise Chaining

One of the major advantages of Promises is that asynchronous operations can be chained. For example:
getUser()
    .then((user) => {
        return getOrders(user.id);
    })
    .then((orders) => {
        return getProducts(orders);
    })
    .then((products) => {
        console.log(products);
    })
    .catch((error) => {
        console.error(error);
    });
Each .then() can return another Promise. This makes complex asynchronous workflows easier to structure than deeply nested callbacks.

What is Async/Await?

async/await provides a cleaner syntax for working with Promises. Instead of writing:
getUser()
    .then((user) => {
        console.log(user);
    })
    .catch((error) => {
        console.error(error);
    });
you can write:
async function showUser() {
    try {
        const user = await getUser();

        console.log(user);
    } catch (error) {
        console.error(error);
    }
}
The code is easier to read because it looks similar to synchronous code while still working with asynchronous Promises.

The async Keyword

The async keyword is used to define an asynchronous function. For example:
async function greet() {
    return "Hello";
}
An important point is that an async function always returns a Promise. For example:
async function getMessage() {
    return "Hello Node.js";
}

getMessage().then((message) => {
    console.log(message);
});
Output:
Hello Node.js
Even though the function returns a string, the async keyword causes the function to return a Promise that fulfills with that string.

The await Keyword

The await keyword is used inside an async function to wait for a Promise to settle. For example:
async function getUserData() {
    const user = await getUser();

    console.log(user);
}
Here:
const user = await getUser();
waits for the getUser() Promise to fulfill before continuing within that async function. Importantly, this does not mean that Node.js blocks the entire server while waiting.

Handling Errors with async/await

A common way to handle errors with async/await is try...catch.
async function getUserData() {
    try {
        const user = await getUser();

        console.log(user);
    } catch (error) {
        console.error(error);
    }
}
If the Promise is rejected, the error is caught by:
catch
This approach is often easier to read than a long chain of .then() and .catch() calls.

Callback vs Promise vs Async/Await

Let’s compare the three approaches.

Callback

getUser((error, user) => {
    if (error) {
        console.error(error);
        return;
    }

    console.log(user);
});

Promise

getUser()
    .then((user) => {
        console.log(user);
    })
    .catch((error) => {
        console.error(error);
    });

Async/Await

async function showUser() {
    try {
        const user = await getUser();

        console.log(user);
    } catch (error) {
        console.error(error);
    }
}
All three approaches can handle asynchronous operations, but they have different syntax and characteristics.

Converting Callback-Based Code to Promises

Node.js provides a utility that can help convert many callback-style functions into Promise-based functions. For example, with the File System module:
const fs = require("fs");
const { promisify } = require("util");

const readFile = promisify(fs.readFile);
You can then use:
readFile("data.txt", "utf8")
    .then((data) => {
        console.log(data);
    })
    .catch((error) => {
        console.error(error);
    });
However, many modern Node.js APIs already provide Promise-based versions, so promisify() is not always necessary.

Using the Promise-Based File System API

Modern Node.js provides Promise-based APIs directly. For example:
const fs = require("fs/promises");

async function readData() {
    try {
        const data = await fs.readFile("data.txt", "utf8");

        console.log(data);
    } catch (error) {
        console.error(error);
    }
}

readData();
This is much easier to read than deeply nested callbacks.

Sequential Asynchronous Operations

Sometimes one operation depends on the result of another. For example:
async function processUser() {
    try {
        const user = await getUser();

        const orders = await getOrders(user.id);

        console.log(orders);
    } catch (error) {
        console.error(error);
    }
}
Here, the second operation requires the result of the first. The operations therefore need to happen in sequence.

Running Independent Promises in Parallel

Sometimes operations are independent of each other. For example:
async function loadData() {
    const usersPromise = getUsers();
    const productsPromise = getProducts();

    const [users, products] = await Promise.all([
        usersPromise,
        productsPromise
    ]);

    console.log(users);
    console.log(products);
}
Promise.all() allows independent asynchronous operations to run concurrently rather than waiting for one to finish before starting the next. This can improve performance when the operations do not depend on each other.

Promise.all()

Promise.all() accepts an array of Promises.
const results = await Promise.all([
    promise1,
    promise2,
    promise3
]);
If all Promises fulfill, you receive their results. If one of the Promises rejects, Promise.all() rejects. For example:
const results = await Promise.all([
    getUsers(),
    getProducts(),
    getOrders()
]);
The result is an array containing the corresponding results.

Promise.allSettled()

Sometimes you want to know the result of every Promise, even if some operations fail. In that situation, Promise.allSettled() can be useful.
const results = await Promise.allSettled([
    getUsers(),
    getProducts(),
    getOrders()
]);
It provides the status of each operation. A result can have a status such as:
fulfilled
or:
rejected
This is useful when one failed operation should not prevent you from examining the results of the others.

Promise.race()

Promise.race() settles when the first Promise settles. For example:
const result = await Promise.race([
    request1,
    request2
]);
Whichever Promise settles first determines the result. This can be useful in certain timeout or competing-operation scenarios.

Avoiding Unnecessary await

Consider:
const users = await getUsers();
const products = await getProducts();
If getUsers() and getProducts() are completely independent, this makes the second operation start only after the first one has completed. Instead, you can use:
const [users, products] = await Promise.all([
    getUsers(),
    getProducts()
]);
This allows both operations to proceed concurrently.

Common Mistakes with Async/Await

Mistake 1: Forgetting await

For example:
const user = getUser();

console.log(user.name);
If getUser() returns a Promise, user is a Promise rather than the actual user object. Use:
const user = await getUser();
inside an appropriate async context.

Mistake 2: Forgetting Error Handling

Avoid leaving important asynchronous operations without error handling. Use:
try {
    const data = await getData();
} catch (error) {
    console.error(error);
}

Mistake 3: Using await for Independent Operations Sequentially

Instead of:
const users = await getUsers();
const products = await getProducts();
consider:
const [users, products] = await Promise.all([
    getUsers(),
    getProducts()
]);
when the operations are independent.

Mistake 4: Using await Outside an Appropriate Context

In CommonJS code, await generally needs to be inside an async function. For example:
async function main() {
    const data = await getData();

    console.log(data);
}

main();
ES Modules also support top-level await in appropriate module contexts.

Real-World Example with Express.js

Async/await becomes especially useful when working with Express.js and databases or external APIs. For example:
app.get("/users", async (req, res) => {
    try {
        const users = await getUsersFromDatabase();

        res.json(users);
    } catch (error) {
        console.error(error);

        res.status(500).json({
            message: "Unable to retrieve users"
        });
    }
});
This is a common pattern in Node.js applications. The route handler is asynchronous, waits for the database operation, and sends a response when the operation completes.

Best Practices for Asynchronous Node.js Code

Keep these practices in mind when working with asynchronous operations.

Prefer async/await for Readability

For new application code, async/await is often easier to read and maintain than deeply nested callbacks.

Handle Errors

Use try...catch or appropriate Promise error handling.

Use Promise.all() When Appropriate

Run independent asynchronous operations concurrently when doing so is safe and useful.

Avoid Callback Nesting

If callback-based code becomes deeply nested, consider restructuring it or using Promise-based APIs.

Don’t Block the Event Loop

Avoid expensive synchronous operations in request-handling paths when they could block the server from processing other work.

Understand the Difference Between Sequential and Concurrent Work

Not every await should be executed one after another. Identify which operations actually depend on each other.

Callback, Promise, and Async/Await: Quick Comparison

Feature Callback Promise Async/Await
Syntax Function passed to another function .then() / .catch() async / await
Error handling Error-first convention .catch() try...catch
Readability Can become difficult when nested Better Usually easiest to read
Chaining Can become nested Supported Straightforward
Modern Node.js code Still used Common Very common
Best for beginners Basic concept Important Recommended approach to learn

Conclusion

Asynchronous programming is a fundamental part of Node.js development. The three approaches you should understand are:

Callbacks

Functions that are executed after an operation completes.
doSomething((error, result) => {
    // Handle result
});

Promises

Objects representing the eventual completion or failure of an asynchronous operation.
doSomething()
    .then(result => {
        console.log(result);
    })
    .catch(error => {
        console.error(error);
    });

Async/Await

A cleaner syntax for working with Promises.
async function run() {
    try {
        const result = await doSomething();

        console.log(result);
    } catch (error) {
        console.error(error);
    }
}
For modern Node.js development, you should be comfortable with all three, because you will encounter callback-based code in existing projects and APIs, while Promises and async/await are widely used for modern asynchronous programming. Once you understand callbacks, Promises, and async/await, you are ready to explore more practical Node.js topics such as working with the File System, HTTP requests, APIs, databases, and Express.js applications.
Exit mobile version