The Event Loop: Complete Model
The Interview Question Everyone Gets Wrong
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
Most developers know the output is 1, 4, 3, 2. But ask why — why does the Promise callback run before setTimeout even though both are "asynchronous" and setTimeout has a delay of 0? — and you'll separate the engineers who memorized the answer from those who understand the machine.
The answer is the event loop. Not the simplified "there's a queue" version. The actual algorithm.
The Mental Model
The event loop is a post office with priority mail. It processes letters (tasks) one at a time from the mailbox. But after processing each letter, before grabbing the next one, the clerk checks a priority slot (microtask queue) and processes ALL priority items first. Only when the priority slot is completely empty does the clerk grab the next regular letter. Occasionally, the clerk also stops to update the display board (render).
The Algorithm
The HTML specification defines the event loop in Section 8.1.7. Here's what actually happens, stripped of spec jargon:
while (true) {
// 1. Pick the oldest task from a task queue (if any)
task = pickOldestTaskFromAnyQueue();
if (task) {
// 2. Execute it (this runs on the call stack)
execute(task);
}
// 3. Microtask checkpoint — drain ALL microtasks
while (microtaskQueue.length > 0) {
execute(microtaskQueue.shift());
}
// 4. If it's time to render (~16.6ms for 60fps):
if (shouldRender()) {
// 4a. Run requestAnimationFrame callbacks
runRAFCallbacks();
// 4b. Style, Layout, Paint
render();
}
// 5. If nothing to do, wait for a task to appear
}
That's it. That's the entire event loop. Every async behavior you've ever observed — setTimeout ordering, Promise resolution timing, animation frame scheduling — follows from this algorithm.
What Counts as a Task (Macrotask)?
A task is a discrete unit of work scheduled by the browser or by APIs:
setTimeout/setIntervalcallbacks- I/O completion callbacks
- UI events (click, keydown, scroll)
MessageChannel/postMessagerequestIdleCallback
Each task runs to completion. The engine cannot interrupt a task halfway through.
What Counts as a Microtask?
Microtasks are higher-priority work that must complete before the event loop moves on:
Promise.then/catch/finallycallbacksqueueMicrotask()callbacksMutationObservercallbacks
The critical rule: all microtasks are drained after every task — including microtasks that are scheduled by other microtasks. This is the key to understanding Promise timing.
The Task Queue Is Not a Single Queue
The spec says the browser can have multiple task queues with different priorities. In practice, browsers have:
- An input event queue (clicks, keyboard) — highest priority
- A timer queue (setTimeout, setInterval)
- A networking queue (fetch completions)
- A rendering queue (internal)
The browser chooses which queue to pick from. This is why user input events feel responsive even when timers are backed up — the browser prioritizes the input queue.
The HTML spec mandates a minimum delay of 4ms for nested setTimeout calls (after the 5th nesting level). Even without nesting, the actual delay depends on when the event loop gets around to processing the timer queue. If a long task is running, setTimeout(fn, 0) might not fire for hundreds of milliseconds.
Deep Explanation: Walking Through the Algorithm
Step through the code below to see exactly how the event loop processes each line, manages the call stack, and drains the queues:
The output is 1, 4, 3, 2. Not because of magic rules about "microtasks before macrotasks." Because of the algorithm: after each task, drain all microtasks, then pick the next task.
Production Scenario: The Long Task Problem
Here's where the event loop model saves you in production. Imagine a dashboard that processes 100,000 data points to render a chart:
// Bad: blocks for 200ms — no rendering, no user input
function processData(data) {
const results = [];
for (let i = 0; i < data.length; i++) {
results.push(heavyComputation(data[i]));
}
renderChart(results);
}
During that 200ms, the event loop is stuck on a single task. The browser cannot:
- Respond to clicks or scrolling
- Run
requestAnimationFramecallbacks - Render any DOM changes
- Process any other timers
The fix is to break the work into multiple tasks, giving the event loop breathing room:
// Good: yields to the event loop after each chunk
function processDataChunked(data) {
const results = [];
let i = 0;
const CHUNK_SIZE = 1000;
function processChunk() {
const end = Math.min(i + CHUNK_SIZE, data.length);
while (i < end) {
results.push(heavyComputation(data[i]));
i++;
}
if (i < data.length) {
// Schedule next chunk as a new task — lets rendering happen
setTimeout(processChunk, 0);
} else {
renderChart(results);
}
}
processChunk();
}
Each setTimeout call creates a new task. Between tasks, the event loop runs the microtask checkpoint and (potentially) renders. The page stays responsive.
Don't use Promise.resolve().then(processChunk) for yielding. Microtasks run before the render step, so you'd still block rendering. You need a task (setTimeout, MessageChannel) to yield to the render pipeline. scheduler.yield() is the modern API for this — it creates a task that preserves priority.
The Render Step in Detail
The render step doesn't run on every event loop iteration. It runs approximately every 16.6ms (for a 60fps display), and only if:
- There are visual changes to process
- The document is visible (not in a background tab)
- The browser determines it's time based on the display refresh rate
When it does run, the order is:
- requestAnimationFrame callbacks — your chance to make DOM changes
- Style recalculation — compute final CSS values
- Layout — calculate geometry (widths, heights, positions)
- Paint — create the visual representation
- Composite — combine layers and send to the GPU
Why background tabs throttle timers
When a tab is hidden (document.visibilityState === 'hidden'), browsers aggressively throttle the event loop. setTimeout minimum delay increases to 1000ms. requestAnimationFrame doesn't fire at all. This saves CPU and battery. It also means you cannot rely on timer precision in background tabs — your "1 second" timer might fire after 10 seconds. Use the Page Visibility API to handle this correctly.
Common Mistakes
| What developers do | What they should do |
|---|---|
| Saying 'JavaScript has one task queue' The spec explicitly allows multiple queues. Browsers exploit this to keep input responsive even when timers are backed up. | The browser has multiple task queues with different priorities. User input typically gets priority over timers. |
| Thinking the event loop is part of JavaScript the language ECMAScript defines the job queue for Promises, but the full event loop with task queues, rendering, and I/O is the host's responsibility. | The event loop is defined by the HTML spec and implemented by the host environment (browser, Node.js). JavaScript the language has no concept of an event loop. |
| Using microtasks to yield to rendering Microtasks are higher priority than rendering. Scheduling work as microtasks is equivalent to synchronous execution from the rendering pipeline's perspective. | Use macrotasks (setTimeout, MessageChannel) or scheduler.yield() to yield. Microtasks drain before rendering. |
Challenge: Predict the Timing
Challenge: Event Loop Ordering
setTimeout(() => console.log('A'), 0);
Promise.resolve()
.then(() => console.log('B'))
.then(() => console.log('C'));
setTimeout(() => console.log('D'), 0);
console.log('E');
Show Answer
Output: E, B, C, D, A... wait, actually: E, B, C, A, D.
Step-by-step:
setTimeout Aregisters — adds callback to task queue (position 1)Promise.resolve().then(B)— B goes to microtask queuesetTimeout Dregisters — adds callback to task queue (position 2)console.log('E')runs synchronously. Output: E- Script task ends. Microtask checkpoint: run B. Output: E, B
- B's
.then(C)schedules C as a microtask. Still draining: run C. Output: E, B, C - Microtask queue empty. Pick next task: setTimeout A. Output: E, B, C, A
- Microtask checkpoint (nothing). Pick next task: setTimeout D. Output: E, B, C, A, D
Key insight: both .then() callbacks run during a single microtask checkpoint because the second .then() is scheduled by the first microtask, and the checkpoint drains completely.
Key Rules
- 1The event loop runs: pick task -> execute -> drain ALL microtasks -> maybe render -> repeat. This order is inviolable.
- 2Microtasks always drain completely between tasks. A microtask scheduling another microtask extends the checkpoint — both run before the next task.
- 3The browser has multiple task queues with different priorities. User input events generally outrank timer callbacks.
- 4Rendering happens approximately every 16.6ms (60fps), not after every task. The browser decides when to render based on visibility, display refresh rate, and whether there are visual changes.
- 5To yield to rendering from a long computation, use setTimeout or MessageChannel (macrotasks). Microtasks and Promises do NOT yield to rendering.