The JS Event Loop, Macrotasks, and Microtasks — Step by Step
After previously sorting out the this binding, prototype chain mechanism, and debouncing/throttling, today let's tackle another absolutely unavoidable and must-ask hard bone in JS — the Event Loop.
Many students often get confused by the output order when facing complex nested combinations of setTimeout and async/await. Don't worry, after reading this article, you'll find its underlying logic is actually very clear.
1. Why is JS single-threaded?
Before understanding the event loop, we need to understand a major premise: JavaScript only enables one thread for execution by default.
Many students might ask, since there are time-consuming tasks, why not design it for multi-threaded concurrent execution? The reason is simple, and it's related to JS's host environment (the browser):
- DOM rendering safety: JS can directly manipulate the DOM structure. If multiple threads existed, thread A might be adding styles to a DOM node while thread B wants to delete that node, which could cause very unsafe rendering conflicts.
- Performance and complexity: Once multi-threading is introduced, a "lock" mechanism is needed to handle state synchronization. This greatly increases the language's complexity and development difficulty, while also adding extra performance overhead to the device.
💡 Key pitfall: The JS engine thread and the GUI rendering thread are mutually exclusive. This means when JS is executing complex logic, page rendering will be blocked; and vice versa. This is also why we cannot let the JS main thread freeze.
2. Task classification: Synchronous vs. Asynchronous, Macrotasks vs. Microtasks
In our code, there will definitely be time-consuming tasks (like timers, HTTP requests) and non-time-consuming tasks. To prevent time-consuming tasks from freezing the main thread, the JS engine stores time-consuming tasks (asynchronous tasks) in queues for queuing first, prioritizing the execution of non-time-consuming synchronous tasks.
When we talk about asynchronous tasks, they are not queued equally but are strictly divided into two armies:
1. Macrotask
This belongs to the "regular VIP", including:
- The overall code
script(this is also the biggest macrotask) setTimeout()/setInterval()- Network requests (Ajax)
- I/O operations
- UI rendering (UI-rendering)
2. Microtask
This belongs to the "super VIP". As long as there are tasks in the microtask queue, the main thread will prioritize processing them. Including:
Promise.then()process.nextTick()(Node.js environment)MutationObserver
3. The core running axis: The Event Loop mechanism
How exactly does the code execute? Actually, just remember the following unshakable 4-step loop:
- Execute synchronous tasks: The main thread executes code from top to bottom (note, the entire
scriptitself is a macrotask). During this process, when encountering asynchronous tasks, they are stored into the corresponding "macrotask queue" or "microtask queue" according to their type. - Empty the microtask queue: After the synchronous code finishes executing, the main thread immediately goes to the microtask queue to look, and takes out all microtasks inside to execute them all at once.
- Page rendering: After the microtasks are emptied, if necessary, the browser will take this gap to render the page.
- Execute the next macrotask: Go to the macrotask queue to look, take out the macrotask at the front of the queue and execute it. (Note: This marks the beginning of the next event loop, and after execution, it will return to step 2).
4. Two detail exam points that are easy to trip over
1. The execution timing of setTimeout
We often queue setTimeout as a macrotask, but there is a detail that must be noted: all setTimeout share the same timeline. The one that enters the macrotask queue first does not necessarily get taken out and executed first. When the event loop processes timer macrotasks, it judges who expires first and who executes first based on the length of the timing you set.
2. Seeing through the essence of async/await
With the popularization of ES6+, async/await has become the new favorite for asynchronous programming, but it is essentially just syntactic sugar for Promises:
- About
async: Addingasyncin front of a function is equivalent to implicitlyreturning a Promise object inside this function. - About
await: This is the most confusing part! When we writeawait xxx, the code ofxxxitself will be treated as synchronous code and executed immediately. Whatawaitreally does is push all the code that follows it into the microtask queue and yield the current main thread.
5. Ultimate practical combat: A piece of code to see through the macrotask/microtask execution flow
Theory is done, let's get into real combat. Below is a very classic interview question, please see the code and execution order annotation diagram b65bbec7f915852a2ebc22c93fe00eb8.png:
console.log('script start'); // Step 1
async function async1() {
await async2()
console.log('async1 end'); // Step 5
}
async function async2() {
console.log('async2 end'); // Step 2
}
async1()
setTimeout(() => {
console.log('setTimeout'); // Step 8
}, 0)
new Promise((resolve, reject) => {
console.log('promise'); // Step 3
resolve()
})
.then(() => {
console.log('then1'); // Step 6
})
.then(() => {
console.log('then2'); // Step 7
});
console.log('script end'); // Step 4
Many beginners get a headache seeing this kind of nesting, but as long as you apply the "4-step loop method" and the "await cutting method" we talked about earlier, and break it down step by step, the logic will become as clear as water. Below is the detailed execution derivation for steps 1 to 8 in the diagram:
🕒 Phase 1: Complete the main thread synchronous tasks
Print 1 (
script start): The code goes from top to bottom, encounters the first synchronous code, prints directly.Print 2 (
async2 end): Executeasync1(), encounterawait async2(). Remember what was said above, the code on the right side ofawaitwill execute synchronously immediately! So enterasync2and print directly.- Key turning point: After executing
async2,awaittakes effect, packing the code after it (i.e.,console.log('async1 end')) into the microtask queue, and then forcibly yields the main thread!
- Key turning point: After executing
Macrotask queuing: Encounter
setTimeout, throw its callback function into the macrotask queue, pretend not to see it, and continue down.Print 3 (
promise): Encounternew Promise, its internalexecutorfunction executes synchronously, prints directly. Immediately after,resolve()is called, which activates the subsequent.then, throwingconsole.log('then1')into the microtask queue.Print 4 (
script end): Execute to the last line, at this point, all synchronous code of the main thread has been executed.
Current queue status inventory:
- Microtask queue:
[Execute async1 remaining code, Execute then1]- Macrotask queue:
[Execute setTimeout]
🕒 Phase 2: Empty the microtask queue (The decisive battle)
The main thread is idle, the iron law of the Event Loop: As long as there is something in the microtask queue, it must be completely squeezed dry before even glancing at the macrotask.
Print 5 (
async1 end): Take the first task from the microtask queue, return to whereasync1was suspended earlier and continue execution.Print 6 (
then1): Take the second microtask and execute it.- Hidden combo: After executing
then1, it triggers the second.thenafter it, thus dynamically generating a new microtaskconsole.log('then2'), which is appended to the tail of the microtask queue.
- Hidden combo: After executing
Print 7 (
then2): The main thread finds the microtask queue has new work again, takes it out and executes it. At this point, the microtask queue is completely emptied.
🕒 Phase 3: Execute the next macrotask
- Print 8 (
setTimeout): The microtasks are emptied, and it's finally the turn of thesetTimeoutthat has been waiting in the macrotask queue long enough to grow mushrooms. This is also the beginning of the next event loop.
Conclusion
Mastering the event loop is a necessary path for us to advance from "being able to write business code" to "writing high-performance, bug-free code". Engrave the queue mechanism of macrotasks and microtasks into your mind, and in the future, whether troubleshooting complex asynchronous bugs or tackling interview questions, you will be able to single-step debug in your mind just like the JS engine.
If this article was helpful to you, likes and saves are welcome! Let me know in the comments what underlying principle analysis you want to see next time!