Skip to content
yutils

How the Event Loop Actually Works

Call stack + task queue + microtask queue, why setTimeout(fn, 0) isn't actually 0, microtask starvation, libuv's thread pool in Node, the difference between browser and Node event loops, and how 'async' is just a sugar for the queue.

~9 min read

JavaScript is single-threaded — so how do async fetch / setTimeout / Promise work together? Answer: the event loop. The call stack, task queue, and microtask queue cooperate. This guide covers why setTimeout(fn, 0) isn't 0ms, microtask starvation, and how Node differs from the browser.

Event Loop Core — Three Components

┌─────────────────────────────┐
│        Call Stack           │
│  (frames of currently       │
│   running functions)        │
└──────────┬──────────────────┘
           │ pop / push
           ↓
┌─────────────────────────────┐
│   Microtask Queue           │   ← Promise.then, queueMicrotask
│   (Promise callbacks)       │
└──────────┬──────────────────┘
           │ Drain (all) after each task
           ↓
┌─────────────────────────────┐
│   Task Queue (macrotask)    │   ← setTimeout, setInterval,
│   (timer / I/O callbacks)   │     I/O, postMessage
└─────────────────────────────┘

Rules:
1. When the call stack empties → drain ALL microtasks
2. After microtasks → process ONE task from the task queue
3. If that task creates microtasks → drain them before the next task
4. Repeat

Why setTimeout(fn, 0) Isn't 0ms

console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");

Output: A, C, B

Why:
1. console.log("A") runs (call stack)
2. setTimeout queues the callback in the task queue + delay 0
3. console.log("C") runs
4. Stack empty
5. No microtasks
6. Pull setTimeout's callback from the task queue → "B"

→ "0ms delay" means "next turn".
   Current task must finish before the next task can start.
   Minimum ~4ms (browser clamp) — HTML5 spec.

Use:
  setTimeout(fn, 0) = "pause large work to give the UI time to paint"

Microtasks vs Macrotasks

setTimeout(() => console.log("timer"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("sync");

Output: sync, promise, timer

Why:
1. console.log("sync") — call stack
2. Promise.then callback → microtask queue
3. setTimeout callback → task queue
4. Stack empty
5. Drain microtasks → "promise"
6. Process one task → "timer"

Rules:
- Microtasks: Promise.then, queueMicrotask, MutationObserver
- Macrotasks: setTimeout, setInterval, setImmediate (Node), I/O,
              postMessage, requestAnimationFrame (browser)

→ Promise.then chains always run before setTimeout.

Microtask Starvation

function recursive() {
  Promise.resolve().then(recursive);
}
recursive();
// → A microtask reschedules itself forever
//   Microtask drain never ends → macrotasks and UI paint blocked → freeze

vs setTimeout:
function recursive() {
  setTimeout(recursive, 0);
}
recursive();
// → Each turn is a macrotask; microtasks drain in between; UI can paint

→ Deep Promise chains can freeze a browser or Node.
   Insert chunking (yield via setTimeout) if needed.

Node.js Event Loop (libuv)

Node's event loop = provided by libuv. Six phases:

┌───────────────────────────┐
│   timers (setTimeout)     │  ← expired timer callbacks
├───────────────────────────┤
│   pending callbacks       │  ← OS I/O completions
├───────────────────────────┤
│   idle, prepare           │  ← internal
├───────────────────────────┤
│   poll (I/O)              │  ← receive new I/O events, run callbacks
├───────────────────────────┤
│   check (setImmediate)    │  ← setImmediate callbacks
├───────────────────────────┤
│   close callbacks         │  ← close events (socket.on('close'))
└───────────────────────────┘
     ↓ Microtasks drain between phases

setImmediate vs setTimeout(fn, 0):
- setTimeout: timers phase
- setImmediate: check phase
- Very close in time, order depends on phase

Node-specific — process.nextTick:
- Even higher priority than microtasks (between phases, immediately)
- Starvation risk (recursive nextTick)

Async / Await in Practice

async function foo() {
  console.log("A");
  await Promise.resolve();
  console.log("B");
}

foo();
console.log("C");

Output: A, C, B

Why:
- await is sugar for Promise.then(...)
- The line after await schedules as a microtask
- So foo()'s tail ("B") runs after the synchronous "C"

→ async/await is syntax; underneath it's Promise + microtasks.
   Understanding this is essential for debugging ordering / race issues.

Workers — Breaking the Single Thread

Web Worker (browser):
  const worker = new Worker("worker.js");
  worker.postMessage({task: "compute"});
  worker.onmessage = (e) => console.log(e.data);

  // worker.js
  self.onmessage = (e) => {
    const result = heavyComputation(e.data);
    self.postMessage(result);
  };

→ A separate OS thread (or process). Doesn't block the main thread.
→ The answer for CPU-bound work.

Node worker_threads — same pattern. Also libuv has a thread pool
   (4 threads, UV_THREADPOOL_SIZE) used by some "sync" APIs (fs, crypto, dns).

Common Pitfalls

  • Blocking sync functions — JSON.parse(huge), sync bcrypt, etc. freeze the loop. Use async or workers.
  • Trusting setTimeout timing — clamps and system load matter. For precise timing use performance.now() or Audio APIs.
  • Deep Promise chains — microtask starvation. Chunk large batches with setTimeout yields.
  • Silent errors — uncaught Promise rejections can be silent. Use process.on("unhandledRejection") or window.addEventListener("unhandledrejection").
  • Overusing workers — each worker is ~MB of memory + spawn cost. Pool and reuse.

Wrap-up

The event loop isn't magic but a strict algorithm — call stack + microtasks + macrotasks in a defined order. Understanding it dissolves "why does this callback run later than that one?" mysteries.

Practical — push heavy sync work to workers / chunked async. Insert yields into deep Promise chains. Use Node's nextTick carefully. CPU-bound = workers; I/O-bound = await is enough.

Back to guides