The Event Loop & Async Execution Model
Understanding the event loop isn't about memorizing output puzzles. It's about having a scheduling mental model β predicting when your code runs relative to rendering, user input, and other async operations.
The Architecture
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β JavaScript Runtime β
β ββββββββββββββββ ββββββββββββββββββββββββββββββββββββ β
β β Call Stack β β Web APIs / Node APIs β β
β β β β (setTimeout, fetch, DOM events, β β
β β main() ββββββ requestAnimationFrame, I/O) β β
β β fn() β βββββββββββββ¬βββββββββββββββββββββββ β
β β ... β β β
β ββββββββ¬ββββββββ β β
β β β β
β Event Loop βΌ β
β βββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β 1. Drain Microtask Queue (ALL) β β
β β Promise.then, queueMicrotask, MutationObserverβ β
β β β β
β β 2. Pick ONE Macrotask β β
β β setTimeout, setInterval, I/O callbacks β β
β β β β
β β 3. Render (if needed, ~16ms for 60fps) β β
β β requestAnimationFrame β Style β Layout β β
β β β Paint β Composite β β
β β β β
β β 4. requestIdleCallback (if time remaining) β β
β β β β
β β β Go to step 1 β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββMicrotasks vs Macrotasks
This distinction is the core of event loop understanding:
| Microtasks (Higher Priority) | Macrotasks (Lower Priority) |
|---|---|
Promise.then/catch/finally | setTimeout / setInterval |
queueMicrotask() | setImmediate (Node) |
MutationObserver | I/O callbacks |
process.nextTick (Node) | UI rendering |
await continuations | requestAnimationFrame* |
*rAF runs at a specific point in the render cycle, between microtasks and the next macrotask.
The Critical Rule
ALL microtasks drain before the next macrotask or render. This means:
// Microtask chain blocks rendering
Promise.resolve().then(() => {
// This runs before any setTimeout, before any render
Promise.resolve().then(() => {
// This ALSO runs before any setTimeout or render
// You can chain infinitely β it will block everything
});
});
setTimeout(() => console.log('macro'), 0); // Waits for ALL microtasksThe Complete Execution Order
console.log('1 - sync');
setTimeout(() => console.log('2 - macro'), 0);
Promise.resolve()
.then(() => console.log('3 - micro'))
.then(() => console.log('4 - micro (chained)'));
queueMicrotask(() => console.log('5 - micro (queued)'));
requestAnimationFrame(() => console.log('6 - rAF'));
console.log('7 - sync');Output:
1 - sync
7 - sync
3 - micro
5 - micro (queued)
4 - micro (chained)
2 - macro
6 - rAF (next render frame)Why This Order?
- Synchronous code runs to completion (1, 7)
- Call stack empty β drain microtask queue (3, 5, 4) β note: chained
.thencreates a new microtask - Render step: rAF callbacks run (6)
- Pick next macrotask (2)
The Render Pipeline Integration
One frame (~16ms at 60fps):
βββ Execute macrotask (or microtask flush)
βββ Drain microtask queue
βββ requestAnimationFrame callbacks
βββ Style recalculation
βββ Layout
βββ Paint
βββ Composite
βββ requestIdleCallback (if time remains in frame)
βββ β Next frameWhy This Matters for UI
// β Blocks rendering β microtask loop
function busyMicrotasks() {
let i = 0;
function loop() {
if (i++ < 100000) {
queueMicrotask(loop); // 100K microtasks β no render until done
}
}
loop();
}
// β
Yields to renderer β uses macrotasks
function yieldingWork() {
let i = 0;
function chunk() {
const end = Math.min(i + 1000, 100000);
while (i < end) processItem(i++);
if (i < 100000) setTimeout(chunk, 0); // Yields between chunks
}
chunk();
}Timing APIs and When to Use Each
setTimeout(fn, 0) β "Do this later"
// Defers to next macrotask β after microtasks AND potentially after a render
setTimeout(() => {
// Good for: breaking up long synchronous work
// Bad for: anything that needs to happen before next paint
}, 0);Minimum delay: Browsers clamp nested setTimeout to 4ms after 5 levels of nesting.
queueMicrotask(fn) β "Do this ASAP, but after current task"
queueMicrotask(() => {
// Runs before any macrotask or render
// Good for: state updates that must be consistent before next render
// Bad for: heavy work (blocks rendering)
});requestAnimationFrame(fn) β "Do this before next paint"
requestAnimationFrame(() => {
// Synchronized with display refresh (~60fps)
// Good for: DOM measurements, animations, visual updates
// Each callback gets a DOMHighResTimeStamp
element.style.transform = `translateX(${position}px)`;
});requestIdleCallback(fn) β "Do this when you have free time"
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
processTask(tasks.shift());
}
if (tasks.length > 0) {
requestIdleCallback(processRemainingTasks);
}
}, { timeout: 2000 }); // Force execution after 2s even if busyGood for: Analytics, prefetching, non-critical computations.
Comparison
| API | Priority | When it runs | Use for |
|---|---|---|---|
queueMicrotask | Highest | Before next task/render | Critical state consistency |
requestAnimationFrame | High | Before next paint | Visual updates, animations |
setTimeout(fn, 0) | Medium | Next macrotask | Yielding, deferring work |
requestIdleCallback | Lowest | When browser is idle | Background work, analytics |
async/await and the Event Loop
await pauses the function and schedules the continuation as a microtask:
async function example() {
console.log('A'); // Sync
await Promise.resolve(); // Pauses here, schedules rest as microtask
console.log('B'); // Runs as microtask
}
console.log('1');
example();
console.log('2');
// Output: 1, A, 2, BThe code after await is essentially the .then() callback β it runs after the current synchronous code completes.
Node.js Differences
Node.js has additional phases:
βββββββββββββββββββββββββββββ
ββ>β timers β setTimeout, setInterval
β βββββββββββββββ¬ββββββββββββββ
β βββββββββββββββ΄ββββββββββββββ
β β pending callbacks β System-level callbacks
β βββββββββββββββ¬ββββββββββββββ
β βββββββββββββββ΄ββββββββββββββ
β β poll (I/O) β File, network callbacks
β βββββββββββββββ¬ββββββββββββββ
β βββββββββββββββ΄ββββββββββββββ
β β setImmediate β After I/O, before timers
β βββββββββββββββ¬ββββββββββββββ
β βββββββββββββββ΄ββββββββββββββ
β β close callbacks β socket.destroy(), etc.
β βββββββββββββββββββββββββββββprocess.nextTick runs before any other microtask β it's even higher priority than Promise.then.
Common Pitfalls
Microtask Starvation
// This freezes the browser β rendering never gets a chance
function infinite() {
Promise.resolve().then(infinite);
}
infinite(); // β οΈ Blocks ALL macrotasks and renderingThe "setTimeout is not a timer" Misconception
setTimeout(fn, 100); // "Run fn after AT LEAST 100ms, not exactly 100ms"If the main thread is busy for 200ms, the callback won't run until 200ms+ even though 100ms was specified.
Promise.resolve() vs new Promise(resolve => resolve())
// Both create resolved promises, but:
Promise.resolve(value); // If value is a Promise, returns it as-is (no wrapping)
new Promise(resolve => resolve(value)); // Always creates a new Promise wrapperInterview Signal
Senior candidates demonstrate:
- The full loop β Microtasks β render β macrotask, not just "microtask before macrotask"
- Render integration β Where rAF fits, why microtask floods block painting
- Practical scheduling β Choosing the right API for the right timing need
- Starvation awareness β Understanding that microtasks can block everything
- Node.js awareness β process.nextTick, setImmediate, the poll phase