JavaScript Event Loop

An overview of how asynchronous code runs in JavaScript

The Event Loop in a Kitchen

JavaScript is a single-threaded language, which means that it can only execute one task at a time. However, it is possible to run asynchronous code using something called the event loop, a mechanism that allows it to perform non-blocking I/O (Input/Output) and handle long-running tasks without blocking the main thread.

Now what did all that mean? Well, let's look at it using an example:

Imagine you're a chef cooking in a kitchen. You have a list of orders to prepare, but some orders require more time to cook than others. You also have an assistant who helps you with the preparation.

You start by preparing the first order on your list. If it's a quick order, you can prepare it quickly and move on to the next order. But if it's a long order, you don't want to keep other customers waiting. So, you hand over the order to your assistant to prepare, while you start working on the next order on the list.

Once your assistant is done preparing the long order, they notify you, and you take it back and serve it to the customer. Meanwhile, you continue preparing other orders on the list.

In this analogy, you are the event loop, and your list of orders is the JavaScript code. The quick orders are the synchronous code that runs quickly, while the long orders are the asynchronous code that takes longer to execute. Your assistant is the web API that handles the long-running tasks in the background.

The Task Queue and Microtask Queue

The event loop is a continuous loop that checks for tasks in two different queues: the task queue and the microtask queue. The task queue is a queue of tasks that are executed in a first-in-first-out (FIFO) order. This includes tasks such as I/O callbacks, timer callbacks, and UI rendering tasks. The microtask queue, on the other hand, is a queue of tasks that are executed after the current task has finished. It has a higher priority than the task queue. It includes tasks such as Promise callbacks, mutation observers, and queueMicrotask() callbacks.

The Call Stack

In JavaScript, the call stack is what keeps track of all the functions that are being executed. It is like a stack of books where the bottom-most book is the first one to be read and the one on the top which is placed last is read last.

For example, consider the following code:

function firstFunction() {
  // Executes First
  console.log("Hello");
  secondFunction();

  // Executes Last
  console.log("Bye");
}

function secondFunction() {
  // Executes Second
  console.log("Goodbye");
}

// Call firstFunction
firstFunction();

// Hello
// Goodbye
// Bye

When the firstFunction is called, it is added to the call stack. The first line of firstFunction logs "Hello" to the console and the second line calls secondFunction, which is then added to the call stack. The first line of secondFunction logs "Goodbye" to the console, and then secondFunction finishes executing and is removed from the call stack. Control is returned to firstFunction, which logs "Bye" to the console and then finishes executing, and is removed from the call stack. At this point, the call stack is empty and the program has finished running.

The call stack is where the JavaScript engine keeps track of the function calls that are currently being executed. When a function is called, it is added to the call stack, and when the function returns, it is removed from the call stack. If the call stack is already full when a new task is added to the queue, the task will have to wait until the current tasks on the call stack have been completed before it can be executed.

Asynchronous JavaScript

When all the tasks in the call stack have finished executing, the JavaScript engine checks the task queue for tasks to execute using the event loop. When a task is executed, it is added to the call stack. If the task is asynchronous, such as a timer callback or an I/O callback, it is added to the task queue, and the JavaScript engine moves on to the next task.

console.log('start');

// Asynchronous timer callback is pushed to task queue
setTimeout(() => {
  // This is called last even though the timer is set to 0
  console.log('Inside setTimeout');
}, 0);

console.log('end');

// start
// end
// Inside setTimeout

Now, in the case of a Promise it works similarly but with a slight difference. Remember we mentioned that Promises are pushed to the microtask queue while setTimeout is pushed to a task queue.

Let us look at the same example this time including a Promise:

console.log('start');

// Executes last because the task queue has lower priority
setTimeout(() => {
  console.log('Inside setTimeout');
}, 0);

// Executes first because the microtask queue has higher priority
Promise.resolve().then(() => {
  console.log('Inside Promise');
});

console.log('end');

// start
// end
// Inside Promise
// Inside setTimeout

If a Promise is resolved, its callback function is added to the microtask queue, which has a higher priority than the task queue. This means that any tasks in the microtask queue will be executed before any tasks in the task queue.

The tasks in a microtask queue have a higher priority than the tasks in a task queue.

Closing Thoughts

JavaScript runs on a single thread, to avoid blocking the call stack, it is important to write efficient, non-blocking code, and to use techniques such as asynchronous programming, Promises, and event listeners to handle long-running tasks. This allows the JavaScript engine to process tasks more efficiently and responsively and can help to improve the overall performance of your application.

In conclusion, the event loop is a fundamental concept in JavaScript that allows it to handle asynchronous code in an efficient and non-blocking way. Understanding how the event loop works can help you write more efficient and responsive JavaScript code and can improve the overall performance of your applications.