The JavaScript Event Loop Explained in Detail
Master the JavaScript event loop. Learn how the call stack, Web APIs, microtask queue, and task queue work together to make asynchronous JavaScript possible without multiple threads.
JavaScript runs on a single thread. Yet it handles HTTP requests, file reads, UI events, and timers concurrently without blocking. This is the event loop: a continuous cycle that coordinates the call stack, the browser's Web APIs, and two task queues to give JavaScript its non-blocking character. Understanding it explains every surprising async behavior you've encountered.
The Components
The event loop connects four parts:
| Component | What It Is | Who Manages It |
|---|---|---|
| Call Stack | Active execution contexts, LIFO | JavaScript engine (V8, SpiderMonkey) |
| Web APIs | setTimeout, fetch, DOM events, etc. | Browser / Node.js runtime |
| Microtask Queue | Promise callbacks, queueMicrotask() | JavaScript engine |
| Task Queue (Macrotask Queue) | setTimeout, setInterval, DOM events | Browser / Node.js runtime |
// This single code snippet uses all four components
console.log("1: Call stack (sync)");
setTimeout(() => {
console.log("4: Task queue (setTimeout)");
}, 0);
fetch("/api").then(() => {
console.log("3: Microtask queue (Promise)");
});
console.log("2: Call stack (sync)");
// Output:
// 1: Call stack (sync)
// 2: Call stack (sync)
// 3: Microtask queue (Promise) -- runs before setTimeout!
// 4: Task queue (setTimeout)The Event Loop Algorithm
The event loop runs a loop that repeats forever:
while (true) {
// Step 1: Run all synchronous code on the call stack until empty
runCallStack();
// Step 2: Process ALL microtasks until microtask queue is empty
// (New microtasks added during this step are also processed)
while (microtaskQueue.isNotEmpty()) {
microtask = microtaskQueue.dequeue();
execute(microtask);
}
// Step 3: Render updates if needed (browser only)
updateRendering();
// Step 4: Take ONE task from the task queue (if any)
if (taskQueue.isNotEmpty()) {
task = taskQueue.dequeue();
execute(task); // This may add new things to call stack
}
// Go back to step 1
}
The order is always: Sync code → All microtasks → (render) → One macrotask → Repeat
Walking Through an Example
console.log("A"); // 1: Sync
setTimeout(() => console.log("B"), 0); // Web API -> task queue
Promise.resolve()
.then(() => console.log("C")) // -> microtask queue
.then(() => console.log("D")); // -> microtask queue (added when C runs)
queueMicrotask(() => console.log("E")); // -> microtask queue
console.log("F"); // 2: SyncStep-by-Step Execution
| Step | Event Loop Action | Output |
|---|---|---|
| 1 | Call stack: console.log("A") | A |
| 2 | Web API: Register setTimeout with 0ms | - |
| 3 | Promise: .then(C) added to microtask queue | - |
| 4 | queueMicrotask(E) added to microtask queue | - |
| 5 | Call stack: console.log("F") | F |
| 6 | Stack empty. Process microtasks: run C | C |
| 7 | C's .then(D) is added. Run E | E |
| 8 | Run D (was added in step 7) | D |
| 9 | Microtasks empty. Pick ONE task: run B | B |
Final output: A, F, C, E, D, B
Microtask Queue Starvation
Microtasks must ALL drain before any macrotask runs. This means a microtask that continuously adds more microtasks can block macrotasks (and rendering) indefinitely:
// DANGEROUS: Infinite microtask loop — blocks the event loop
function recurse() {
Promise.resolve().then(recurse);
}
recurse();
// setTimeout below NEVER fires because microtask queue never empties
setTimeout(() => console.log("Never"), 0);This is why infinite recursive microtasks are a critical bug. Compare with recursive setTimeout, which would only block after each macrotask cycle.
Task vs Microtask Sources
| Source | Queue Type |
|---|---|
Promise.then() / .catch() / .finally() | Microtask |
async/await (resumes after await) | Microtask |
queueMicrotask() | Microtask |
MutationObserver callbacks | Microtask |
setTimeout | Macrotask |
setInterval | Macrotask |
setImmediate (Node.js only) | Macrotask (special) |
DOM events (click, keydown, etc.) | Macrotask |
requestAnimationFrame (browser only) | Special (before render) |
Async/Await and the Event Loop
async/await is syntactic sugar over Promises. After each await, the remainder of the async function is a microtask:
async function main() {
console.log("A"); // Sync
await Promise.resolve(); // Suspend here, add continuation as microtask
console.log("B"); // Runs as microtask after "D"
await Promise.resolve();
console.log("C"); // Runs as second microtask
}
main();
console.log("D"); // Sync, runs before B
// Output: A, D, B, CMultiple Awaits
async function fetchUser() {
console.log("1: Start fetch");
const res = await fetch("/user"); // Suspends here
console.log("3: Got response"); // Microtask when fetch resolves
const data = await res.json(); // Suspends again
console.log("4: Parsed data"); // Microtask when json() resolves
return data;
}
console.log("0: Before async call");
fetchUser();
console.log("2: After async call (sync)");
// 0: Before async call
// 1: Start fetch
// 2: After async call (sync)
// 3: Got response (after network, as microtask)
// 4: Parsed data (after JSON parse, as microtask)Rendering and the Event Loop
In browsers, rendering (style calculation, layout, paint) happens in a specific slot in the event loop:
1. Run call stack to empty
2. Drain microtask queue
3. >>> Rendering checkpoint <<<
- requestAnimationFrame callbacks
- Style recalculation
- Layout
- Paint
4. One macrotask from task queue
This is why DOM updates from synchronous code appear simultaneously — the browser doesn't render between individual JS assignments:
// These three DOM mutations all happen in one frame
element.style.width = "100px";
element.style.height = "100px";
element.style.background = "red";
// Browser doesn't repaint between these lines
// All changes are batched into one render cycle
// BUTs:
element.offsetHeight; // FORCED REFLOW — reads layout, forces immediate recalculation
element.style.width = "200px"; // Now triggers TWO layouts (expensive)Node.js Event Loop Differences
Node.js has a more granular event loop with distinct phases:
Node.js Event Loop Phases:
1. timers - setTimeout, setInterval callbacks
2. pending I/O - I/O callbacks deferred from previous iteration
3. idle/prepare - Internal use
4. poll - Retrieve new I/O events
5. check - setImmediate callbacks
6. close callbacks - Close handlers (e.g. socket.on('close'))
// Node.js specific ordering
const { setImmediate } = require('timers');
setTimeout(() => console.log("setTimeout"), 0);
setImmediate(() => console.log("setImmediate")); // Runs in 'check' phase
// In Node.js, the order of setTimeout(fn, 0) vs setImmediate is non-deterministic
// from the main module. Inside an I/O callback, setImmediate always runs first
process.nextTick(() => console.log("nextTick")); // Like queueMicrotask, runs firstVisualizing the Event Loop
// Full demonstration
console.log("[1] Sync start");
setTimeout(() => console.log("[6] Timeout 1"), 0);
setTimeout(() => console.log("[7] Timeout 2"), 0);
new Promise((resolve) => {
console.log("[2] Promise executor (sync!)");
resolve();
})
.then(() => {
console.log("[4] Promise then 1");
queueMicrotask(() => console.log("[5] Nested microtask"));
});
console.log("[3] Sync end");
// Execution order:
// [1] Sync start
// [2] Promise executor (sync!) -- executor runs synchronously!
// [3] Sync end
// [4] Promise then 1 -- microtask
// [5] Nested microtask -- microtask added during microtask drain
// [6] Timeout 1 -- first macrotask
// [7] Timeout 2 -- second macrotask (next iteration)Long Tasks and Rendering Jank
Spending too long in a single task blocks rendering:
// BAD: One 500ms task freezes the UI
function expensiveOperation() {
const start = Date.now();
while (Date.now() - start < 500) {} // Block for 500ms
console.log("Done");
}
expensiveOperation();
// UI is frozen for 500ms
// GOOD: Break work into chunks using setTimeout to yield to rendering
function expensiveInChunks(items) {
const CHUNK_SIZE = 100;
let index = 0;
function processChunk() {
const end = Math.min(index + CHUNK_SIZE, items.length);
for (; index < end; index++) {
processItem(items[index]);
}
if (index < items.length) {
setTimeout(processChunk, 0); // Yield — allows rendering and other tasks
}
}
processChunk();
}Rune AI
Key Insights
- Call stack is synchronous: JavaScript executes one thing at a time on the call stack; the event loop makes async work possible by deferring callbacks to queues
- Microtasks drain before macrotasks: All Promise
.then()andqueueMicrotask()callbacks run to completion before anysetTimeoutor DOM event callback - async/await is microtask-based: Each
awaitsuspends the async function and schedules its continuation as a microtask, running after current sync code - Rendering waits for microtasks: The browser only repaints after the microtask queue is empty, making DOM updates batched and efficient
- Long sync tasks block the loop: Avoid synchronous operations over ~16ms on the main thread; break them into chunks or use Web Workers
Frequently Asked Questions
Why do Promise callbacks run before setTimeout callbacks?
Does each browser tab have its own event loop?
What does it mean for the event loop to be "blocked"?
Is the Promise constructor callback synchronous?
How does async/await fit into the event loop?
Conclusion
The event loop continuously processes the call stack, drains all microtasks, renders if needed, then picks one macrotask to process. Microtasks (Promises, queueMicrotask) always run before macrotasks (setTimeout, setInterval, DOM events). JavaScript's single-threaded model is only possible because the event loop handles async operations through queues rather than true concurrency.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.