webdev.complete
Async JavaScript
JavaScript
Lesson 31 of 117
25 min

The Event Loop

Why JS is single-threaded but not slow.

JavaScript runs on a single thread. One thing at a time, in order. And yet a webpage can fetch data, animate a button, respond to your clicks, and wait for a timer all at once. The thing that makes that possible is the event loop, and once you understand it you understand 90% of weird async bugs.

The four moving parts

The runtime has exactly four pieces you need to picture:

  • Call stack- the list of functions currently running. Last in, first out. When it's empty, JS is idle.
  • Web APIs - things provided by the browser (or Node), not the language itself: setTimeout, fetch, the DOM. They run outside the JS thread.
  • Task queue (macrotasks) - completed Web API jobs waiting their turn: timers, I/O, click events.
  • Microtask queue - a higher-priority queue for promise callbacks (.then, await continuations) and queueMicrotask.
The loop, simplified
While the call stack is empty: drain the entire microtask queue, then take one task from the macrotask queue and run it, then repeat. Microtasks always cut the line.

A picture in code

js
console.log("1");

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

Promise.resolve().then(() => console.log("3"));

console.log("4");

// Output: 1, 4, 3, 2

Step by step:

  1. console.log("1") runs synchronously.
  2. setTimeout hands the callback to the browser. The callback eventually lands in the task queue.
  3. Promise.resolve().then(...) schedules its callback on the microtask queue.
  4. console.log("4") runs synchronously.
  5. The stack is empty. The loop drains microtasks first → 3.
  6. Then it takes one task → 2.

setTimeout(fn, 0) is not zero

Even with a delay of 0, the callback has to go through the task queue. It will only run after the current call stack empties and after every microtask scheduled before it.

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

for (let i = 0; i < 5; i++) {
  Promise.resolve().then(() => console.log("micro " + i));
}

console.log("sync");

// Output:
// sync
// micro 0
// micro 1
// micro 2
// micro 3
// micro 4
// timer
Minimum delay is not zero either
Browsers clamp setTimeout to a minimum (4ms in most cases, more if the tab is backgrounded). Use queueMicrotask or Promise.resolve().then(...) for "run after this stack but before any timer."

The classic interview puzzle

js
async function main() {
  console.log("a");

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

  await Promise.resolve();
  console.log("c");

  Promise.resolve().then(() => console.log("d"));

  console.log("e");
}

main();
console.log("f");

// Output: a, f, c, e, d, b

Walking through it:

  • "a" runs (sync).
  • setTimeout schedules "b" as a task.
  • await Promise.resolve() pauses main and schedules its continuation as a microtask. Control returns to the caller.
  • "f" runs (still sync, outside main).
  • The stack is empty. Drain microtasks → continuation of main runs, logs "c".
  • Schedules "d" as a microtask, logs "e".
  • Drain microtasks again → "d".
  • One task → "b".

Why blocking the loop is bad

Because there's one thread, a long synchronous loop freezes everything: animations stop, clicks queue up, the tab feels dead. That's why heavy CPU work belongs in a Web Worker, why fetch is async, and why you should never write a long synchronous for loop on the main thread.

Predict the order, then run it

Try to guess the output before running. Then change the code: swap a promise for a setTimeout, add an await, see what moves.

console.log("1: script start");

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

Promise.resolve().then(() => console.log("3: promise.then"));

queueMicrotask(() => console.log("4: queueMicrotask"));

(async () => {
  console.log("5: async start");
  await Promise.resolve();
  console.log("6: after await");
})();

console.log("7: script end");

// Predict the order before scrolling down...
//
//
//
// Actual order:
// 1: script start
// 5: async start
// 7: script end
// 3: promise.then
// 4: queueMicrotask
// 6: after await
// 2: setTimeout 0

Quiz

Quiz1 / 3

Which queue runs first when the call stack is empty?

Recap

  • JS is single-threaded. The event loop coordinates between the call stack and queues.
  • Microtasks (promises, queueMicrotask) drain fully between every macrotask (timers, I/O).
  • setTimeout(fn, 0)is "run after this stack and all current microtasks," not "run immediately."
  • Blocking the main thread freezes the UI. Use async APIs for I/O, Workers for heavy CPU.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.