Javascript Async/Await Explained in the context of event loops
This article is designed to clarify some confusion around async/await usage in a javascript runtime environment. We will dive into how javascript (known as a single threaded runtime) achieves concurrency using an event loop mechanism.
Call back PTSD? I am happy to inform you there is a cure. Since JS8 or ECMAScript 2017 (ECMAScript is just a standard set by ECMA organization), using promises became a lot more practical due to these two magic keywords async and await.
Lets do a quick introspection into promises before moving on. A promise is a built in control abstraction object which contains a collection of functions. It helps to structure especially code, especially async. Lets illustrate how we can extend a Promise class/object functionality to add an uppercase method.
class MyPromise extends Promise {
upperCase() {
return this.then(str => str.toUpperCase());
}
}
// Usage example 1 (invoking resolve as a direct property)
MyPromise.resolve("it works")
.upperCase()
.then(result => console.log(result))
.catch(error => console.error(error));
// Usage example 2 (instantiating with a custom constructor)
new MyPromise((resolve, reject) => {
if (Math.random() < 0.5) {
resolve("it works");
} else {
reject(new Error("promise rejected; it does this half the time just to show that part working"));
}
})
.upperCase()
.then(result => console.log(result))
.catch(error => console.error(error));
In short by combining callbacks and functions Promises provide an abstraction for an asynchronous operation. They are called promises because they don’t return a result right away but rather a future expected result. Just a note: Callbacks can also just be functions passed to other function but for the purposes of this article we will be referring to callback promises. Now that we have a basic understanding of promises and callbacks it is time to ask, what are these two magical keywords and how they make our life better as JS developers?
Async
keyword is typically used during a function declaration. It tells the JavaScript runtime environment (V8, Node.js, or Deno) that it should execute the function body or at least part of it in an asynchronous fashion. The return value of this is function is a Promise Object.
Await
keyword is typically be used to store the result of the Promise Object. It informs the JavaScript runtime environment that it should process the contents of the block on a separate thread and proceed with the normal program execution. Await can be used with any Promise. But it is most useful when combined with async. Here is an example of a practical use case:
const fastPromise = async () => {
// do something on a separate thread
setTimeout(() => console.log("doing some fast calculations..."), 1000);
}
const slowPromise = async () => {
// do something on a separate thread
setTimeout(() => console.log("doing some slow calculations..."), 10000);
}const fastResponse = await fastPromise()
const slowResponse = await slowPromise()return {
quick: fastResponse,
slow: slowResponse
}
We can run these two network calls concurrently and completely separate from each other and return the combined results in the end which is not possible to do with promises alone. You would have to wait for the first response, then second response, hence the single threaded limitation.
const getResponses = () => {
fastApiCall()
.then((fast) => {
return slowApiCall(slow).then({
return {
quick: fastResponse,
slow: slowResponse
}
})
})
}
- Aside from concurrency using the async/await combo helps write javascript in a sequential order, making it a lot easier to read and debug. Let us look at a simple example which does not involve network calls.
const multiply = (num1, num2) =>
new Promise((resolve) => resolve(num1 * num2))
const sum = (total, num3) =>
new Promise((resolve) => resolve(total + num3))// sync implementation
const getResult = () => (
multiply(10, 20)
.then(product => sum(product, 30))
.then(total => {
console.log('total using .then', total)
return total
})
)// async implementation
const getResultAsync = async () => {
const product = await multiply(10, 20)
const total = await sum(product, 30)
console.log('total using async', total)
return total
}// Just for verification, We still love you callbacks :)
getResult().then(res =>
getResultAsync().then(resAsync =>
console.log('Same result ??? ', resAsync === res)
)
)
Same result, different methods. Lets dive into how these two version are executed under the hood. At the base of the Javascript Engine (V8 engine for example is a commonly used implementation for Chrome and NodeJS) lies an event loop . If you would like a deep dive into the inner works this article is great but here is my take. The JS engine puts the executable line/blocks of code of a program into a call stack or an task/microtask queue. Essentially if it can process right away it will go into the stack. (Last in First out). Otherwise it goes into the corresponding queue for scheduling.
Task: A task is any JavaScript code which is scheduled to be run by the standard mechanisms such as initially starting to run a program, an event callback being run, or an interval or timeout being fired. Tasks are typically processed by the call stack.
Callback: 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.
Callback Queue: Scheduler for callback functions that need to be asynchronously executed. Think of ordinary callback functions coming from the setTimeout() API after the timer expires. Or a callback used as a parameter by the map function.
Microtask: JavaScript code that should execute after the function or program which created it exits and only if the JavaScript execution stack is empty, but before returning control to the event loop. A promise that is expected from a data fetch call would be a good example.
Microtask Queue: is a virtually managed data structure where microtasks get added and executed. as long as no other JavaScript is mid-execution, and at the end of each task. Microtask Queue is similar to the Callback Queue, but has higher priority. JavaScript promises and the Mutation Observer API both use this queue to run their callbacks, but there are other times when the ability to defer work until the current event loop pass is wrapping up. Different browsers have slightly different implementation of the microtask queue but mozilla has good documentation to get a deeper dive.
Back to our example. In getResultAsync because we are using the async keyword the execution of the function body gets paused, and the rest of the async function gets run in a microtask. So we return the execution control to the global scope so that javascript engine can continue to execute tasks in the call stack (events, other scripts, etc.), and once the task queue is empty we resume with the microtask queue.
In getResult the callback returned from the Promise, gets assigned its own thread and added to the callback queue. So we return the execution control to the global scope so that javascript engine can continue to execute tasks in the call stack (events, other scripts, etc.), and once the task queue is empty we resume with the callback queue. Notice that because the second call (summation function) is nested, and since its part of the same block it can not execute, making the code single threaded.
As you can see only difference between these two examples is subtle but they do behave similarly and it is how Javascript can utilize multithreading. Happy Coding!