JavaScript

The JavaScript Event Loop, Explained With Diagrams

How the call stack, task queue, and microtask queue actually interact — and why your setTimeout(fn, 0) doesn't run immediately.

Ava ChenJuly 18, 20262 min read
Share:

JavaScript is single-threaded, yet it handles thousands of concurrent operations without blocking. The event loop is how — and it trips up almost everyone the first time they see the actual execution order.

The three pieces

  1. Call stack — where currently executing code lives. Synchronous code runs here, one frame at a time.
  2. Microtask queue — where Promise callbacks (.then, async/await continuations) go.
  3. (Macro)task queue — where setTimeout, setInterval, and I/O callbacks go.

The event loop's rule is simple but has a specific order: run everything on the call stack until it's empty, then drain the entire microtask queue, then run one task from the task queue, then repeat.

The classic surprising example

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

Output:

1 4 3 2

Walking through it:

  1. console.log("1") runs synchronously — call stack.
  2. setTimeout schedules its callback on the task queue and returns immediately — it doesn't run yet, even with a 0ms delay.
  3. Promise.resolve().then(...) schedules its callback on the microtask queue.
  4. console.log("4") runs synchronously.
  5. Call stack is now empty. The event loop drains the microtask queue: "3" logs.
  6. Only now does the event loop pick up the next task: the setTimeout callback runs, logging "2".

Why this matters in practice

async/await continuations are microtasks. This means a chain of awaited promises will always fully resolve before a pending setTimeout fires, no matter how small its delay — a common source of confusion when debugging race conditions between timers and async data fetching.

js
async function load() {
  const data = await fetch("/api/data").then((r) => r.json());
  console.log(data); // this microtask runs before any pending setTimeout
}

The practical takeaway

If you need something to run after the current synchronous code but before the browser repaints or handles the next timer, a microtask (a resolved Promise, or queueMicrotask()) gets there first. If you need to yield back to the browser entirely — letting it paint, handle input, or run other pending timers — setTimeout(fn, 0) (or requestAnimationFrame for visual work) is the tool, precisely because it waits for the next pass of the event loop instead of the current one.

Advertisement

Frequently Asked Questions

Ava Chen
Ava Chen

Senior Software Engineer

Ava writes about frontend architecture, React, and TypeScript. Previously built developer tools at scale-up startups.

Related Articles