Event Loop Explained

Sanjeev Sharma - Sep 7 - - Dev Community

Another one among the popular frontend interview questions. It tests interviewees depth of knowledge in JavaScript ecosystem.

This is question #4 of Frontend Interview Questions series. If you're looking to level up your preparation or stay updated in general, consider signing up on Frontend Camp.


Before we dig into the event loop, I want you to look at the following illustration. You might not understand all the components mentioned here, but it's better to build a mental model before we start.

Event Loop architecture

We'll now zoom in on each of the components one by one and build our understanding of the Event Loop.

You might be surprised to know that APIs like fetch and setTimeout are not native to JavaScript. These APIs are provided by the JavaScript runtime environment.

Every JavaScript runtime comes with a JavaScript engine. In Chrome and Node.js, it's V8, and in Firefox, it's SpiderMonkey. The JavaScript engine mainly consists of two components: the Heap and the Call Stack.

During execution, the Heap is used for dynamic memory allocation, and the Call Stack is used to store the execution context. We'll mainly focus on the Call Stack in this article.

Call Stack

JavaScript, being a single-threaded language, comes with a single Call Stack. The Call Stack operates on the Last In, First Out (LIFO) principle. When a function execution starts, it is pushed onto the stack. After the execution is complete, it's popped off the stack, and the control returns to the calling function or the main program. This LIFO structure ensures that the most recently called function is always the first to complete.

Here's an illustration to visualize it.

Working of Call Stack

