In Node JS, asynchronous operations allow us to perform tasks without blocking the main thread, enabling the application to handle multiple requests concurrently. These operations include reading/writing files, making HTTP requests, querying databases, and more.

Callbacks

A callback function is a function passed into another function as an argument, which is then invoked inside the outer function to complete some kind of routine or action.

Synchronous callback functions execute instantly, but asynchronous callback functions execute at a later time.

In this example, we have two functions: greet and sayGoodbye. The greet function takes two arguments: name (a string) and callback (a function).

function greet(name, callback) {
    console.log(`Hello, ${name}!`);
    callback();
}

function sayGoodbye() {
    console.log("Goodbye!");
}; 

greet("Alice", sayGoodbye);

It will output:

Hello, Alice!
Goodbye!

The sayGoodbye function is called immediately following the greet function's completion, making it an example of a synchronous callback function.

In the next example, the asyncOperation function uses setTimeout to simulate an asynchronous task. The callback is executed after a delay of 1 second.

setTimeout() takes a callback function as an argument and executes the callback function after a specified amount of time.

function asyncOperation(callback) {
    console.log("Start operation");
    setTimeout(function() {
        callback();
    }, 1000);
    console.log("End operation");
};

function callback() {
    console.log("Callback executed");
}

asyncOperation(callback);

It will output:

Start operation
End operation
Callback executed

Callbacks can lead to callback hell or pyramid of doom, where multiple nested callbacks can make code hard to read and maintain.

Here is a example of nested callbacks:

function pyramidOfDoom() {
    setTimeout(() => {
        console.log(1);
        setTimeout(() => {
            console.log(2);
            setTimeout(() => {
                console.log(3);
                setTimeout(() => {
                    console.log(4);
                    setTimeout(() => {
                        console.log(5);
                    }, 3500);
                }, 1500);
            }, 500);
        }, 2000);
    }, 1000);
}

In the above code, each new setTimeout is nested inside a higher order function, creating a pyramid shape of deeper and deeper callbacks.

Let's look at another example, say we have a list of users, their posts and their respective comments, like this:

const users = [
    { id: 1, name: 'Luther Hargreeves' },
    { id: 2, name: 'Diego Hargreeves' },
    { id: 3, name: 'Allison Hargreeves' },
];
const posts = [
    { id: 1, title: 'First Post', user_id: 2 },
    { id: 2, title: 'Second Post', user_id: 1 },
    { id: 3, title: 'Third Post', user_id: 2 },
    { id: 4, title: 'Fourth Post', user_id: 2 },
    { id: 5, title: 'Fifth Post', user_id: 3 },
];

const comments = [
    { user_id: 2, post_id: 2, text: 'Great!'},
    { user_id: 3, post_id: 2, text: 'Nice Post!'},
    { user_id: 1, post_id: 3, text: 'Awesome Post!'},
];

Now, we will write a function to get a post by passing the post id. If the post is found, we will retrieve the comments related to that post.

const getPost = (post_id, callback) => {
    const post = posts.find( post => post.id === post_id);
    setTimeout(() => {
        if(post) {
            callback(null, post);
        } else {
            callback("No such post found", undefined);
        }
    }, 1000);
};

const getUser = (user_id, callback) => {
    const user = users.find( user => user.id === user_id);
    setTimeout(() => {
        if(user) {
            callback(null, user);
        } else {
            callback("No such user found", undefined);
        }
    }, 500);
};

const getComments = (post_id, callback) => {
    const result = comments.filter( comment => comment.post_id === post_id);
    setTimeout(() => {
        if(result) {
            callback(null, result);
        } else {
            callback("No comments found", undefined);
        }
    }, 100);
}

