Kodokon kodokon.com

The event loop: call stack, microtasks, macrotasks

Understand how the engine actually schedules synchronous code, microtasks, and macrotasks.

10 min · 3 questions

Open this lesson in Kodokon

JavaScript runs your code on a single thread, but the event loop is not defined by ECMAScript: it is the HTML specification (and libuv on the Node.js side) that describes how it works. The engine maintains a call stack for the code currently running, a queue of macrotasks (timers, I/O, events) and a queue of microtasks (Promise.prototype.then, queueMicrotask, MutationObserver). The golden rule: after each macrotask, the microtask queue is drained completely before control is handed back.

JAVASCRIPT
console.log("script start");

setTimeout(() => console.log("timeout"), 0);

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

console.log("script end");
// script start, script end,
// promise 1, promise 2, timeout
Microtasks always run before macrotasks.

The initial script is itself a macrotask: until it finishes, nothing else runs. Once the call stack is empty, the engine drains the microtask queue, including the microtasks added while draining. This detail explains why two chained then calls run before a setTimeout(fn, 0): each then schedules another microtask, and control is never handed back until the queue is empty.

JAVASCRIPT
setTimeout(() => console.log("macro 1"), 0);
setTimeout(() => console.log("macro 2"), 0);

queueMicrotask(() => {
  console.log("micro 1");
  queueMicrotask(() => console.log("micro 2"));
});
// micro 1, micro 2, macro 1, macro 2
A nested microtask runs before any macrotask.

The await keyword builds on this mechanism: it suspends the async function, hands control back to the caller, then schedules the resumption as a microtask. Even await null triggers this suspension, because the value is wrapped in an already-resolved promise. That is why the code placed after an await never runs synchronously.

JAVASCRIPT
async function main() {
  console.log("before await");
  await null;
  console.log("after await");
}

main();
console.log("sync code");
// before await, sync code, after await
await suspends the function and reschedules the rest.

Knowledge check

Make sure you remember the key points of this lesson.

  1. A script logs C synchronously, schedules A via setTimeout(fn, 0) and B via Promise.resolve().then. In what order do the letters appear?
    • A, B, C
    • C, A, B
    • C, B, A
    • B, C, A
  2. When is the microtask queue drained?
    • After each macrotask, completely, including microtasks added while draining
    • Only one microtask runs between two macrotasks
    • Every 16 ms, just before the page is rendered
    • Only once all macrotasks have been processed
  3. What does await actually do under the hood?
    • It blocks the thread until the promise resolves
    • It suspends the async function and schedules its resumption as a microtask
    • It turns the rest of the function into a macrotask
    • It does nothing if the awaited value is not a promise