DEV Community

Cover image for How the JavaScript Event Loop Actually Works
varshini-as
varshini-as

Posted on • Edited on

How the JavaScript Event Loop Actually Works

Introduction

JavaScript runs on a single thread — it can only do one thing at a time — yet it manages to juggle asynchronous operations like fetching data from a server or responding to user interactions without making the page unresponsive. The secret behind this coordination is the Event Loop, which decides when and how each task gets executed.

In this article, we’ll break down the event loop step by step — using clear visuals and GIFs — so you can see what happens inside the call stack, task queue, and microtask queue. By the end, you’ll understand exactly why your async code behaves the way it does.

What is the Event Loop?

The event loop is the mechanism that enables JavaScript to handle asynchronous operations while remaining single-threaded. It ensures non-blocking execution by coordinating between the call stack, web APIs, callback queues, and microtask queues.

Now, let’s take a closer look at each part that makes this process work.

Components of the Event Loop

To understand the event loop better, let’s break it into its key components:

  1. Call Stack - The call stack is where JavaScript keeps track of function calls. Functions are pushed onto the stack when invoked and popped off when they finish execution.

  2. Web APIs/Node APIs - These provide the environment for asynchronous tasks like setTimeout(), fetch, and DOM events. They are managed outside the call stack.

  3. Callback Queue (Task Queue) - When an asynchronous operation completes, its callback is queued here to await execution.

  4. Microtask Queue - Tasks like Promise resolutions and MutationObserver callbacks are queued here. This queue is prioritized over the callback queue.

  5. Event Loop - The event loop continuously checks if the call stack is empty and, if so, pushes the next task from the callback or microtask queue onto the stack.

How it works (Step-by-step example)

Let's consider the following code snippet to understand the event loop in action:

console.log("Start");

setTimeout(() => {
console.log("Timeout callback");
}, 0);

console.log("End");
Enter fullscreen mode Exit fullscreen mode

The output will be:

Start  
End  
Timeout callback  
Enter fullscreen mode Exit fullscreen mode

At first glance, you might expect "Timeout callback" to appear immediately after "Start", but it doesn’t.

Here's what's happening under the hood:

Event loop in action

  1. The JavaScript engine starts by executing the synchronous code, line by line: The console.log("Start") function is pushed onto the call stack. It executes immediately, printing "Start" to the console. After execution, the function is popped off the stack.

  2. Next, the engine encounters the setTimeout function: The setTimeout function is pushed onto the call stack. It registers the callback function with the Web API (provided by the browser or runtime like Node.js). After registering the callback, setTimeout is popped off the stack. The callback function is now waiting in the Web API environment with a timer set to 0 milliseconds.

  3. The interpreter moves to the next line. The console.log("End") function is pushed onto the call stack. It executes immediately, printing "End" to the console. After execution, the function is popped off the stack.

  4. Once all synchronous code is executed, the call stack is empty. The event loop checks if there are any pending tasks in the task queue:

  • The callback function from setTimeout is moved from the Web API to the task queue after the timer expires (0 milliseconds).
  • The event loop picks up the callback from the task queue and pushes it onto the call stack.
  • The callback function executes, printing Timeout callback to the console.
  • After execution, the function is popped off the stack.

Once the callback is executed and popped off the stack, the event loop moves on to the next task in the queue. But not all queued work is treated equally. JavaScript introduces another layer of complexity: Microtasks, primarily driven by Promises, actually jump ahead of the regular task queue. That’s what we’ll explore next.

Microtask Queues

Microtasks are prioritized over tasks (like setTimeout callbacks), which means they are executed immediately after synchronous code completes, even before tasks in the task queue.

To see this in action, consider this example:

console.log("Start");

setTimeout(() => {
  console.log("Timeout callback");
}, 0);

Promise.resolve().then(() => {
  console.log("Promise resolved");
});

console.log("End");
Enter fullscreen mode Exit fullscreen mode

Now, to see it in action:
Micro tasks queue in action

Let's break down the execution flow into the following steps:

  1. console.log("Start") is added to the Call Stack and executed immediately. console.log("Start") runs and logs "Start". console.log("End") runs and logs "End".

  2. The setTimeout is encountered. Its callback (console.log("Timeout callback")) is handed off to the Web API and scheduled for execution after 0 milliseconds. Once the timer expires, the callback is moved to the task queue. The call stack is now empty.

  3. The Promise.resolve() creates a resolved promise. Its .then() callback (console.log("Promise resolved")) is added to the microtask queue for execution after all synchronous code.

  4. Synchronous code execution continues. console.log("End") is added to the call stack and executed immediately.

  5. Once the synchronous code finishes, the event loop checks the microtask queue. The .then() callback is executed (console.log("Promise resolved")) and "Promise resolved" is logged to the console.

  6. After all microtasks are executed, the event loop moves to the task queue.

This brings us to the output:

Start  
End  
Promise resolved  
Timeout callback
Enter fullscreen mode Exit fullscreen mode

By prioritizing microtasks, JavaScript guarantees that promise resolutions or other immediate tasks are executed before moving on to other queued tasks, such as setTimeout callbacks.
 

Ready to take the wheel?

Now that you understand the event loop, let's put that knowledge into practice! Try to predict the output of the following code snippet and compare it with the actual output:

console.log("Start");

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

Promise.resolve().then(() => {
  console.log("Promise 1");
});

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

Promise.resolve().then(() => {
  console.log("Promise 2");
});

console.log("End");
Enter fullscreen mode Exit fullscreen mode

Conclusion

  • JavaScript is single-threaded, but it uses the event loop to handle asynchronous operations.
  • The call stack tracks function calls, and the event loop ensures that tasks from the callback and microtask queues are executed when the stack is empty.
  • Web APIs (like setTimeout, fetch) manage asynchronous tasks and move their callbacks to the task queue once they complete.
  • Microtasks (like promises) are given priority over regular tasks, ensuring they are executed before tasks in the callback queue.

In short, the event loop is at the core of JavaScript's ability to handle asynchronous operations in a single-threaded environment. By understanding its components — such as the call stack, web APIs, callback queue, and microtask queue — you can gain deeper insights into how JavaScript executes tasks and maintains non-blocking behavior. Whether it's handling DOM events, promises, or timers, the event loop ensures smooth and responsive web applications.

Embrace the power of JavaScript's event loop, and you’ll be well on your way to mastering asynchronous programming! Happy coding!

Top comments (0)