🔄 Understanding the JavaScript Event Loop: Callbacks, Promises, and the Microtask Queue
You've seen the interview question a hundred times. What does this log?
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// Output: 1, 4, 3, 2
Most developers can answer this correctly. But why does 3 come before 2? Why isn't Promise the same as setTimeout(fn, 0)? The answer requires understanding the event loop's three data structures, and how they interact. This is not a trivia question — it's the foundation for understanding async behavior, race conditions, and performance optimization in every JavaScript application.
What Are the Three Queues in the Event Loop?
The JavaScript runtime (V8 in Chrome/Node.js, SpiderMonkey in Firefox, JavaScriptCore in Safari) processes work through three data structures:
- Call Stack: One frame per function call. Synchronous code executes here, top to bottom. When the stack is empty, the event loop pulls from the queues. The stack is LIFO (Last In, First Out).
- Microtask Queue: Promises (.then, .catch, .finally), MutationObserver callbacks, queueMicrotask(). Processed entirely after each task, before the next task. This queue is emptied completely — if microtasks spawn new microtasks, they execute in the same cycle.
- Task Queue (Macrotask Queue): setTimeout, setInterval, I/O callbacks, UI events, postMessage, setImmediate (Node.js). One task processed per event loop iteration, then ALL pending microtasks, then render (in browsers), then the next task.
Why Does Promise.then() Run Before setTimeout(fn, 0)?
The event loop iteration is: (1) pick one task from the Task Queue, (2) run it to completion, (3) drain the ENTIRE Microtask Queue, (4) render if needed (browser only), (5) repeat. setTimeout(fn, 0) schedules a new task after 0ms. Promise.resolve().then(fn) schedules a microtask. Since microtasks drain between tasks, the Promise callback always runs before the next task — even a task with 0ms delay.
The spec defines this in the HTML Standard's event loop processing model. The critical sentence: "First, perform a microtask checkpoint." This checkpoint runs at the end of every task — and it drains the entire microtask queue, including any microtasks added during the checkpoint.
When Does the Microtask Queue Cause Infinite Loops?
Because the microtask queue is drained completely — including recursively added microtasks — you can starve the event loop:
function recursiveMicrotask() {
Promise.resolve().then(() => {
console.log('running...');
recursiveMicrotask(); // Schedules ANOTHER microtask
});
}
recursiveMicrotask();
// The page freezes. No rendering. No events. No network responses.
Each call adds a new microtask to the queue before the current checkpoint finishes. The checkpoint never finishes because the queue never empties. The browser's rendering pipeline never gets a turn. This is the equivalent of an infinite while(true) loop, but harder to debug because it's distributed across Promise chains. In Node.js, this blocks the entire process.
setTimeout(fn, 0) vs queueMicrotask(fn): Which to Use When?
| Behavior | setTimeout(fn, 0) | queueMicrotask(fn) |
|---|---|---|
| Executes | Next event loop iteration (after microtasks) | Current iteration, before next task |
| Allows rendering between? | Yes | No |
| Minimum delay | ~4ms (nested >5 levels: 4ms clamp) | <1ms (effectively immediate) |
| Use for | Yielding to the browser (rendering, events) | Atomic state updates (batch DOM changes) |
Use queueMicrotask() when you need to defer execution but want it to happen before the browser processes any I/O or rendering — e.g., batching multiple state changes into one synchronous-appearing update. Use setTimeout(fn, 0) when you deliberately want to yield to the browser — e.g., after updating a large DOM subtree, to let the browser paint before doing more work.
The 4ms Clamp: setTimeout's Hidden Minimum
Per the HTML Standard, setTimeout has a minimum delay of 4ms for nested calls beyond 5 levels of nesting. This means:
// This does NOT run every 0ms — it runs every ~4ms
let count = 0;
function nested() {
count++;
if (count < 100) setTimeout(nested, 0);
}
setTimeout(nested, 0);
This was introduced in 2010 to prevent scripted denial-of-service attacks (infinite setTimeout loops with 0ms delay consuming 100% CPU). For high-frequency timing, use requestAnimationFrame (syncs to display refresh, typically 16.6ms at 60Hz) or performance.now() with manual scheduling.
Practical Debugging: Detect Event Loop Starvation
This snippet detects when the event loop is blocked for too long (useful for monitoring in production):
let lastCheck = performance.now();
function monitorEventLoop() {
const now = performance.now();
const delay = now - lastCheck;
lastCheck = now;
if (delay > 50) { // Event loop was blocked for >50ms
console.warn(`Event loop blocked for ${delay.toFixed(0)}ms`);
// Send to analytics or show warning to user
}
setTimeout(monitorEventLoop, 0);
}
monitorEventLoop();
If the event loop is healthy, this function fires every ~4-5ms and reports ~4-5ms delays. If it reports 200ms, something synchronous blocked the event loop — a long computation, a synchronous XHR, or a microtask loop. This is a lightweight alternative to full APM tools for client-side monitoring.
Found this helpful? Explore 100+ free online tools — no signup needed.