The Event Loop In Depth

Course 3 · Ch 3
The Event Loop In Depth: Microtasks vs Macrotasks
Why a promise's .then() always runs before a setTimeout, even one scheduled for 0 milliseconds

Fundamentals Chapter 10 explained that JavaScript doesn't wait for asynchronous work — console.log lines after a setTimeout run before its callback. What that chapter didn't cover: when multiple asynchronous things are pending at once, JavaScript runs them in a very specific, predictable order — governed by two separate queues, not one.

Recap: The Call Stack Runs Synchronous Code First

console.log("1"); setTimeout(() => console.log("2"), 0); console.log("3"); // 1, 3, 2 — NOT 1, 2, 3, even with a 0ms delay

All ordinary, synchronous code finishes completely before the engine even considers running any scheduled callback — this is the call stack emptying out first. setTimeout(..., 0) doesn't mean "run immediately"; it means "run as soon as the call stack is empty and it's this callback's turn," which is always after every line of synchronous code that follows it.

Two Queues, Not One

Once the call stack is empty, JavaScript doesn't pick from a single waiting line — there are two: the microtask queue (promise callbacks, async function continuations) and the macrotask queue (setTimeout, setInterval, UI events). The rule: the entire microtask queue is fully drained before even one macrotask runs.

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

Even though setTimeout was scheduled before the promise's .then(), the microtask (the promise callback) still runs first — because ALL synchronous code runs first, THEN every pending microtask, and only THEN the next macrotask. This ordering is fixed and predictable, not a coincidence of timing.

await is built on exactly this mechanism
Every await inside an async function (Fundamentals Chapter 10) schedules the rest of that function as a microtask — which is precisely why an async function's code after await consistently runs before any pending setTimeout, no matter how the code is arranged on the page.

async/await Through the Same Lens

async function run() { console.log("A: start of run()"); await null; // resolves instantly, but STILL yields to the microtask queue console.log("C: after await"); } console.log("start"); run(); console.log("B: end of script"); // start // A: start of run() // B: end of script // C: after await

Calling run() executes synchronously up to the first await — that part runs immediately, no different from a normal function. The moment await is hit, everything after it becomes a microtask, even though await null resolves essentially instantly. This is why "B" logs before "C", despite run() being called before "B"'s own console.log.

Why This Matters in Practice

Most real bugs from this come from assuming code "after" an async operation in the source file also runs "after" it in time. A common mistake: updating a UI element inside a .then(), then immediately reading that same element's state on the very next line — that read happens before the microtask has had a chance to run, so it sees stale data. Understanding the queue order explains exactly why.

A long-running microtask chain can still starve macrotasks
Because the ENTIRE microtask queue must drain before any macrotask runs, a promise chain that keeps scheduling more microtasks (.then() returning another promise, repeatedly) can delay setTimeout callbacks and even UI rendering indefinitely. This is a real, if uncommon, performance pitfall — macrotasks aren't starved by one slow microtask, but they can be starved by an unbounded chain of them.
QueueExamplesDrained when?
Call stackAll synchronous codeRuns first, completely, every time
Microtask queue.then(), catch(), finally(), code after awaitFully emptied before the next macrotask
Macrotask queuesetTimeout, setInterval, UI events, fetch's network completionOne at a time, after microtasks are clear

Coding Challenges

Challenge 1

Without running it, write down (or comment) the exact console.log order for this code, then run it to check: console.log("1"); setTimeout(() => console.log("2"), 0); Promise.resolve().then(() => console.log("3")); Promise.resolve().then(() => console.log("4")); console.log("5");

📄 View solution
Challenge 2

Write an async function logSteps() that logs "1", then awaits a Promise.resolve(), then logs "2". Call logSteps(), then immediately log "3" right after the call (not inside it). Predict and then confirm the actual output order.

📄 View solution
Challenge 3

Write code with TWO setTimeout calls (both 0ms, logging "timeout A" and "timeout B" in that order) and ONE promise .then() (logging "promise") scheduled between them. Run it and explain in a comment why the promise callback runs before EITHER timeout, despite being scheduled in the middle.

📄 View solution

Chapter 3 Quick Reference

  • Call stack — all synchronous code; always finishes completely first
  • Microtask queue — promise .then()/catch()/finally(), code after await
  • Macrotask queue — setTimeout, setInterval, UI events
  • Order: all sync code → entire microtask queue → ONE macrotask → check microtasks again → repeat
  • setTimeout(fn, 0) means "as soon as possible," NOT "immediately" — sync code and microtasks always go first
  • await always yields to the microtask queue, even when the awaited value resolves instantly
  • An unbounded chain of microtasks can delay macrotasks indefinitely — a real, rare performance pitfall
  • Next chapter: design patterns — module pattern, observer, debounce/throttle