How JavaScript Manages Asynchronous Operations

Shubham MehtaShubham Mehta
9 min read

You might have heard that JavaScript is single-threaded, non-blocking and asynchronous in nature. First let’s understand what asynchronous means in JavaScript.

What is asynchronous JavaScript?

Asynchronous means performing multiple tasks at the same time without waiting for any specific task to finish. This means that when JavaScript encounters a time-consuming operation, it doesn't wait for that operation to complete. Instead, it continues executing the rest of the code while the operation is still running.

There are many JavaScript methods that are asynchronous in nature, such as:

  • setTimeout - schedules the execution of a function after a specified delay

  • setInterval - schedules the execution of a function at regular intervals

  • fetch - used for making HTTP requests

  • eventListeners - used for running callback functions in response to user actions like clicks, keypresses, mouseovers, etc.

These functions execute without blocking the main thread.

But JavaScript is single-threaded, meaning it can execute only one line of code at a time. So, how does JavaScript manage to execute multiple tasks at the same time?

This is done with the help of callback queues and the event loop.

Before diving into how callback queues and the event loop work, let's first understand how synchronous code is executed in JavaScript.

How Does the JavaScript Engine Execute Synchronous Code?

JavaScript is a single-threaded language, meaning it has only one call stack responsible for managing code execution, including function calls and returns.

Let's use a simple example to explain how the JavaScript engine executes code.

console.log("Start")
function displayWelcomeMessage(){
    console.log("Welcome")
}
displayWelcomeMessage()
console.log("End")

JavaScript engine has only one call stack for execution which processes code in a synchronous manner, meaning line by line. Whenever a JavaScript program runs, the Global Execution Context is first pushed to the call stack, and line-by-line execution starts in this global execution context.

Referring to the above example, first, the control moves to the first line, which logs "Start" to the console. Next, it moves to the function definition of displayWelcomeMessage. The JavaScript engine allocates memory for the function and stores the function definition.

Then, the control moves to the displayWelcomeMessage() function call. When invoked, an execution context for displayWelcomeMessage is created and pushed to the call stack. The code inside this function is executed line by line, logging "Welcome" to the console. Since there is no other code to execute in the function, its execution context is popped out of the stack, and control moves to the next line.

When the console statement is encountered again, "End" is logged. As there are no more statements to execute in the entire program, the Global Execution Context is finally popped out of the stack, leaving the stack empty.

This is how the JavaScript engine executes code using a single call stack with the help of execution contexts. The call stack immediately executes the code that is pushed into it without any delay. But what if a time-consuming function or async function is pushed into the call stack? This would lead to inconsistency as the JS engine never waits for one process to execute completely.

This is where browser APIs are needed for consistent outputs.

Web APIs

They act as an interface between the JS engine and the browser ,allowing access to some of the functionalities which are beyond the scope of core javaScript. These APIs are provided by the browser and are available to use in web based applications.

Some of the commonly used Web APIs -

  • Document Object Model(DOM)- One of the most commonly used Web API which helps you to modify the HTML and XML contents in the web page . Includes functions like:document.getElementById(), document.querySelector(),element.addEventListener(),element.innerHTML,document.createElement()

  • Fetch API - This API provides a way to make HTTP request by the browser to the requested server and handle responses

  • Web Storage APIs - allows web applications to store data in the browser . Commonly used - localStorage and sessionStorage

  • Geolocation API - provides access to the geographical location of the user.

  • Other APIs - WebSockets API , Notifications API , IndexedDb API ,etc...

All these web APIs can be accessed in JavaScript using the window global object provided by the web browser.
Example: window.fetch('url')

Note: Since window is a global object and all the web APIs are part of it, we can directly access the APIs without explicitly mentioning the window object, like fetch('url').

How Asynchronous code is executed ?

Let’s take 3 different examples to explain how asynchronous code is executed by the JavaScript Engine.

Example 1- using setTimeout

console.log("START")
setTimeout(()=>{
    console.log("Hello")
},2000)
console.log("End")
  • First, a Global Execution Context is created and pushed to the call stack. The JS engine then starts executing the code line by line.

  • The first statement uses the DOM WebAPI to log “START” in the console.

  • Next, it encounters setTimeout, which is pushed to the call stack. The JS engine executes this, but the function cannot be executed immediately. It registers the function in the browser using the web API, which will call the callback function after a 2-second delay, and then moves to the next line.

  • It then logs “End” in the console.

  • Since no code is left to execute, the Global Execution Context is popped out of the call stack.

  • Meanwhile, the timer is still running. Once the timer expires, the callback function needs to be pushed back to the call stack for execution.

  • However, it can’t be directly pushed to the call stack as it might interfere with the sequence of code execution, leading to inconsistent outputs. So, upon completion of the async task, the function is pushed to the Task Queue / Callback Queue, which follows the FIFO(First In, First Out) principle.

  • All async functions, except a few, are pushed into this queue in the order of their completion.

  • This is where the Event Loop comes in. The event loop continuously checks the call stack and the task queue. Once the JS engine finishes executing the code and the call stack is empty, the event loop removes the function from the task queue and pushes it to the call stack.
    When a function is pushed into the call stack, it is immediately executed by the JS engine, so console.log(“Hello”) will run and log “Hello” to the console window.

