Event loop, Microtask, and Macro task

Have you ever wondered how javascript executes your code? Understanding how javascript operates behind the scenes is critical for optimizing your program.

Javascript is a single-threaded programming language that has the appearance of multi-threading thanks to a few clever data structures like callstacks and queues. In this article, we'll look at what microtask and macrotask queues are and how they handle our JS program's asynchronous requirements.

CallStack - A call stack is a mechanism for an interpreter (like the JavaScript interpreter in a web browser) to keep track of its place in a script that calls multiple functions. The functions are processed in a Last-in-First-out order.

Event Loop

The event loop is an endless loop that is always running in the background on the JS engine. The general algorithm of an event loop is:

  1. While there are tasks
    • Execute the task, in an oldest first order
  2. Sleep until a task appears, then go to 1

Tasks are jobs assigned to the JS engine. For Example:

  1. When an external script loads, the task is to execute it.
  2. When a user moves the mouse, the task is to dispatch mousemove event and execute it.
  3. When the time is due for a scheduled setTimeout, the task is to run its callback.

The JavaScript engine does nothing most of the time, it only runs if a script/handler/event activates, then waits for more tasks (while sleeping and consuming close to zero CPU).

Macrotask queue

When the Javascript engine is busy running a task and new tasks appear, these new tasks are pushed into a queue called the macrotask queue and wait for their execution.

For instance, while a script is loading, the user moves the mouse, and a setTimeout timer expires. In this case, the js engine will continue to run the script while moving the other tasks to the microtask queue. When the callstack is empty once again, the event loop will pass the task from microtask to call stack in the oldest first order.

macrotask.png

Microtask queue

The Microtask queue organizes all of the microtasks that our code generates. They are typically created by promises: execution of the then/catch/finally handler results in the creation of a microtask. Async-await being another form of promise handling, also creates microtasks.

There’s also a special function queueMicrotask(func) that queues func for execution in the microtask queue.

Javascript engine immediately executes all the microtasks including the new enqueued microtasks before any other event handling, rendering, or macrotask occurs. This is significant because it ensures that the application environment remains the same (no changes in mouse coordinates, no new network data, etc.) between microtasks.

The Event Loop algorithm consists of four key steps:

  1. Evaluate Script: Synchronously execute the script as though it were a function body. Run until the Call Stack is empty.
  2. Run a Task: Select the oldest Task from the Task Queue. Run it until the Call Stack is empty.
  3. Run all Microtasks: Select the oldest Microtask from the Microtask Queue. Run it until the Call Stack is empty. Repeat until the Microtask Queue is empty.
  4. Rerender the UI: Rerender the UI. Then, return to step 2. (only for browsers).

Let's explore a working example.

console.log("start");
setTimeout(function a() {console.log(1)})
Promise.resolve().then(function b() {
    Promise.resolve().then(function d() {});
  })
  .then(function c() {
  })
console.log("end")

Javascript will first run the entire script and push every microtask and macrotask in their respective queues.

evaluate-script.png

  • The JS engine will first log "start".
  • In the second line, setTimeout will immediately expire and function a is enqueued to the macrotask queue.
  • In the third line, the Promise is resolved immediately and function b is enqueued to the microtask queue.
  • The JS engine will skip the asynchronous ".then" calls and it will log "end" to the console in the last line of the script.

Once the script is executed completely. JS engine will run all the microtasks including the newly enqueued tasks.

Function b will move to the callstack where it will be executed. Function b has a Promise.resolve() inside it and thus its .then call enqueues function d to the microtask queue.

view-2 run all microtask.png

The promise enclosing function b is fulfilled, thereby the .then call on it will get executed, thus function c gets enqueued to the microtask queue

view 3 new microtask enqueued.png

We know, that the event loop will push all the microtasks (including d and c) to the callstack, before any macrotask. Thus the JS engine will first execute function d (oldest first) followed by function c.

Once the microtask queue is empty the JS engine will rerender. Then in the second cycle of the event loop, the oldest macrotask in the macrotask queue is pushed to the callstack.

view4 2nd loop cycle run macrotask.png

Function a is pushed to the callstack and executed, console statement logs "1" to the console. Once the callstack is empty, the event loop checks for any microtask present in the microtask queue. The microtask queue is empty, so event loop moves to the rendering phase and sleeps till a new task appears.

You can explore the entire code in the javascript visualizer.