DEV Community

Joseph
Joseph

Posted on

Breaking Down How Asynchronous JavaScript Works Under the Hood as a Noob

What is Asynchronous JavaScript

In a nutshell, asynchronicity means that if JavaScript needs to wait for an operation to complete, it can execute the rest of the code while waiting for that instruction to finish executing in the background. This means your code can remain responsive to other events while that task runs, rather than waiting for it to finish.

The Motivation Behind Asynchronous JavaScript

I want you to imagine a world where asynchronous JavaScript does not exist, which means no async/await or promises. Keep in mind that that JavaScript is a single-threaded language which means that code is run in the order that it appears. Unlike languages like Rust and C, there isn’t a way to create another execution thread to run possibly long-running code like network requests without blocking the main execution thread.

More practically, this means that whenever you need to perform tasks like making an HTTP request, accessing the user’s camera and/or mic or even asking the user to select files, nothing else can run. For example, if you need to make multiple HTTP requests, you would have to wait for one to finish resolving before being able to move on to the next request, which is going to slow down the performance of your website. What a horrible time your users would have.

This, and many problems like it, is what asynchronous JavaScript aims to solve. With that said, it does raise a question of how asynchronicity is even possible when we only have one thread to work with.

How it Works Under the Hood

To understand how asynchronicity is possible in JavaScript there are two concepts we need to understand: Callbacks and Promises, and how they interact with JavaScript’s callback and promise queue and the event loop.

Callbacks, Promises and the Call Stack

The CallStack

The call stack is JavaScript’s main execution thread. Any function or instruction being executed is done so here. To prevent longer-running instructions from blocking other instructions from executing, they are normally removed from the stack and executed by whatever runtime you are using, i.e. node.js, the browser, etc.

Here is an example of how the call stack executes a program:

function add(a, b){
    return a + b
}

function double(a){
    return 2 * a
}

console.log(double(add(5,3))) // 16
Enter fullscreen mode Exit fullscreen mode

conosle.log(double(add(5,3))) will be added to the call stack first. After which, the double function is called and added to the call stack. The double function calls the add function, which adds it to the call stack. As we calculate the return value for each of the functions called, said functions are removed from the call stack until we print the final value of 16 at which point console.log() is also removed from the call stack and the program ends.

Callbacks

A callback is a function passed as an argument to another function and is executed after the first function completes its operations. That first function is normally some call to the browser API, setTimeout is an example of this. Once that first function has finished executing, the function passed in as a parameter is placed in the Callback Queue.

The Callback Queue is a mechanism that JavaScript uses to keep track of the callback functions that need to be executed. Instructions in the callback queue are only executed when the call stack is empty.

console.log("First")

setTimeout(function(){
 console.log("Second")
}, 0)

console.log("Third")

// Result:
// First
// Third
// Second
Enter fullscreen mode Exit fullscreen mode

From this example, we can see that although it looks like Second should be printed to the console before Third, the opposite is true. Because the function that prints Second to the console is wrapped in the timeout function, console.log("Second") is added to the callback queue and therefore only executed after console.log("Third").

Promises

A promise is an object that represents the eventual completion or failure of an asynchronous operation and its resulting value. They provide a simpler way to handle asynchronous operations compared to callbacks by allowing you to chain together multiple operations and handle errors more elegantly. Similarly to callbacks, when a promise is resolved, any function passed into it as an argument is added to a queue called the promise queue or micro-task queue.

Functions and instructions placed in this queue are given higher priority than those in the callback queue. This means that all of the instructions in this queue have to be processed before we start executing any of the instructions in the callback queue.

Here is a code snippet to show this:

const links = []

for(let i = 0; i < 5; i++){
    if(i == 0){
        setTimeout(() => {
        console.log(i)
    }, 0)
  }
    links.push(fetch('https://picsum.photos/200').then(res => res))
  console.log()
  console.log(links.join(" "))
}

// output:
// "[object Promise]"
// "[object Promise] [object Promise]"
// "[object Promise] [object Promise] [object Promise]"
// "[object Promise] [object Promise] [object Promise] [object Promise]"
// "[object Promise] [object Promise] [object Promise] [object Promise] [object Promise]"
// 0
Enter fullscreen mode Exit fullscreen mode

We can see that although console.log(i) looks like it should have run first, it is actually the last to run.

The Event Loop

Once callbacks and promises are added to their respective queues, there needs to be a way for those instructions to be added to the call stack to be executed. This is where the Event Loop comes in. All it is responsible for is looking through the promise and callback queues and adding whatever instructions it finds there to the call sack and this only happens when the call stack is empty. No new instructions will be added to the call stack from the callback queue or promise queue while there is at least one instruction in the call stack.

Top comments (0)