Asynchronous JavaScript: The Event Loop, Callbacks, Promises, and Async/Await
JavaScript is, strictly speaking, synchronous. It behaves asynchronously, though, thanks to its runtime environment — making it, in effect, asynchronous.
These are many questions that you may not know the answer if you haven’t explored what asynchronous JavaScript is. In this article, I would speak of the following and dive deep into asynchronous programming with JavaScript.
- The JavaScript event loop
- Callbacks and callback hell
- Promises
- Asynchronous chaining
- Async/Await
1. JavaScript Event Loop
With the introduction of ES6, asynchronous programming became very popular in the JavaScript community. Up until then, JavaScript did not have a direct notion of asynchrony introduced. The JavaScript engine does not run in isolation. It runs in a hosting environment. There is a built-in mechanism available in JavaScript runtime called the event loop. It handles the execution of multiple chunks of your program over time, each time invoking the JS Engine.
When an asynchronous function (e.g., a network request) with a callback is processed, the callstack executes the asynchronous function, and the callback gets added to the callback queue. When the network request completes, the JavaScript runtime removes the asynchronous function from the callstack. Then the event loop picks the next function in the callback queue (this could be the callback function that was last added or a function that was added to the queue earlier) and pushes it to the callstack. Since the callback function was added to the callback queue, it will certainly end up in the callstack sometime in the future. Due to this, the thread is not blocked until a computationally intensive task is complete.
Tip: Share your reusable components between projects using Bit (Github). Bit makes it simple to share, document, and organize independent components from any project.
Use it to maximize code reuse, collaborate on independent components, and build apps that scale.
Bit supports Node, TypeScript, React, Vue, Angular, and more.
2. Callbacks
In order to perform asynchronous processing than waiting for a function to complete its execution, a process is told to call another function when the result is ready. This “other function” is called the callback. It is passed as an argument to any asynchronous function.
asyncFunction(callback1);
console.log('asyncFunction has been called');// Call when asyncFunction completes
function callback1(error) {
if (!error) console.log('asyncFunction is complete');
}
It does not matter how long anasyncFunction
takes to execute. With the above implementation, it is certain that callback1
will run at some point in the future. Therefore, the console output would be as follows.
asyncFunction has been called
asyncFunction is complete
Callback Hell
Even though the callback-based solution seemed a good option for asynchronous programming in JavaScript, it introduces other problems. A single callback will be attached to a single asynchronous function. However, by nesting asynchronous functions, we could introduce a callback hell.
firstFunction(args, function() {
secondFunction(args, function() {
thirdFunction(args, function() {
// And so on…
});
});
});
Here, each subsequent callback takes an argument from the previous callbacks. This sequence makes the code structure looks like a pyramid, creating difficulties to read and maintain. Further, if there is an error in one function, all the other functions would also get affected, making error handling more complicated.
In order to address this, Promises were later introduced.
3. Promises
ES6 introduced Promises, which provided a clearer syntax that chains asynchronous commands as a series. This is more readable than callbacks and does not result in a callback-hell.
What exactly is a Promise?
A Promise is a JavaScript object with a value that may not be available at the moment when the code line executes. These values are resolved at some point in the future. It allows you to write asynchronous code more synchronously. A promise can have three states: Pending (not fulfilled or rejected), Fulfilled (Operation is successful), Rejected (Operation is unsuccessful).
// Promise constructor
let promise = new Promise(function(resolve, reject) {
const x = "apple";
const y = "apple"if (x === y) {
resolve();
} else {
reject();
}
});// Consuming the Promise
promise
.then(function () {
console.log('Successful');
})
.catch(function () {
console.log('Some error has occured');
});
The promise constructor takes one argument where we need to pass a callback function. The callback function takes two arguments, resolve()
and reject()
. Any functionality that needs to be executed after the Promise is completed (e.g., After a network request) should be placed inside then()
. According to the above example, since the operation would be successful, the console will output “Successful.”
4. Asynchronous Chaining
We can use Promises for asynchronous chaining as well. When we do chaining, after each Promise execution completion, it will handover the execution to the next one as defined in the chain. The clear benefit here is that the code is human readable, rather going into callback hell.
promise
.then(function () {
console.log('Promise object resolved');
})
.then(function () {
console.log('Operation successful');
})
.catch(function () {
console.log('Some error has occured');
});// Console Output
Promise object resolved
Operation sucsessful
Likewise, with the use of promises and Promise chaining, we can easily implement asynchronous functions.
5. Async/Await
This is another method to write asynchronous code in JavaScript. Await
is known as a nicer syntax for Promises. However, this method has some advantages over promises, such as,
- Results in concise and cleaner code.
- Error handling is much more straightforward (Allows handling both synchronous and asynchronous errors with the same construct —
try/catch
).
Let’s consider the below asynchronous code block written using Promises.
const makeRequest = () => {
try {
getJSON()
.then(result => {
const data = JSON.parse(result)
console.log(data)
})
// handles asynchronous errors
.catch((err) => {
console.log(err)
})
} catch (err) {
console.log(err)
}
If the same code block is written using async/await
, the code would be cleaner and improves human readability.
const makeRequest = async () => {
try {
const data = JSON.parse(await getJSON())
console.log(data)
} catch (err) {
console.log(err)
}
}
This is one of the most revolutionary features added to JavaScript over the past few years. It provides an intuitive replacement for Promises.
However, using either Promises or Async/Await in your code for asynchronous programming is an important decision you have to make. Both methods would allow you to write asynchronous functions and improve the performance of your application.