Understanding Event Loop: Microtasks, Macrotasks

JavaScript’s concurrency model revolves around an event loop, a mechanism that allows the execution of code in a non-blocking, asynchronous way. However, understanding the order in which different types of tasks are executed is critical when writing performant, bug-free code. In this post, we’ll explore the concepts of microtasks and macrotasks with a practical example to clarify how JavaScript handles these different task types.

The Example Code

Let’s take a look at the following code snippet:

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

setTimeout(() => console.log(2), 10);

queueMicrotask(() => {
  console.log(3);
  queueMicrotask(() => console.log(4));
});

console.log(5);

You might be wondering: In what order will these console.log() statements run? To understand this, we need to dive deeper into how the event loop works and how macrotasks and microtasks are treated differently in JavaScript.

A Quick Overview of Macrotasks and Microtasks

JavaScript organizes task execution into two categories:

  1. Macrotasks (also known as “tasks”): These include things like setTimeout(), setInterval(), or events like DOM manipulation, I/O operations, and more. When a macrotask is queued, it will only be processed after the current execution context is fully cleared, including any remaining microtasks.
  2. Microtasks: These are often callbacks associated with promises (.then() or Promise.resolve()), queueMicrotask(), and MutationObserver. They execute after the currently running script but before the next macrotask.

How the Event Loop Works

The event loop processes tasks in a two-phase approach:

  1. All synchronous code (the code outside of functions like setTimeout, Promise.then, and queueMicrotask) runs first. This includes standard function calls, variable declarations, and console logs. Once this is finished, the event loop shifts its focus to pending tasks.
  2. Microtasks are processed next. All pending microtasks will be executed before any macrotasks are touched. If new microtasks are queued while other microtasks are being processed, they will also be added to the same microtask queue and will be handled before the event loop moves on to macrotasks.
  3. Macrotasks come last. Once all microtasks are cleared, macrotasks such as setTimeout are processed.

Breaking Down the Code

Let’s now break down our example step by step.

Step 1: Synchronous Code First

console.log(5);

This is straightforward synchronous code. It will be executed immediately when the script runs. Therefore, the first output we see will be:

5

Step 2: Promise Microtask

Next, we have:

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

When the promise is resolved, the .then() callback is placed in the microtask queue. Microtasks are executed right after the synchronous code, but before any macrotasks. So, console.log(1) will be executed after the synchronous code completes.

Step 3: Timer with setTimeout()

setTimeout(() => console.log(2), 10);

This creates a macrotask that will be queued and executed only after the microtask queue is cleared and the 10ms delay is over. For now, it’s just waiting for the event loop to come back to it.

Step 4: Microtask Using queueMicrotask()

queueMicrotask(() => {
  console.log(3);
  queueMicrotask(() => console.log(4));
});

The first queueMicrotask schedules the execution of console.log(3) after all synchronous code has finished, placing it in the microtask queue. The second queueMicrotask (inside the first one) adds another microtask, console.log(4), to the queue after the current one is completed. Therefore, console.log(3) will happen before console.log(4).

The Order of Execution

Now that we understand how the event loop processes tasks, let’s put everything together and examine the exact order of execution:

  1. Step 1: Synchronous Code
    console.log(5) runs immediately.
   5
  1. Step 2: First Microtask from Promise
    The resolved promise’s .then() handler executes, logging 1.
   5
   1
  1. Step 3: Microtasks from queueMicrotask
    The first queueMicrotask logs 3, then schedules another microtask to log 4, which runs right after.
   5
   1
   3
   4
  1. Step 4: Macrotask from setTimeout
    After all microtasks are done, the event loop moves to macrotasks. The setTimeout callback logs 2.
   5
   1
   3
   4
   2

Conclusion

By understanding the differences between microtasks and macrotasks, and how JavaScript’s event loop processes them, you can better predict how your code will behave. In this example, the synchronous code executes first, followed by all microtasks (like those from promises and queueMicrotask), and finally, any macrotasks (like setTimeout).

The key takeaway is that microtasks always run before macrotasks, making them a more immediate priority for JavaScript’s event loop. When writing complex asynchronous code, leveraging this understanding can help you avoid unexpected behavior and ensure your code runs in the order you expect.

Happy coding!

Leave a Reply

Your email address will not be published. Required fields are marked *