A Deep Dive into the Event Loop in JavaScript: How it Works and Why it Matters

A Deep Dive into the Event Loop in JavaScript: How it Works and Why it Matters

Introduction

As the internet evolved, websites and web applications became more interactive and dynamic, requiring more intensive operations such as retrieving API data through external network requests.

JavaScript is a single-threaded programming language with a synchronous execution model. It can only process one operation at a time, which would result in blocking behavior if API calls were synchronous.

To handle such scenarios, developers must use asynchronous programming techniques. This approach is essential as it allows the browser to process tasks in parallel, enabling users to continue using the browser normally while asynchronous operations are being executed.

This article will explore the significance of asynchronous programming in JavaScript and how it enables web applications to handle time-consuming operations without blocking the main thread.

The Call Stack and Event Loop

Let’s begin this section by understanding the call stack first:

Call Stack

The Call Stack is a data structure in JavaScript that keeps track of the execution of function calls. It is a LIFO (Last-In-First-Out) structure, which means that the last function pushed into the stack will be the first one to be popped out.

When a function is called, it is added to the top of the call stack. The JavaScript engine then executes the function. And when it is completed, the function is popped off the stack. In short, the call stack is a crucial part of the JavaScript runtime environment because it enables JavaScript to execute code synchronously.

Let’s understand it with one example:

const lunch = () => console.log("It's time for lunch!");

const dinner = () => console.log("It's time for dinner!");

const breakfast = () => {
  console.log("Time to breakfast!");
  setTimeout(lunch, 3000);
  dinner();
};
breakfast();

When running the code above, the first starts with the global execution context main() function on the browser, where simultaneously JS engine starts reading the code line by line until it reaches the last line to finally find and execute tasks (function call).

Afterward, the function will be directed to the Call Stack for further task execution.
At first, it will execute the statement “Time to breakfast!” and then move on to the next line, where the asynchronous block of code resides.

As JavaScript is a synchronous and single-threaded programming language, the asynchronous code will be moved into the call stack and popped out immediately. When this execution is done, JS engines get help from Web API

Over time, the setTimeout() function awaits inside the Web API.

Here, Web API assists the JS engine when it needs to run the asynchronous code. With assistance, Web API does deal with the JavaScript code-blocking behavior.

For this example, Web API takes the callback function: setTimeout(lunch, 3000); turn on the timer and pass this call to the callback queue once the 3 seconds have passed on.

The callback function opts for the Callback queue after the time runs out (in this case, it’s 3 seconds) and waits for the Call stack to get the bandwidth.

By this time, the Call stack jumps on another block of code and prints the statement: It’s time for dinner!”

But when the callback waiting event loop comes as a guardian of this function, ensuring effective communication between Call Stack and Callback Queue.

Event Loop

Event loop is a mechanism that enables JavaScript to handle asynchronous operations in a non-blocking way. It is a continuously running process that constantly checks the Call Stack and the Message Queue for tasks to be executed.

The Message Queue is a data structure that holds tasks waiting to be executed. And when an asynchronous task is called, it is added to the Task Queu

The Event Loop checks the Call Stack! And if it is empty, it checks the Task Queue for tasks to be executed. If there are tasks in the Task Queue, the Event Loop takes the first task in the queue and pushes it onto the Call Stack, where it is executed.

When the task is completed, any functions that were called during its execution are added to the Call Stack. These functions are then executed, and when the Call Stack is empty again, the Event Loop checks the Task Queue for the next task to be executed.

In summary, the Call Stack is a data structure that manages the execution of function calls in JavaScript, while the Event Loop is a mechanism that enables JavaScript to handle asynchronous operations in a non-blocking way.

Together, these two components make it possible for JavaScript to execute both synchronous and asynchronous code in a single-threaded environment.

Synchronous and Asynchronous

Synchronous and asynchronous programming are two approaches used in JavaScript to handle the execution of tasks.

Synchronous Programming

It is the traditional approach where tasks are executed one at a time, in sequence. In this approach, the program waits for each task to complete before moving on to the next one.

That means when a task is executed, the program is blocked from executing any other code until the task has been completed. It can result in long wait times and unresponsive applications, especially when dealing with time-consuming operations like network requests or file I/O.

Asynchronous Programming

It is a modern approach where tasks are executed independently without blocking the main thread of execution. In this approach, the program continues to execute other code while waiting for the task to complete.

When a task has been completed, it triggers a callback function, which will be executed in the background, which allows the program to handle multiple tasks concurrently, making it more efficient and responsive.

In JavaScript, asynchronous programming is made possible by utilizing callbacks, promises, and async/await. Callbacks refer to functions that are passed as arguments to other functions and only executed when the function they are passed to finishes.

Promises provide a cleaner and more structured way of handling asynchronous code, allowing developers to chain up multiple asynchronous operations. Async/await is a newer feature in JavaScript that provides a more concise and readable syntax for handling asynchronous operations.

In summary, synchronous programming executes tasks in sequence, one at a time, blocking the main thread for each process to complete.

Asynchronous programming executes tasks independently, allowing the program to continue running other code while waiting for the current process to complete. Also, it is essential for creating responsive and efficient web applications that can handle multiple tasks concurrently.