getPost(2, (err, post) => {
    if (err) {
        console.error('Error getting post:', err);
    } else {
        getUser(post.user_id, (err, user) => {
            if (err) {
                console.error('Error getting user:', err);
            } else {
                console.log(`${user.name} created a post with title ${post.title}`);
                getComments(post.id, (err, comments) => {
                    if (err) {
                        console.error('Error getting comment:', err);
                    } else {
                        comments.forEach((comment) => {
                            getUser(comment.user_id, (err, user) => {
                                if(err) {
                                    console.error('Error getting user:', err);
                                }
                                else {
                                    console.log(`${user.name} comments: ${comment.text}`);
                                }
                            });
                        });
                    }
                });
            }
        });
    }
});

After executing the above code, you will see the following output:

Luther Hargreeves created a post with title Second Post
Luther Hargreeves comment: Great! 
Allison Hargreeves comment: Nice Post!
Promises

Promises help in solving the callback hell problem. A promise represents a value that may not be available yet but will be resolved in the future, either successfully with a value or unsuccessfully with an error.

You can initialize a promise with the new Promise syntax, and you must initialize it with a function. The function that gets passed to a promise has resolve and reject parameters. The resolve and reject functions handle the success and failure of an operation, respectively.

When we initialize a promise, it has a pending state and undefined value:

// Initialize a promise
const promise = new Promise((resolve, reject) => {})
console.log(promise);
// Promise { <state>: "pending" }

When we call the resolve function and pass a value, it has a fulfilled state and a value.

const promise = new Promise((resolve, reject) => {
    resolve('result value');
});
console.log(promise);
// Promise { <state>: "fulfilled", <value>: "result value" }

If we call reject function and pass a value, it will has a rejected state and a value.

const promise = new Promise((resolve, reject) => {
    reject('error value');
});
console.log(promise);
// Promise { <state>: "rejected", <value>: "error value" }

A promise can have three possible states: pending, fulfilled, and rejected.

  • Pending - Initial state before being resolved or rejected
  • Fulfilled - Successful operation, promise has resolved
  • Rejected - Failed operation, promise has rejected

To get the result of the successful promise execution, we need to register a callback handler using .then like this:

promise.then(function(result) {
    console.log(result); // result value
});

To catch the error, we need to register another callback using .catch like this:

promise.then(function(result) {
    console.log(result); // result value
}).catch(function(error) {
    console.log(error);  // error value
});

For reference, here is a table with the handler methods on Promise objects:

Method Description
then() Handles a resolve. Returns a promise, and calls onFulfilled function asynchronously
catch() Handles a reject. Returns a promise, and calls onRejected function asynchronously
finally() Called when a promise is settled. Returns a promise, and calls onFinally function asynchronously

One of the most useful and frequently used Web APIs that returns a promise is the Fetch API, which allows you to make an asynchronous resource request over a network. fetch is a two-part process, and therefore requires chaining then. This example demonstrates hitting the GitHub API to fetch a user's data, while also handling any potential error:

// Fetch a user from the GitHub API
fetch('https://api.github.com/users/octocat')
    .then((response) => {
        return response.json()
    })
    .then((data) => {
        console.log(data)
    })
    .catch((error) => {
        console.error(error)
    });

The fetch request is sent to the https://api.github.com/users/octocat URL, which asynchronously waits for a response. The first then passes the response to an anonymous function that formats the response as JSON data, then passes the JSON to a second then that logs the data to the console. The catch statement logs any error to the console.

Sometimes, you want to execute two or more related asynchronous operations, where the next operation starts with the result from the previous one. For example:

let p = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(10);
    }, 1000);
});

p.then((result) => {
    console.log(result); // 10
    return result * 2;
}).then((result) => {
    console.log(result); // 20
    return result * 3;
}).then((result) => {
    console.log(result); // 60
    return result * 4;
});

In the above example, the return value in the first then() method is passed to the second then() method and the return value in the second then() method is passed the third then(). Because the then() method returns a new Promise with a value resolved to a value, you can call the then() method on the return Promise.

The way we call the then() method like this is often referred to as a promise chain.

When you call the then() method multiple times on a promise, it is not the promise chaining. For example:

