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();