Let’s learn them through an example:

You can illustrate the difference between Synchronous and Asynchronous code very simply:

alert("Hello world!");
document.addEventListener("click", () => alert("Hello world"));

As the compiler reads the first line, it will immediately print the command “Hello World!” . It means the browser will run the JavaScript code further as far as the dialog is displayed, which depicts its synchronous call nature.

The second line will be executed when the click event occurs somewhere on the page. Here, the process can be marked as asynchronous because the call for the event loop awaits.

Without a click event, the handle won’t even bother to execute the code and even when the other event is in the process. Learning lessons, when a code runs for a longer period, the event processing will be left awaited, and the website goes into standby mode.

Therefore, asynchronous events come for help!

const req = new XMLHttpRequest();
req.open("GET", "/more_data.txt");
req.addEventListener("load", () => alert(req.responseText));
req.send();

This block of code runs the XMLHttpRequest – known asXHR and starts with an open() function. The code opts for the callback function to respond. The send()submits the requests while the program is running. And finally, the output will be printed with the function alert().

Nested Callbacks and the Pyramid of Doom

Nested callbacks and the Pyramid of Doom are common issues that arise often when working with asynchronous JavaScript code. They can make code hard to read, maintain, and debug.

Nested callbacks occur when a function executes an asynchronous operation and passes a callback function as an argument to handle the result of that operation. If that callback function also performs an asynchronous operation, it may need to pass another callback function to handle its result.

It can lead to a situation where multiple asynchronous operations are nested inside each other, resulting in a callback hell or callback pyramid.

Here’s how it’s done:

function pyramidOfDoom() {
  setTimeout(() => {
    console.log(1)
    setTimeout(() => {
      console.log(2)
      setTimeout(() => {
        console.log(3)
      }, 500)
    }, 2000)
  }, 1000)
}

In this code, every setTimeout function is nested into the increasing number of the function, which makes the pyramid dense with nested callbacks. When running, you’ll get the output as follows:

Output

1

2

3

In practice, asynchronous code handling can get a little complicated. The reason is, at first, we need to do error handling for the asynchronous code and then send the data for the next request. And when the callback queue emerges, it makes understanding the code structure even harder, Which makes the pyramid of doom. So, what is it?

The Pyramid of Doom is a term used to describe code that contains multiple nested callbacks, resulting in an indentation pattern that resembles a pyramid. It is a common problem when working with asynchronous JavaScript, and it can make code difficult to read and maintain.

Let’s understand it with a practical example:

// Example asynchronous function
function asynchronousRequest(args, callback) {
  // Throw an error if no arguments are passed
  if (!args) {
    return callback(new Error('Whoa! Something went wrong.'))
  } else {
    return setTimeout(
      // Just adding in a random number so it seems like the contrived asynchronous function
      // returned different data
      () => callback(null, {body: args + ' ' + Math.floor(Math.random() * 10)}),
      500,
    )
  }
}

// Nested asynchronous requests
function callbackHell() {
  asynchronousRequest('First', function first(error, response) {
    if (error) {
      console.log(error)
      return
    }
    console.log(response.body)
    asynchronousRequest('Second', function second(error, response) {
      if (error) {
        console.log(error)
        return
      }
      console.log(response.body)
      asynchronousRequest(null, function third(error, response) {
        if (error) {
          console.log(error)
          return
        }
        console.log(response.body)
      })
    })
  })
}

// Execute 
callbackHell()

When dealing with this, you need to associate every function with its potential response and error, to avoid callbackHell creating great confusion.

So, when this is executed, the outline you see as follows:

Output

First 9
Second 3
Error: Whoa! Something went wrong.
    at asynchronousRequest (:4:21)
    at second (:29:7)
    at :9:13

However, handling asynchronous code is much more difficult. So, to avoid nested callbacks and the Pyramid of Doom, developers can use Promises, sync/await, or other asynchronous programming techniques. These techniques provide a more readable and maintainable way to handle asynchronous operations without the need for nested callbacks.

But how is it done? By creating and consuming promises!

Promises

A promise stands for an object to successfully run an asynchronous function and does have the potential to output the value in the future. You can even call it a strong alternative to a callback function with some additional features and easy-to-understand syntax.

n other words, it is asynchronous Web APIs that give promises for the JavaScript developers to consume. But don’t worry, here we’ll explain both: “how to create a Promise in JavaScript?” and “how to consume it as a JavaScript developer?”

Let’s get started with creating a promise:

// Initialize a promise
const promise = new Promise((resolve, reject) => {})

When initializing a promise in a web browser’s console, you’ll find two properties: “pending” and “undefined,” which also will be reflected in an output.

Output
__proto__: Promise
[[PromiseStatus]]: “pending”
[[PromiseValue]]: undefined

As long as you don’t define the promise and fulfill it, it will remain pending forever. So, there comes your first task to fulfill the promised value:

const promise = new Promise((resolve, reject) => {
resolve(‘We did it!’)
})

Now, when inspecting the promise, you’ll get the output as follows:

