The event loop — call stack, task queue, microtask queue

JavaScript runs on a single thread with no true parallelism, yet handles asynchronous work constantly — the event loop is the exact mechanism that makes that possible, and its two separate queues explain a genuinely common ordering surprise.

Intermediate

3 min read

The call stack: synchronous code, one frame at a time

function a() { b(); }
function b() { c(); }
function c() { console.log("in c"); }
a();
// call stack grows: a → b → c, then unwinds as each function returns

JavaScript executes on a single call stack — one function running at a time, with each nested call added to the top of the stack and removed when it returns. This is why JavaScript is described as single-threaded: there's exactly one call stack, and as long as it has synchronous work on it, nothing else (including any async callback) can run, no matter how long that synchronous work takes.

Where async callbacks actually wait: two separate queues, not the stack

console.log("1");
setTimeout(() => console.log("2"), 0); // goes to the MACROTASK (task) queue, even with a 0ms delay
Promise.resolve().then(() => console.log("3")); // goes to the MICROTASK queue
console.log("4");
// Actual output: 1, 4, 3, 2 — NOT 1, 2, 3, 4

setTimeout's callback, even with a 0ms delay, never runs synchronously — it's handed off to the macrotask (task) queue, waiting until the call stack is completely empty. A Promise's .then() callback goes to a separate microtask queue. The event loop's actual job, repeated forever, is: run everything currently on the call stack to completion, then drain the entire microtask queue (every microtask, including new ones added while draining), and only then take a single task from the macrotask queue and repeat. This is exactly why console.log("3") (a microtask) prints before console.log("2") (a macrotask), even though the setTimeout was scheduled first in the source code.

Why this ordering isn't trivia — it explains a real, common surprise

Promise.resolve().then(() => {
  console.log("microtask 1");
  Promise.resolve().then(() => console.log("microtask 2 (added DURING draining)"));
});
setTimeout(() => console.log("macrotask"), 0);
// Output: microtask 1, microtask 2, macrotask — the SECOND microtask, added
// mid-drain, still runs before the already-queued macrotask

Because the event loop drains the microtask queue completely — including microtasks added while draining — before ever touching the macrotask queue, a chain of promises can keep the macrotask queue waiting indefinitely if each .then() schedules another one. This is the actual mechanism behind a real, occasionally-encountered bug class: a setTimeout callback that seems to run "later than expected," when the real cause is that microtasks keep getting added faster than the loop can move on to macrotasks.

async/await runs on the same queues — it's not a separate mechanism

async function example() {
  console.log("1");
  await Promise.resolve();
  console.log("2"); // this line runs as a MICROTASK, exactly like a .then() callback would
}
example();
console.log("3");
// Output: 1, 3, 2 — everything after `await` behaves like it's inside a .then()

async/await (covered in depth in the next lesson) doesn't introduce a new scheduling mechanism — code after an await runs as a microtask, the exact same queue a .then() callback uses, just with syntax that reads top-to-bottom instead of nested callbacks. Understanding the event loop's two-queue behavior directly explains why "3" prints before "2" here, even though "2" appears earlier in the source code than console.log("3").

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. Why does a Promise's `.then()` callback run before a `setTimeout(fn, 0)` callback, even though the timeout was scheduled first?

2. Why can a chain of promises that keep scheduling new microtasks delay a setTimeout callback indefinitely?

3. Does async/await introduce a separate scheduling mechanism from Promises?