JavaScript async/await: Why Your console.logs Print in the "Wrong" Order · 第 3 / 5 节

Lesson 03: Promises jump the line

Lesson objectives:

  • Distinguish the microtask queue from the task queue.
  • Explain that a promise .then() callback is a microtask, not a task.
  • Predict that the whole microtask queue drains before the next task, so a .then() beats a setTimeout(fn, 0).

Prerequisites: Lesson 02 (call stack, task queue, event loop) | Previous << 02 | Next 04 >>

You wrote the .then() last, so why does it run before the timer?

Here is the ordering that breaks most people's model. You schedule a setTimeout(fn, 0) on one line, then a few lines later — after it — you attach a .then() to a resolved promise. Both are "deferred," both look like they should run after the synchronous code. By Lesson 02's task-queue rule you might expect the timer, registered first, to win. It doesn't. The .then() runs first, every time. There is a second waiting room with higher priority, and promises live in it. Once you see it, promise-versus-timer ordering stops being a coin flip.

Explanation

There are two queues, not one. The task queue you met in Lesson 02 holds setTimeout callbacks. Alongside it sits a separate microtask queue, and formally the microtask queue is not a task queue — it is its own thing1. Promises use the microtask queue to run their callbacks: a .then(), .catch(), or .finally() handler is scheduled as a microtask2.

The difference is when each queue is drained, and this is the whole lesson. After the currently running synchronous code finishes and the stack is empty, the event loop does not immediately grab the next task. First it runs all the microtasks, draining the microtask queue completely, and only then does it take one task from the task queue3. Microtasks have higher priority, and the microtask queue is emptied before the task queue is touched3.

So compare the two callbacks. A setTimeout(fn, 0) callback goes to the task queue. A .then() callback goes to the microtask queue. When the synchronous script ends, the microtask queue is drained first — so the .then() runs before the setTimeout, no matter which line you wrote first2. A .then() handler also always runs asynchronously, even when the promise is already settled; it is scheduled, never run inline4.

Walk it with two queues in mind. console.log("1") runs synchronously and prints 1. The setTimeout line queues its callback in the task queue. The Promise.resolve().then(...) line queues its callback in the microtask queue. console.log("4") runs synchronously and prints 4. Now the script is done and the stack is empty. The event loop drains the microtask queue first, so 3 prints. Only then does it pull one task from the task queue, so 2 prints last. The order is 1, 4, 3, 2.

Two more facts that follow from "drain the whole microtask queue first." If a microtask schedules another microtask, that new one also runs before the next task — the event loop keeps going until the microtask queue is truly empty, even if microtasks keep adding more2. And chained promises (.then().then()) each schedule their handler as a separate microtask, so a long promise chain can fully resolve before a single setTimeout(fn, 0) ever runs.

Worked example (follow along)

A resolved promise with a two-step chain, racing a timer:

Trace the two queues. start and end are synchronous, so they print first: start, then end. The setTimeout callback goes to the task queue. The first .then() goes to the microtask queue. When the stack empties, the microtask queue drains: then 1 runs and prints, and its completion schedules then 2 as a new microtask — which the event loop runs before touching any task, so then 2 prints too. Only now, with the microtask queue empty, does the event loop pull the task and print timer. The order is start, end, then 1, then 2, timer. The entire two-step promise chain finished before the zero-delay timer got a turn.

Your turn (faded example)

Predict the output, tagging each deferred line as "task" or "microtask." Fill in the order:

Answer: the order is a, e, c, d, b. The synchronous logs a and e print first, in source order. The setTimeout callback (b) is a task; the two .then() callbacks (c, d) are microtasks. When the stack empties, the microtask queue drains first, in registration order: c, then d. Only after the microtask queue is empty does the event loop run the one task: b. So both promise callbacks beat the timer, even though the timer was registered before them — microtasks always drain before the next task.

Summary + what's next

There are two waiting rooms. setTimeout callbacks go to the task queue; promise .then()/.catch()/.finally() callbacks go to the microtask queue. When the stack empties, the event loop drains the entire microtask queue before it runs a single task — so a promise callback beats a zero-delay timer, no matter the source order. You now have the full ordering machine: stack, then all microtasks, then one task, repeat. The last piece is the one that trips people even after they know all this: what does await actually do to the line beneath it? The answer is "it turns the rest of your function into a microtask" — and that is Lesson 04.

Footnotes

  1. HTML Living Standard: Event loops — https://html.spec.whatwg.org/multipage/webappapis.html#event-loops

  2. MDN: Using microtasks in JavaScript with queueMicrotask() — https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide 2 3

  3. MDN: JavaScript execution model — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Execution_model 2

  4. MDN: Promise.prototype.then() — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then

Exercises

Exercise 1 · Level 1 (warm-up)

Paste the 1 / 2 / 3 / 4 snippet from the Explanation into micro.mjs (or the console). Predict the order, then run node micro.mjs and check.

Done criteria · checked locally
Exercise 2 · Level 2 (advanced)

Add a queueMicrotask(() => console.log("mt")) and a nested .then() inside an existing .then(). Predict how they interleave with a setTimeout(fn, 0), then verify.

Done criteria · checked locally