When the script execution starts, the global execution context is created(let's call it main()) and pushed onto the Call Stack, followed by:

  1. console.log("Hello") is pushed onto the call stack. Its output is logged in the console, and then it's finally popped off the stack.

  2. After this, we have three function declarations - fn1(), fn2(), and fn3(). These don't get pushed to the Call Stack as we haven't executed them yet, but memory is allocated to store these functions. The exact location of this memory allocation can vary depending on the JavaScript engine's implementation.

  3. When we reach fn3(), it's pushed onto the Call Stack. Inside fn3(), we make another call to fn2(). Following the LIFO principle, fn2() is pushed onto the Call Stack on top of fn3(), and its execution starts. Once fn2() finishes execution, it's popped off the stack, and the control returns back to fn3(). fn3() then proceeds with the rest of the execution and gets popped off the stack.

  4. Finally, control reaches back to the main program, and it moves to the next line - execute fn1(). fn1() goes through the same process again. It's pushed onto the stack, and an execution context is created for it. Once it's done executing, it's popped off the stack, and the control reaches back to the main program.

  5. In the end, when the script is done executing, main()(global execution context) is also popped off the stack, and the Call Stack is empty!

JavaScript, being a single-threaded language, can only do one thing at a time. When there's stuff to do on the Call Stack and JavaScript is busy executing it, it cannot do anything else. This is why it's important not to put long-running tasks on the Call Stack.

But then how does JavaScript support async tasks like API calls and setTimeout?

Let's understand this with another example.

Call Stack and Web APIs

This example uses setTimeout to execute a function later. If you notice, JavaScript didn't wait for 3 seconds before moving on to the next line. Then how does JavaScript know when the timer runs out? It can only do one thing at a time. If it's not counting up to 3 seconds, who is?

Turns out, JavaScript offloads these kinds of async tasks to the browser via Web APIs.

When the control reaches the setTimeout, it gets pushed onto the Call Stack. It tells the browser - Hey, can you remind me to run this fn after t milliseconds? and then continues with the rest of the program. Now, it's the browser's job to keep track of the timer.

But what happens when the timer runs out?

Web APIs cannot push anything directly onto the Call Stack. There could be multiple callbacks from setTimeouts or fetch calls waiting to be run. If everything gets pushed onto the Call Stack, things will go haywire. You'd see unexpected results, with the Call Stack being hijacked randomly.

This is where the Task Queue comes in. It is also known as the Macrotask Queue or Callback Queue.

Also, note that the browser can perform multiple tasks in parallel. The underlying libraries are built on multi-threaded languages.

Task Queue

When the timer runs out, the callback function from setTimeout is pushed into the Task Queue. The Task Queue (also known as the Callback Queue) holds callbacks from Web APIs and various event handlers waiting to be executed.

Some examples of tasks that go into Task queue:

  1. setTimeout and setInterval callbacks
  2. DOM events (like click, keypress, etc.)
  3. AJAX/HTTP requests
  4. I/O operations (in Node.js)

Once the Call Stack is empty, JavaScript picks up the tasks from the Task Queue and pushes them onto the Call Stack for execution.

Call Stack, Web APIs and Task Queue

So far, we've covered the Call Stack, Web APIs, and Task Queue. There's one final piece left to complete the puzzle, and it's an important one - the Microtask Queue.

Understanding the difference between these queues is crucial for grasping how JavaScript prioritizes and executes asynchronous operations.

Microtask Queue

Not everything gets pushed to the Task Queue. Modern APIs that support promises get pushed to the Microtask Queue. This queue has higher priority than the Task Queue.

Here's a list of items that use the Microtask Queue:

  1. Promise handler callbacks (then(callback), catch(callback), and finally(callback))
  2. Execution of async function bodies following await
  3. queueMicrotask callbacks
  4. MutationObserver callbacks

Once the Call Stack is empty, before checking the Task Queue, JavaScript checks the Microtask Queue. It executes all the callbacks(aka microtasks) available in the Microtask Queue and then moves to the Task Queue.

Here's an example to help you visualize it:
Call Stack, Web APIs, Task Queue and Microtask Queue

In the example above, there's an additional Promise-based fetch call. When the server responds, its callback will be pushed to the Microtask Queue.

When the Call Stack is empty, the Microtask Queue is checked. If we have the response callback ready, it's pushed onto the Call Stack. Once the Microtask Queue is empty, the Task Queue is checked. If we have the setTimeout's callback ready in the Task queue, it's pushed onto the Call Stack.

If we hadn't received the API response in time and the Microtask Queue was empty, then setTimeout's callback would have been executed first.

Key points about these two queues:

  1. Tasks from both queues are only picked once the Call Stack is empty.

  2. The Microtask Queue has high priority, so it's checked first. All the tasks available in the Microtask Queue are executed before the Task Queue is checked.

  3. After each task execution from the Task Queue, the Microtask Queue is checked again. It could be the case that one of the tasks from the Task Queue led to the insertion of a callback in the Microtask Queue. Being high priority, it must be checked frequently.

Remember: Only one item is executed from the Task Queue before the Microtask Queue is checked again, whereas all the items from the Microtask Queue are executed before checking anything else. The Microtask Queue must be emptied when control comes to it!

The Event Loop: Tying It All Together

All the mechanisms we've discussed - the Call Stack, Web APIs, Task Queue, and Microtask Queue - are orchestrated by what's known as the Event Loop.

The Event Loop is the heart of JavaScript's concurrency model. It continuously checks the Call Stack and, when it's empty, looks at the Microtask Queue and Task Queue. It's responsible for moving callbacks from these queues to the Call Stack for execution, maintaining the order and priority we've discussed.

This process allows JavaScript, a single-threaded language, to handle asynchronous operations efficiently, enabling non-blocking I/O operations and creating the illusion of multi-threading.

Understanding the Event Loop is crucial for writing efficient and responsive JavaScript code, whether you're working on browser-based applications or server-side with Node.js.


Summary

  1. JavaScript is single-threaded but can handle asynchronous operations. The Event Loop creates the illusion of multi-threading in JavaScript.
  2. The JavaScript Engine consists of the Heap (for memory allocation) and the Call Stack.
  3. The Call Stack follows LIFO (Last In, First Out) structure for function execution.
  4. Web APIs (like setTimeout, fetch) are provided by the browser, not JavaScript itself.
  5. Web APIs offload asynchronous tasks from the Call Stack to enable non-blocking execution.
  6. The Task Queue (or Callback Queue) holds callbacks from Web APIs (e.g., setTimeout, DOM events).
  7. The Microtask Queue has higher priority and contains Promise callbacks and queueMicrotask callbacks.
  8. The Event Loop continuously monitors the Call Stack and both queues.
  9. When the Call Stack is empty, the Event Loop first checks and empties the Microtask Queue.
  10. After Microtasks, the Event Loop takes one task from the Task Queue and pushes it to the Call Stack. Then it repeats the process in point 9 until both Microtask Queue and Task queue are empty.
  11. Microtasks always execute before the next Task(from Task/Callback queue), ensuring Promise resolutions are handled promptly.

Resources

  1. Jake Archibald on Event Loop
  2. What the heck is the event loop anyway? | Philip Roberts | JSConf EU
. . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player