CodeOath
← All posts
JavaScript75 min total · 15 parts

JavaScript Core Concepts: Scope, Closures, `this`, and the Event Loop

Contents — Part 12 of 15: The Event Loop: Microtasks vs. Macrotasks
Part 12 of 15 · ~2 min

The Event Loop: Microtasks vs. Macrotasks

JavaScript runs on a single thread, but clearly does things "later" all the time — timers fire, network responses arrive, Promises resolve. The event loop is the mechanism that decides the order all of this deferred work actually runs in, using two separate queues.

Diagram of the JavaScript event loop showing the call stack, Web APIs, the microtask queue, and the macrotask queue

  • Microtasks — Promise .then()/.catch()/.finally() callbacks, queueMicrotask(), and (in Node) process.nextTick (which runs even before other microtasks)
  • Macrotasks (also called "tasks") — setTimeout, setInterval, I/O callbacks, UI events like clicks

The rule that explains almost every async ordering question: after the current synchronous code finishes, the engine drains the entire microtask queue — completely, including any new microtasks queued by earlier ones — before running even a single macrotask.

console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
// Output: 1, 4, 3, 2

Order of events: "1" and "4" run synchronously first. Then, before the setTimeout callback (a macrotask) is allowed to run, the engine fully drains the microtask queue — just the one .then() here — logging "3". Only after the microtask queue is completely empty does the next macrotask ("2") get its turn. This holds even with a 0ms delay, because the delay only controls when the callback is eligible to run, not that it preempts pending microtasks.

Microtasks can starve macrotasks

Because the engine won't move to the next macrotask until the microtask queue is fully empty, a microtask that keeps scheduling more microtasks can delay timers and rendering indefinitely:

function loop() {
  Promise.resolve().then(loop); // schedules another microtask, forever
}
loop();
// setTimeout callbacks, clicks, and rendering never get a turn — the microtask queue never empties

This is a real, if uncommon, class of bug — an unbounded chain of chained Promise callbacks can make an app feel completely frozen even though technically "nothing is blocking" in the traditional synchronous-loop sense.

Where async/await fits into the queues

Everything after an await in an async function resumes as a microtask, once the awaited Promise settles — which is why the ordering rules above apply identically to async/await code, even though it doesn't visibly mention .then() anywhere:

console.log("start");

async function example() {
  console.log("A");
  await null; // yields — everything after this line is scheduled as a microtask
  console.log("B");
}
example();

console.log("end");
// Output: start, A, end, B

await null still yields control back to the caller and resumes "B" as a microtask, exactly as if it were Promise.resolve(null).then(() => console.log("B"))async/await is genuinely just Promise chaining with different syntax, all the way down to scheduling.