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.

JavaScriptintermediate
13 min read

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:

ComponentWhat It IsWho Manages It
Call StackActive execution contexts, LIFOJavaScript engine (V8, SpiderMonkey)
Web APIssetTimeout, fetch, DOM events, etc.Browser / Node.js runtime
Microtask QueuePromise callbacks, queueMicrotask()JavaScript engine
Task Queue (Macrotask Queue)setTimeout, setInterval, DOM eventsBrowser / Node.js runtime
javascriptjavascript
// 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:

CodeCode
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

javascriptjavascript
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: Sync

Step-by-Step Execution

StepEvent Loop ActionOutput
1Call stack: console.log("A")A
2Web API: Register setTimeout with 0ms-
3Promise: .then(C) added to microtask queue-
4queueMicrotask(E) added to microtask queue-
5Call stack: console.log("F")F
6Stack empty. Process microtasks: run CC
7C's .then(D) is added. Run EE
8Run D (was added in step 7)D
9Microtasks empty. Pick ONE task: run BB

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:

javascriptjavascript
// 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

SourceQueue Type
Promise.then() / .catch() / .finally()Microtask
async/await (resumes after await)Microtask
queueMicrotask()Microtask
MutationObserver callbacksMicrotask
setTimeoutMacrotask
setIntervalMacrotask
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:

javascriptjavascript
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, C

Multiple Awaits

javascriptjavascript
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:

CodeCode
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:

javascriptjavascript
// 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:

CodeCode
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'))
javascriptjavascript
// 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 first

Visualizing the Event Loop

javascriptjavascript
// 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:

javascriptjavascript
// 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

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() and queueMicrotask() callbacks run to completion before any setTimeout or DOM event callback
  • async/await is microtask-based: Each await suspends 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
RunePowered by Rune AI

Frequently Asked Questions

Why do Promise callbacks run before setTimeout callbacks?

Promises use the microtask queue, which is drained completely before the event loop picks up the next macrotask from the task queue. This is by design: microtasks are meant for short continuations that logically belong to the current task. `setTimeout` is a macrotask that represents a new, separate unit of work scheduled for a future iteration of the loop.

Does each browser tab have its own event loop?

Yes. Each browser tab (and web worker) has its own event loop, [call stack](/tutorials/programming-languages/javascript/understanding-the-javascript-call-stack-guide), and task queues. Tabs cannot directly block each other's event loops. However, a single service worker has its own event loop that is shared across tabs using it.

What does it mean for the event loop to be "blocked"?

The event loop is blocked when synchronous code runs for so long that the event loop cannot complete a cycle. No microtasks, no rendering, no macrotasks can run during this time. Users see unresponsive UI. Anything over ~16ms is potentially noticeable (one frame at 60fps). Use Web Workers for CPU-intensive work to keep the event loop free.

Is the Promise constructor callback synchronous?

Yes. The function passed to `new Promise((resolve, reject) => {...})` runs synchronously โ€” the executor runs immediately, inline, just like any other synchronous code. Only the `.then()` and `.catch()` callbacks are asynchronous (scheduled as microtasks). This is a common source of confusion.

How does async/await fit into the event loop?

`await` pauses the async function and schedules the rest as a microtask to run when the awaited value resolves. Each `await` creates one microtask handoff. The caller of the async function receives a pending Promise immediately and continues executing synchronously. This is why code after an `await` (including in the same async function) always runs after the current synchronous code completes.

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.