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:
- 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. - Microtasks: These are often callbacks associated with promises (
.then()
orPromise.resolve()
),queueMicrotask()
, andMutationObserver
. 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:
- All synchronous code (the code outside of functions like
setTimeout
,Promise.then
, andqueueMicrotask
) 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. - 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.
- 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:
- Step 1: Synchronous Code
console.log(5)
runs immediately.
5
- Step 2: First Microtask from Promise
The resolved promise’s.then()
handler executes, logging1
.
5
1
- Step 3: Microtasks from
queueMicrotask
The firstqueueMicrotask
logs3
, then schedules another microtask to log4
, which runs right after.
5
1
3
4
- Step 4: Macrotask from
setTimeout
After all microtasks are done, the event loop moves to macrotasks. ThesetTimeout
callback logs2
.
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!