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. RepeatWhy 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.