Back to Blog
Runtime2026-04-28

The JavaScript Event Loop, Explained Visually

Understand how the call stack, microtask queue, and macrotask queue actually interact.

The event loop is the heart of JavaScript runtime behavior. Understanding it eliminates whole categories of timing bugs — and explains why "just use setTimeout 0" sometimes works, sometimes janks, and sometimes deadlocks a test.

The Mental Model: One Stack, Two Kinds of Queues

A JavaScript runtime maintains at minimum:

- Call stack — the currently executing synchronous code; nothing else runs until it empties - Microtask queue — promise reactions, queueMicrotask callbacks, MutationObserver notifications - Macrotask queuessetTimeout/setInterval timers, I/O completions, message events, UI events

One turn of the loop: take one macrotask, run it to completion, then drain the microtask queue to empty — including microtasks queued by other microtasks — then (in browsers) possibly render, then take the next macrotask. That "drain to empty" rule is the single most important sentence in this post.

Ordering, Demonstrated

- setTimeout(() => console.log('macro'), 0); - Promise.resolve().then(() => console.log('micro')); - console.log('sync');

Output: sync, micro, macro. Synchronous code finishes first because the stack must empty. The promise reaction runs next because microtasks drain before any timer fires — even a zero-delay one. Note also that setTimeout(fn, 0) is not really zero: browsers clamp nested timers to 4ms after five levels of nesting, and background tabs throttle far more aggressively.

The Microtask Starvation Trap

Because the queue drains to empty, a microtask that queues another microtask runs in the same turn:

- function loop() { Promise.resolve().then(loop); } — the browser never renders again

The loop never yields to macrotasks or the renderer. This is not theoretical: recursive promise chains in state libraries and "flush until stable" test utilities hit it regularly. If work must yield, schedule the continuation as a macrotask (setTimeout, MessageChannel) or use scheduler.yield() where available.

queueMicrotask vs Promise.resolve().then

Both enqueue microtasks. queueMicrotask(fn) is the direct API: no promise allocation, and an exception inside it propagates as a normal uncaught error rather than an unhandled rejection. Prefer it when you need "after the current synchronous work, before anything else" semantics without promise machinery.

Node.js Has More Phases

Node's loop (libuv) cycles through named phases: timerspending callbacksidle/preparepoll (I/O) → check (setImmediate) → close callbacks. Two Node-specific queues outrank everything:

- process.nextTick callbacks run before other microtasks, after each phase transition — abuse can starve I/O exactly like the microtask trap - Promise microtasks drain right after the nextTick queue

Practical consequences: setImmediate(fn) runs in the check phase and is the idiomatic "after I/O, next turn" hook. The classic interview race — setTimeout(fn, 0) vs setImmediate(fn) at top level — is nondeterministic because it depends on whether the loop enters the timers phase before the 1ms timer threshold is reached; inside an I/O callback, setImmediate always wins because check follows poll.

The Browser Rendering Boundary

Rendering is not a task in either queue. Between macrotasks — after microtasks drain — the browser *may* run the rendering pipeline: requestAnimationFrame callbacks, style recalculation, layout, paint. Roughly every 16.7ms on a 60Hz display, but only when there is something to draw and the main thread is free.

- requestAnimationFrame runs before paint, in the render step — it is not a macrotask, and rAF callbacks queued during rAF wait for the next frame - A long synchronous task or a microtask flood blocks the render step entirely — that is what "jank" literally is - setTimeout-based animation drifts because timers have no relationship to frame timing; rAF is aligned by construction

This is also why measuring DOM layout after a mutation inside a microtask is safe (nothing painted yet), while assuming the user has *seen* the change is wrong.

Scheduling Long Work in 2026

For main-thread work that exceeds a frame budget, the modern toolkit is:

- scheduler.postTask(fn, { priority }) — prioritized macrotask scheduling (user-blocking, user-visible, background) - scheduler.yield() — await it inside a loop to let input and rendering interleave, resuming with priority over new tasks - requestIdleCallback — genuinely deferrable work, with a deadline object - Workers — anything CPU-bound that never needs the DOM

Chunking with await scheduler.yield() every few milliseconds is the pattern that keeps INP (Interaction to Next Paint) healthy under real load.

One caution with requestIdleCallback: idle time may never arrive on a busy page, so pass a timeout option for work that must eventually run, and never put user-visible updates behind it.

Debugging Checklist

- Output order surprising? Write down which queue each callback lands in; sync → microtasks → one macrotask → repeat - UI frozen but CPU busy? Look for microtask or nextTick recursion - Timer firing late? Check nesting clamps, background-tab throttling, and long tasks ahead of it - Node exiting before work finishes? Pending microtasks do not keep the process alive; pending timers and sockets do

The event loop is not folklore; it is a small, precise state machine. Learn the drain-to-empty rule, the render boundary, and Node's phase order, and most "random" timing bugs become deterministic — because they always were.