let p = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(10);
    }, 1000);
});
        
p.then((result) => {
    console.log(result); // 10
    return result * 2;
})

p.then((result) => {
    console.log(result); // 10
    return result * 3;
})

p.then((result) => {
    console.log(result); // 10
    return result * 4;
});

Here is the modified code of callback function example that uses Promises instead of callbacks:

const users = [  
    { id: 1, name: 'Luther Hargreeves' },  
    { id: 2, name: 'Diego Hargreeves' },  
    { id: 3, name: 'Allison Hargreeves' },  
];  

const posts = [  
    { id: 1, title: 'First Post', user_id: 2 },  
    { id: 2, title: 'Second Post', user_id: 1 },  
    { id: 3, title: 'Third Post', user_id: 2 },  
    { id: 4, title: 'Fourth Post', user_id: 2 },  
    { id: 5, title: 'Fifth Post', user_id: 3 },  
];  

const comments = [  
    { user_id: 2, post_id: 2, text: 'Great!'},  
    { user_id: 3, post_id: 2, text: 'Nice Post!'},  
    { user_id: 1, post_id: 3, text: 'Awesome Post!'},  
];  

const getPost = (post_id) => {  
    return new Promise((resolve, reject) => {  
        const post = posts.find(post => post.id === post_id);  
        setTimeout(() => {  
            if (post) {  
                resolve(post);  
            } else {  
                reject("No such post found");  
            }  
        }, 1000);  
    });  
};  

const getUser = (user_id) => {  
    return new Promise((resolve, reject) => {  
        const user = users.find(user => user.id === user_id);  
        setTimeout(() => {  
            if (user) {  
                resolve(user);  
            } else {  
                reject("No such user found");  
            }  
        }, 500);  
    });  
};  

const getComments = (post_id) => {  
    return new Promise((resolve, reject) => {  
        const result = comments.filter(comment => comment.post_id === post_id);  
        setTimeout(() => {  
            if (result) {  
                resolve(result);  
            } else {  
                reject("No comments found");  
            }  
        }, 100);  
    });  
};  

getPost(2)  
    .then(post => {  
        return getUser(post.user_id)  
            .then(user => {  
                console.log(`${user.name} created a post with title ${post.title}`);  
                return getComments(post.id);  
            });  
    })  
    .then(comments => {  
        comments.forEach(comment => {  
            getUser(comment.user_id)  
                .then(user => {  
                    console.log(`${user.name} comments: ${comment.text}`);  
                })  
                .catch(err => {  
                    console.error('Error getting user:', err);  
                });  
        });  
    })  
    .catch(err => {  
        console.error('Error:', err);  
    }); 
Async/Await

An async function allows you to handle asynchronous code in a manner that appears synchronous. async functions still use promises under the hood, but have a more traditional JavaScript syntax.

The async keyword allows you to define a function that handles asynchronous operations.

To define an async function, you place the async keyword in front of the function as follows:

async function greet(name) {
    return `Hello, ${name}`;
}

Since async functions always returns a Promise, you can use then() method to consume it, like this:

greet('Alison').then(console.log);

You can also explicitly return a Promise from the greet() function as shown in the following code:

async function greet(name) {
    return Promise.resolve(`Hello, ${name}`);
}

Besides the regular functions, you can use the async keyword in the function expressions:

let greet = async function (name) {
    return `Hello, ${name}`;
}

Or in arrow functions:

let greet = async (name) => `Hello, ${name}`;

Or in methods of classes:

class person {
    async greet(name) {
        return `Hello, ${name}`;
    }
}

You use the await keyword to wait for a Promise to settle either in a resolved or rejected state.

You cannot use the await keyword inside a sync function:

async function greet(name) {
    return Promise.resolve(`Hello, ${name}`);
}
function print() {
    let result = await greet('Alison');
    console.log(result);
}
print();
// SyntaxError: await is only valid in async functions, async generators and modules

