JavaScript Event Loop: Breaking Down the Mystery

Caio Borghi - Aug 3 '23 - - Dev Community

What happens when the following code is run in Node.js?



setTimeout(() => console.log(1), 10)
Promise.resolve().then(() => console.log(2))
console.log(3)


Enter fullscreen mode Exit fullscreen mode

If your answer was different from:



3
2
1


Enter fullscreen mode Exit fullscreen mode

Perhaps you don't fully understand the execution order of JavaScript and the operation of the Event Loop.

No worries, I'll try to explain.

First of all, if you have doubts about what is:

  • JavaScript
  • ECMAScript
  • JavaScript Runtime

I recommend that you read the glossary before continuing.

Now, let's go, I will explain what happens at each stage of the execution of this JavaScript code.

Main Thread

Node interprets the JavaScript file from top to bottom, line by line, in a single thread.

Running setTimeout()

The main thread will interpret the first instruction, add it to the Call Stack, where it will be executed and removed from the Call Stack.

Visualization of the Main Thread executing the first function call: setTimeout(() => console.log(1), 10)

The setTimeout instruction is used to schedule the execution of a function after certain milliseconds.

This function is part of the libuv library, which Node uses to create a Timer without blocking the main thread.

The main thread executes the setTimeout function, which starts a timer in a new thread, through a library called libuv. At the end of the timer, the callback will be added to the macro-task queue

After starting the Timer, the main thread will remove the instruction from the Call Stack.

Main Thread pops from call stack

At the end of the interval, the timer will add the callback of the setTimeout function to the macro-task queue.

Running Promise.resolve().then()

While the Timer of the libuv library waits for the 10ms, the Main thread will interpret the next line of the file.

Main thread consumes the next instruction from the call stack

The instruction this time is



Promise.resolve().then(() => console.log(2))


Enter fullscreen mode Exit fullscreen mode

The main thread will execute the function Promise.resolve().then()

Promise is an object that represents a completion or failure of an asynchronous operation.

By calling the resolve() function without any parameter, we are declaring Promise that does not return any value, but that's okay.

Executing the function Promise.resolve()

For now, we are more interested in the behavior of the .then function of a Promise.

By passing () => console.log(2) as callback for our Promise, we are telling Node to execute this code as soon as the Promise is successfully finished.

In other words, we are saying that, as soon as the resolve() method of the Promise is executed, Node should execute our console.log(2) instruction.

But, that's not exactly how it works.

Every Promise callback is sent instantly to a special queue called Micro Tasks Queue.

Pushes Promise callback to MicroTasks queue

Recapping

This is the current state of the script execution:

Current state of the script execution

Everything that happened so far, surely, took less than 10 milliseconds, which is why the Timer has not yet added the instruction of console.log(1) to the Macro Tasks Queue.

But, by using libuv, the Main thread can continue working normally, in a non-blocking manner.

Ok, you might be wondering:

Event Loop

Throughout this process, with each interpretation of a new line from the file, the Event Loop performed a very important, albeit repetitive function.

  • Check if the Call Stack was empty.

Event Loop asking if the Call Stack is empty

As you can see, the answer was always: NO!

At no time during the execution of this script was the Call Stack empty, so our friend Event Loop will keep waiting.

Emptying the Call Stack

Now, the Main Thread interprets the last instruction of the file.

Main Thread consumes the last call from the call stack

This is a simple instruction, which displays a value on the console, its result is:



3


Enter fullscreen mode Exit fullscreen mode

And, for the first time, the Call Stack is empty!

Event Loop

Now, the most awaited moment for the Event Loop, the moment when it has the power to act!

It will only validate the other queues when the Call Stack is empty!

At each loop, it will:

  • Process all tasks in the Micro Tasks queue
    • Adding them to the Call Stack
  • Process 1 task from the Macro Tasks queue
    • Adding it to the Call Stack
  • Wait for the Call Stack to empty
  • Repeat

The Main Thread executes every instruction in the main context.

Now, continuing the execution of the example code:

Micro Tasks

When the Call Stack becomes empty, it means that the Main Thread is not executing anything.

Then, the Event Loop consumes all tasks from the Micro Tasks Queue and adds them to the Call Stack.
Event Loop consuming function from the micro tasks queue

Next, the Main Thread consumes the instruction from the Call Stack and executes it.
Main Thread consuming call stack



console.log(2) // Writes 2 to the console


Enter fullscreen mode Exit fullscreen mode

Now, the Call Stack becomes empty again.

Then, the Event Loop looks for more tasks in the Micro Tasks queue.

Current state of the application

As it is empty, it finishes its work in the Micro Tasks Queue and starts consuming the Macro Tasks Queue.

Macro Tasks

Now, suppose that the 10-millisecond interval has passed and the Timer has inserted the console.log(1) function into the Macro Tasks queue, the Event Loop will transfer 1 instruction from the Macro Tasks Queue to the Call Stack.

Event Loop consuming Macro Tasks queue

Then, the Main Thread consumes the last instruction from the Call Stack and executes it.
Main Thread consuming the Call Stack



console.log(1) // Writes 1 to the console


Enter fullscreen mode Exit fullscreen mode

Important point: If there were still instructions in the Micro Tasks queue, these would be processed. But, as everything is empty, the program execution is heading towards the end.

That's why the code:



setTimeout(() => console.log(1), 10)
Promise.resolve().then(() => console.log(2))
console.log(3)


Enter fullscreen mode Exit fullscreen mode

Will result in:



3
2
1


Enter fullscreen mode Exit fullscreen mode

We've reached the end - Arlindo Cruz

Now you understand what happens behind the scenes of JavaScript. The Event Loop manages the queues of micro and macro tasks and, with that, ensures that asynchronous instructions are executed harmoniously in the context of the main thread.

Understanding how it works helps us write more efficient codes and better predict the behavior of our applications.

Next time you're writing JavaScript code, I hope you remember everything that happens behind the scenes of the Event Loop.

See you later!

Glossary

JavaScript

It's a high-level, dynamic, interpreted programming language that supports multiple programming paradigms (functional, imperative, object-oriented).

It's a "medium" of conversation between something you want to do and what the computer executes.

ECMAScript

It's a set of rules that defines how JavaScript should work, it defines the language standards (syntax, data types, control structures, and operators), and JavaScript is the implementation of these standards.

If you want to understand better, read this article

JavaScript Runtime

It's the engine that executes JavaScript code.

When writing JavaScript code, you write instructions (which follow the rules defined by ECMAScript), but to execute these instructions, you need a Runtime.

It's as if JavaScript were a recipe and the Runtime was a cook who executes the recipe.

Node, V8 and SpiderMonkey are the most well-known JavaScript runtimes in the world.

. . . . . . . . . . . . . . . . .