When working with Node.js you'll encounter code that is run synchronously and asynchronously. When things run synchronously, tasks are completed one at a time. All other tasks must be completed before another one can be started. As discussed in our first Node.js post, Node.js uses an event loop to manage asynchronous operations.
Asynchronous Execution in Node.js
The main takeaway is that although you may only have a single thread running JavaScript at any one time, there may be I/O operations running in the background, such as network requests or filesystem writes. The event loop will run any code that needs to run after an asynchronous operation is completed, and if the main thread is released. Starting asynchronous operations will not halt your code and wait for an result in the same scope that they're started. There's a few different ways to tell Node what to do after those operations are completed, and we'll explore them all here.
Callbacks
Traditionally when writing JavaScript, code that's executed after concurrent operations are completed would be contained in a callback function. These callback functions get passed to the function as a parameter so it can be called when the operation completes.
This works perfectly fine; however, it isn't without issues. Callbacks can get out of hand if you need to do multiple concurrent operations in a sequence, with data from each previous operation being used in the next operation. This leads to something known as callback hell and can quickly lead to unmaintainable code. For example, check out the following pseudocode:
app.get('/user/:userId/profile', (req, res) => {
db.get_user(req.params.userId, (err, user) => {
if (err) {
// User can't be queried.
res.status(500).send(err.message);
} else {
// User exists.
db.get_profile(user.profileId, (err, profile) => {
if (err) {
// Profile can't be queried.
res.status(500).send(err.message);
} else {
// Success! Send back the profile.
res.status(200).json(profile);
}
});
}
});
});
This is by no means ideal and you can probably see how this can get out of hand fairly quickly. The code quickly starts turning into a pyramid with the addition of more asynchronous operations, with each asynchronous operation adding another layer of depth to your code.
Promises
If an async function returns a promise instead, callback hell can be avoided. A promise is an object that represents an asynchronous operation that will eventually complete or fail.
A promise will be in any one of these states at any given time:
- Pending: initial state, the operation hasn't completed yet.
- Fulfilled: the operation was completed successfully.
- Rejected: the operation failed.
Let's look at an example. Here we have the same db object with the same methods, but they've been changed to return promises instead. With promises, the code can be re-written:
app.get('/user/:userId/profile', (req, res) => {
db.get_user(req.params.userId).then((user) => {
// Fulfilled: Query the profile for the user.
return db.get_profile(user.profileId);
}).then((profile) => {
// Fulfilled: Send back the profile we just queried.
res.status(200).json(profile);
}).catch((err) => {
// Rejected: Something went wrong while querying the user or the profile.
res.status(500).send(err.message);
});
});
Promises also require callbacks to run your code that needs to operate on data obtained through asynchronous means. The benefit that promises offer is chaining. Using chaining, you can have a promise handler return another promise and pass the result of that promise to the next .then()
handler. Not only does this flatten our code making it easier to read, but it allows us to use the same error handler for all operations if we so desire.
The catch()
handler will be called when a promise is rejected, usually due to an error, and it behaves similarly to the native try
catch
mechanism built into the language. finally()
is also supported, and this will always run not matter if a promise succeeds or fails.
app.get('/user/:userId', (req, res) => {
db.get_user(req.params.userId).then((user) => {
res.status(200).json(user);
}).catch((err) => {
res.status(500).send(err.message);
}).finally(() => {
console.log('User operation completed!'); // This should always run.
});
});
Async and Await
If you understand promises, understanding async / await will be easy. Asyc and await is syntactic sugar on top of promises, and makes asynchronous code easier to read and write by making it look like synchronous code. We can rewrite out previous example using the async
and await
keywords:
app.get('/user/:userId/profile', async (req, res) => {
try {
const user = await db.get_user(req.params.userId);
const profile = await db.get_profile(user.profileId);
// Fulfilled: Send back the profile we just queried.
res.status(200).json(profile);
} catch (err) {
// Rejected: Something went wrong while querying the user or the profile.
res.status(500).send(err.message);
} finally {
console.log('User operation completed!'); // This should always run.
}
});
Using this method, we can assign the results of asynchronous operations to variables without defining callbacks! You'll notice the addition of the async
keyword in the definition of the express route callback. This is required if you plan on using await
to obtain the results of promises in your function.
Adding await
before the get_user
and get_profile
calls will make execution of the route handler wait for the results of those asynchronous operations to come in before continuing. If await
is excluded in this example, the value of user
would be a Promise
object instead of a user object and would not contain the profileId
that we need to query the profile, resulting in an error.
You will also notice this code is now wrapped in a native try / catch block. In order to get the error handling we were using before, we switched to using the native try / catch syntax supported by the language as it's supported by async / await!
Conclusion
Promises and async / await make writing concurrent code in Node.js a much more enjoyable experience.