You use the await keyword only inside an async function:

async function greet(name) {
    return Promise.resolve(`Hello, ${name}`);
}
async function print() {
    let result = await greet('Alison');
    console.log(result);
}

print(); // Hello, Alison

You cannot use the await keyword at the top level of a script:

<script>
    async function greet(name) {
        return Promise.resolve(`Hello, ${name}`);
    }
    
    let result = await greet('Alison');
    // SyntaxError: await is only valid in async functions, async generators and modules
</script>

Instead, make the script a module:

<script type="module">
    async function greet(name) {
        return Promise.resolve(`Hello, ${name}`);
    }
    
    let result = await greet('Alison');
    // SyntaxError: await is only valid in async functions, async generators and modules
</script>

If a promise resolves normally, then await promise returns the result. But in case of a promise is rejected, it throws the error, just as if there were a throw statement at that line.

async function greet(name) {
    return Promise.reject(new Error('Invalid name'));
}
async function print() {
    let result = await greet('Alison');
    console.log(result);
}

print(); // Uncaught (in promise) Error: Invalid name

The above code is the same as this:

async function greet(name) {
    throw new Error('Invalid name');
}
async function print() {
    let result = await greet('Alison');
    console.log(result);
}

print(); // Uncaught (in promise) Error: Invalid name

You can catch the error by using the try...catch statement, the same way as a regular throw statement:

async function greet(name) {
    return Promise.reject(new Error('Invalid name'));
}
async function print() {
    try {
        let result = await greet('Alison');
        console.log(result);
    } catch(error) {
        console.log(error);
    }
}

print(); // Uncaught (in promise) Error: Invalid name

Here is the modified code of callback function example that uses async/await instead of callbacks:

const users = [  
    { id: 1, name: 'Luther Hargreeves' },  
    { id: 2, name: 'Diego Hargreeves' },  
    { id: 3, name: 'Allison Hargreeves' },  
];  

const posts = [  
    { id: 1, title: 'First Post', user_id: 2 },  
    { id: 2, title: 'Second Post', user_id: 1 },  
    { id: 3, title: 'Third Post', user_id: 2 },  
    { id: 4, title: 'Fourth Post', user_id: 2 },  
    { id: 5, title: 'Fifth Post', user_id: 3 },  
];  

const comments = [  
    { user_id: 2, post_id: 2, text: 'Great!'},  
    { user_id: 3, post_id: 2, text: 'Nice Post!'},  
    { user_id: 1, post_id: 3, text: 'Awesome Post!'},  
];  
  
const getPost = async (post_id) => {  
    return new Promise((resolve, reject) => {  
        const post = posts.find(post => post.id === post_id);  
        setTimeout(() => {  
            if (post) {  
            resolve(post);  
            } else {  
            reject("No such post found");  
            }  
        }, 1000);  
    });  
};  
  
const getUser = async (user_id) => {  
    return new Promise((resolve, reject) => {  
        const user = users.find(user => user.id === user_id);  
        setTimeout(() => {  
            if (user) {  
            resolve(user);  
            } else {  
            reject("No such user found");  
            }  
        }, 500);  
    });  
};  
  
const getComments = async (post_id) => {  
    return new Promise((resolve, reject) => {  
        const result = comments.filter(comment => comment.post_id === post_id);  
        setTimeout(() => {  
            if (result) {  
                resolve(result);  
            } else {  
                reject("No comments found");  
            }  
        }, 100);  
    });  
};  
  
async function main() {  
    try {  
        const post = await getPost(2);  
        const user = await getUser(post.user_id);  
        console.log(`${user.name} created a post with title ${post.title}`);  
        const comments = await getComments(post.id);  
        comments.forEach(async (comment) => {  
            const user = await getUser(comment.user_id);  
            console.log(`${user.name} comments: ${comment.text}`);  
        });  
    } catch (err) {  
        console.error('Error:', err);  
    }  
}  
  
main();