This is how simple asynchronous code is executed by the JS Engine using the Call Stack, Event Loop, and Task Queue.

Example 2- using DOM Event Listener

For this example we’ll use event listener to show how they work in JavaScript

console.log("START")
document.addEventListener('click',()=>{
    console.log("CLICKED !!")
})
console.log("END")
  • First, a Global Execution Context is pushed to the call stack, and the JS engine starts executing the code line by line.

  • On executing the first line, "START" is logged in the console.

  • Next, the control moves to the second line. Here, addEventListener is a WebAPI provided by the browser and gets pushed into the call stack. The JS engine, with the help of the web browser, registers this callback function in the WebAPI environment and continuously listens for the click event.

  • Meanwhile, the JS engine starts executing the next line of code and logs "END" in the console.

  • Since there is no code left to execute, the GEC is popped out of the call stack.

  • When a click event occurs, the registered function is pushed to the callback queue, not directly to the call stack.

  • The event loop will pop this function from the callback queue and push it to the call stack. The JS engine will execute the code in this function and log "CLICKED" in the console window.

The browser will continuously check for the click event unless it is removed during unmounting or the window is closed.

Example 3 - using fetch( ) / Promises in JS

Unlike the other two examples, the fetch API works differently during execution.
Let's look at an example to understand.

console.log("START")
fetch('https://jsonplaceholder.typicode.com/posts')
    .then(response => response.json())   
    .then(data => console.log(data))    
    .catch(err => console.log(`Error occurred while fetching: ${err}`));
console.log("END")

This is a simple JavaScript code that fetches data from a mock API, converts it into JSON format, and logs it in the console window.

Let’s see how the JS engine will execute this code:

  • First, the Global Execution Context is pushed into the call stack, and the JS engine starts executing the code line by line.

  • On executing the first line, "START" is logged in the console.

  • Then the control shifts to the next line, and the fetch function is pushed to the call stack. The JS engine immediately executes the function in the call stack, so it executes the fetch function. Fetching data from an API is time-consuming, so it returns a promise, and the engine, with the help of the browser, registers the callback function in the WebAPI environment.

  • In the meantime, the JS engine executes the next statement and logs "END" in the console window.

  • When the response comes from the API, whether a success or a rejection, this callback function should be passed to the callback queue. But this is not what happens with promises.

  • There is another queue known as the microtask queue, which has higher priority than the callback queue.

  • Once the promise is resolved, the callback function is pushed to this higher priority queue, the microtask queue, rather than the callback queue.

  • The event loop will keep checking if the call stack is empty. Once the call stack is empty, it will pop the function from the microtask queue and push it to the call stack for execution.
    Note: Even if there are some callback functions waiting in the callback queue, the event loop will prioritize the microtask queue. It will first check if there is any function in the microtask queue. If not, then it will shift to the callback queue.

  • Once the promise resolves, if there is an error, the callback function will catch the error and log the err object to the console.
    If there is a success, the data will be converted into JSON format and logged to the console based on the function definition.

Now let’s discuss some key components provided by JavaScript Runtime :

Event Loop

Event Loop is functionality provided by the JavaScript runtime and is responsible for handling asynchronous operations, managing how and when code executes in response to events or tasks. It allows the JavaScript runtime to be non-blocking while still executing tasks consistently .

Callback Queue

This queue holds functions that are waiting to be executed after they have completed their asynchronous tasks. When functions (like setTimeout or event listeners) which are asynchronous in nature complete, their callbacks are added to this queue, waiting for the call stack to become empty before they can be executed by the JS Engine.

Microtask Queue

A special queue that has higher priority than the callback queue. It holds microtasks, such as promise callbacks and MutationObserver functions. The event loop checks the microtask queue immediately after the call stack is empty, ensuring that microtasks are executed before any tasks in the callback queue.

Conclusion

This is how asynchronous code is executed in JavaScript, utilizing the call stack, event loop, and different queues to manage tasks efficiently. Understanding these mechanisms is crucial for mastering JavaScript's asynchronous behavior.

0
Subscribe to my newsletter

Read articles from Shubham Mehta directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Shubham Mehta
Shubham Mehta

Web Developer with experience in writing code that builds interactive and responsive web apps using MERN stack.