跪拜 Guibai
← All articles
Frontend · JavaScript

The JS Event Loop, Macrotasks, and Microtasks — Step by Step

By Amethyst05 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Misreading async execution order is one of the most common sources of production bugs in JavaScript. Knowing that microtasks always drain before the next macrotask — and that `await` splits a function into synchronous and microtask halves — prevents race conditions and interview failures alike.

Summary

JavaScript runs on a single thread to avoid DOM rendering conflicts, so time-consuming operations get queued as either macrotasks (script, setTimeout, I/O) or microtasks (Promise.then, MutationObserver). The event loop follows a fixed four-step cycle: execute all synchronous code, drain every microtask in the queue, optionally render the page, then pick up the next macrotask.

Two details catch developers off guard. First, setTimeout callbacks don't execute in insertion order — the engine picks whichever timer expires first. Second, async/await is Promise syntax sugar: the code to the right of `await` runs synchronously, but everything after it gets pushed into the microtask queue.

A worked example traces eight console.log statements through a script mixing async functions, a zero-delay setTimeout, and chained Promise .then calls. The walkthrough shows exactly when each line prints and why the microtask queue must be completely empty before any macrotask runs.

Takeaways
JavaScript is single-threaded because multi-threaded DOM manipulation would cause unsafe rendering conflicts, and locking adds complexity and overhead.
The JS engine thread and the GUI rendering thread are mutually exclusive; long-running JS blocks page rendering.
Asynchronous tasks split into macrotasks (script, setTimeout, I/O, UI rendering) and microtasks (Promise.then, MutationObserver, process.nextTick in Node).
The event loop cycles through four steps: run all sync code, drain every microtask, optionally render, then take one macrotask.
setTimeout callbacks don't execute in insertion order; the engine runs whichever timer expires first.
`await` runs the expression on its right synchronously, then pushes everything after it into the microtask queue and yields the thread.
An `async` function implicitly returns a Promise; `await` is syntactic sugar that splits the function into sync and microtask parts.
Chained `.then()` calls can dynamically add new microtasks during microtask draining, which must also be emptied before moving to the next macrotask.
Conclusions

The mental model of 'await splits the function' is more reliable than memorizing output orders, because it explains why `async1 end` prints before `then1` even though `async1()` was called first.

The fact that all microtasks must drain before any macrotask runs means a recursively added microtask can starve the macrotask queue and block rendering indefinitely.

Many developers assume `setTimeout(fn, 0)` runs immediately after sync code, but the microtask queue always jumps the line, which is why `then1` and `then2` print before `setTimeout` in the example.

Concepts & terms
Event Loop
The mechanism that allows JavaScript to perform non-blocking asynchronous operations despite being single-threaded, by cycling through a queue of macrotasks and microtasks.
Macrotask
A category of asynchronous task that includes the overall script, setTimeout, setInterval, I/O, and UI rendering. Only one macrotask is processed per event-loop iteration.
Microtask
A higher-priority asynchronous task category that includes Promise.then, MutationObserver, and process.nextTick. The event loop drains the entire microtask queue before touching any macrotask.
async/await
Syntactic sugar over Promises. An async function returns a Promise; await runs its right-hand expression synchronously and queues the rest of the function as a microtask.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