Output
__proto__: Promise
[[PromiseStatus]]: “fulfilled”
[[PromiseValue]]: “We did it!”

So, it’s clear now that the promise has three states:

Pending – Initial state before getting rejected or resolved

Fulfilled – A successful resolvent to the promise

Rejected – A failed promise operation

The later part of the code shows the fulfilled/resolved promise status!

Now, let’s take a look at the way to consume the promise we just created:

Promises have a then method, which runs when promises are fulfilled or resolved in the code and returns the Promise value as a parameter.

This is how it is done!

promise.then((response) => {
  console.log(response)
})

In the promise creation step, the code contained [[PromiseValue]] of a “We did it!” statement. So, when running the above code, it will throw that value as a response:

Output

We did it!

Until now, we haven’t made use of asynchronous Web API. So, now, we are going to test the asynchronous call request by making the use of setTimeout function as a function:

const promise = new Promise((resolve, reject) => {
  setTimeout(() => resolve('Resolving an asynchronous request!'), 2000)
})

// Log the result
promise.then((response) => {
  console.log(response)
})

The then method with a parameter response will get called only after the setTimeout operation is processed as mentioned time limit, which is 2000 ms. So, the output will be called in the following manner:

Output

Resolving an asynchronous request!

Now, let’s fulfill the promise value using the code given below:

// Chain a promise
promise
  .then((firstResponse) => {
    // Return a new value for the next then
    return firstResponse + ' And chaining!'
  })
  .then((secondResponse) => {
    console.log(secondResponse)
  })

So, the output will be executed as given below by fulfilling the then log with a value:

Output

Resolving an asynchronous request! And chaining!

As then is chained, it allows developers to consume the promise values in a synchronous and readable way than callbacks with easy-to-maintain and verify methods.

The Event Queue

In JavaScript, the event queue manages the order in which tasks are executed and is commonly used to handle asynchronous tasks such as user input, network requests, or timers.

When an asynchronous task is initiated, it is added to the event queue, and its associated event listener waits for the event to occur.

Once the event occurs, the event listener is triggered, and its task is added to the event queue. The event loop processes the queue in the order in which tasks are added, removing each task and executing it.

Synchronous tasks are executed immediately, whereas asynchronous tasks are initiated, and their associated callback functions are added to the event queue.

It is essential to note that the event queue operates in a single-threaded environment in JavaScript. So, only one task can be executed at a time, and the event loop can only process one process at a time. Therefore, it is crucial to keep tasks in the event queue as short and efficient as possible to avoid blocking the main thread and affecting the performance of the application.

Best Practices for Optimizing the Event Loop

Avoid Blocking the Main Thread

JavaScript is a single-threaded language, and any long-running task that blocks the main thread can affect the performance of the application. To avoid blocking the main thread, consider using asynchronous programming techniques such as callbacks, promises, and async/await.

Use Promises and Manage Async Functions with async/await

Promises and async/await are powerful tools for managing asynchronous tasks in JavaScript. They allow developers to write code that is easier to read, write and maintain. Using these techniques can help optimize the event loop by ensuring that tasks are executed in a non-blocking manner.

Be Careful with Loops and Recursion

Loops and recursion can be powerful tools for writing efficient code, but they can also cause the event loop to become blocked if they are not used carefully. To optimize the event loop, it is essential to use loops and recursion sparingly and to ensure that they do not cause the main thread to become blocked.

Use the Right Data Structures

The choice of data structure can have a significant impact on the performance of the event loop. For example, using a Map or Set instead of an array can provide faster lookups and better performance. Additionally, using data structures that are optimized for the specific task can help reduce the amount of time it takes to execute tasks.

Monitor Performance

Monitoring the performance of the event loop is crucial for identifying potential issues and optimizing performance. Tools such as the Chrome DevTools performance panel can provide insights into how the event loop is performing and help identify areas where optimization is needed.

Wrapping Up The Loop

In conclusion, the event loop is an essential part of JavaScript’s asynchronous programming model that allows developers to create efficient and responsive web applications. The call stack and the task queue, which make up the event loop, work together to handle both synchronous and asynchronous code execution in a single-threaded environment.

Asynchronous programming is crucial for handling time-consuming tasks, and it is achieved through the use of callbacks, promises, and async/awaits. However, nested callbacks and the Pyramid of Doom can create readability and maintainability issues in asynchronous code.

So, understanding the event loop and asynchronous programming is essential for any JavaScript developer looking to create high-performing web applications.

References:
https://www.digitalocean.com/community/tutorials/understanding-the-event-loop-callbacks-promises-and-async-await-in-javascript#async-functions-with-async-await
https://levelup.gitconnected.com/mastering-the-javascript-event-loop-understanding-how-it-works-and-how-to-optimize-it-ec3e04c95e81
https://www.scaler.com/topics/javascript/event-loop-in-javascript/
https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop
https://www.geeksforgeeks.org/what-is-an-event-loop-in-javascript/
https://dev.to/shamantasristy/what-is-event-loop-in-javascript-34a5
https://www.innoq.com/en/articles/2022/04/async-javascript-event-loop/#synchronousandasynchronous