JavaScript is inherently single-threaded, meaning it can only execute one operation at a time. Despite this architectural constraint, modern web applications seamlessly fetch background data, process complex user interactions, and render buttery-smooth 60fps animations—all without locking up the main thread.

How does a single thread orchestrate such complex multitasking?

The answer lies in the Event Loop, a sophisticated scheduling mechanism that manages execution across four distinct phases: the Call Stack, the Microtask Queue, the Macrotask Queue, and the Render Queue.

In this post, we’ll demystify the internal rules of the Event Loop. We'll explore exactly how the browser prioritizes tasks, dissect common interview riddles, and experiment with an interactive, real-time queue simulator.


The Three Queues: Who Goes First?

When asynchronous JavaScript code is triggered (like a resolved promise or a finished timer), it doesn't run immediately. Instead, it gets pushed to a queue. The Event Loop's job is simple: look at the Call Stack, wait for it to be completely empty, and then push callbacks from the queues onto the stack in a strict order of priority.

Here is the priority hierarchy:

┌──────────────────────────────────────────────┐
│           Call Stack (Synchronous)           │ <-- Run until empty
└──────────────────────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────┐
│       1. Microtask Queue (High Priority)     │ <-- Run ALL pending tasks
└──────────────────────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────┐
│       2. Render Queue (Medium Priority)      │ <-- Run rAF, recalculate, paint
└──────────────────────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────┐
│       3. Macrotask Queue (Low Priority)      │ <-- Run ONE task per tick
└──────────────────────────────────────────────┘
  1. Microtasks (Promise.then, queueMicrotask, MutationObserver): The highest priority. As soon as the call stack clears, the Event Loop must empty the entire Microtask Queue before doing anything else—even if new microtasks are enqueued while processing!
  2. Render Tasks (requestAnimationFrame, Style/Layout reflows, Painting): Checked before drawing the next frame (typically every 16.7ms for a 60Hz display). (Note: requestAnimationFrame callbacks run before the browser's rendering steps, not as part of the same queue. The "Render Queue" is a useful mental model for the "update the rendering" phase.)
  3. Macrotasks (or just Tasks: setTimeout, setInterval, network requests, user inputs): The lowest priority. The Event Loop executes exactly one macrotask, then immediately yields control to check if any microtasks or render tasks are waiting.

Here is a reference of the key methods and APIs belonging to each queue:

Queue Priority Associated Methods / APIs
Microtask Queue High (Runs until empty) Promise.then() / catch() / finally(), queueMicrotask(), MutationObserver, process.nextTick() (Node.js)
Render Queue Medium (Before repaint) requestAnimationFrame(), Style recalculations, Layout reflow, Painting
Macrotask Queue Low (Runs 1 per tick) setTimeout(), setInterval(), setImmediate() (Node.js), requestIdleCallback(), UI Events (click, keypress, etc.), network/fetch callbacks

💡 queueMicrotask vs Promise.resolve().then()
While both schedule a microtask, queueMicrotask(fn) is the purpose-built API. It's more explicit and slightly more efficient since it doesn't create an intermediate Promise object.

⚠️ Browser vs. Node.js
This post describes the browser event loop. Node.js uses libuv with its own distinct phases (timers → pending → idle → poll → check → close). There is no render queue in Node. APIs like process.nextTick() and setImmediate() are Node-specific.


Interactive Event Loop Simulator

Use the controls below to enqueue tasks and watch the Event Loop fetch, evaluate, and clear each queue in real time.

  • Click Step to execute a single cycle step.
  • Click Run to let it process automatically.
Interactive Event Loop Simulator

Console Log Output
[System]: Ready. Click buttons to enqueue operations...

Execution Examples: Test Your Intuition

Let's look at some real examples to see this execution model in action.

Example 1: The Classic Interview Riddle

What is the console print order for the following code?

console.log("1. Start");

setTimeout(() => {
  console.log("2. setTimeout");
}, 0);

Promise.resolve().then(() => {
  console.log("3. Promise");
});

console.log("4. End");

How the Event Loop evaluates this:

  1. Synchronous Stack: Prints 1. Start and 4. End.
  2. Enqueued: setTimeout callback goes to the Macrotask Queue. Promise callback goes to the Microtask Queue.
  3. Microtask Phase: Call stack clears. The loop checks Microtasks first, finds the promise callback, and prints 3. Promise.
  4. Macrotask Phase: Microtask queue is empty. The loop moves to the Macrotask Queue, finds the timer callback, and prints 2. setTimeout.

Final Output:

1. Start
4. End
3. Promise
2. setTimeout

Example 2: Nested Microtasks

What happens when a Microtask schedules another Microtask?

console.log("1. Start");

setTimeout(() => {
  console.log("2. setTimeout");
}, 0);

Promise.resolve().then(() => {
  console.log("3. Microtask A");
  
  Promise.resolve().then(() => {
    console.log("4. Microtask B");
  });
});

console.log("5. End");

How the Event Loop evaluates this:

  1. Prints 1. Start and 5. End synchronously.
  2. The call stack clears, triggering the Microtask phase.
  3. Microtask A runs and prints 3. Microtask A. During its run, it enqueues Microtask B.
  4. The Event Loop checks if the Microtask queue is empty. It is not because Microtask B is now sitting in it.
  5. The loop immediately runs Microtask B and prints 4. Microtask B.
  6. Only when the Microtask queue is completely clear does the loop move to the Macrotask queue to run setTimeout, printing 2. setTimeout.

Final Output:

1. Start
5. End
3. Microtask A
4. Microtask B
2. setTimeout

Example 3: The Infinite Loop Contrast

What is the difference between a recursive Promise loop and a recursive setTimeout loop?

The Microtask Loop (Tab Crasher):

function loop() {
  Promise.resolve().then(loop);
}
loop();
  • Result: Freezes your browser tab. Because the Event Loop will not stop until the Microtask queue is completely empty, calling loop recursively inside a microtask constantly appends a new microtask. The Event Loop remains trapped in the microtask phase forever. It never yields to the render phase or macrotask phase, making the tab unresponsive.

The Macrotask Loop (Safe Loop):

function loop() {
  setTimeout(loop, 0);
}
loop();
  • Result: Runs forever without freezing the browser. Because the Event Loop only executes one macrotask per tick, it will run loop, schedule a new setTimeout in the macrotask queue, and then immediately check for microtasks and render tasks. It yields control back to the browser to paint and respond to user actions before running the next scheduled setTimeout frame.

Example 4: How async/await Maps to the Event Loop

What happens when you use async/await instead of raw Promises?

async function fetchData() {
  console.log("1. Before await");
  const data = await Promise.resolve("done");
  console.log("2. After await:", data);
}

console.log("3. Script start");
fetchData();
console.log("4. Script end");

How the Event Loop evaluates this:

Under the hood, await splits the function. Everything before the await runs synchronously. Everything after the await is wrapped in a .then() and pushed to the Microtask Queue.

Final Output:

3. Script start
1. Before await
4. Script end
2. After await: done

Example 5: Real-World UI Batching

Why do we sometimes see loading states glitch or not appear at all if we block the thread?

button.addEventListener("click", () => {
  // These 3 state changes happen synchronously
  element.textContent = "Loading...";
  element.style.opacity = "0.5";
  element.classList.add("spinner");

  // The browser hasn't painted yet!
  // All 3 changes are batched into ONE repaint

  Promise.resolve().then(() => {
    console.log("Microtask: still no paint yet!");
  });

  // Paint happens AFTER this callback + microtasks finish
});

Because the click handler is a Macrotask, the browser executes the entire function, then clears all Microtasks, and only then proceeds to the Render Queue. It batches all DOM mutations together into a single repaint, preventing intermediate flickering.


Summary

Understanding the Event Loop is fundamentally about knowing how JavaScript schedules your code. To recap:

  • The Call Stack executes synchronous code immediately.
  • The Microtask Queue (Promises, queueMicrotask) runs as soon as the stack clears, and must empty completely before anything else happens.
  • The Render Queue (requestAnimationFrame, style recalculations) gives you a chance to update the DOM right before the browser paints the next frame.
  • The Macrotask Queue (setTimeout, clicks, network callbacks) runs exactly one task per cycle, yielding control back to the browser to keep the UI responsive.

The next time you encounter a cryptic rendering glitch or a frozen browser tab, remember: you're not just writing code, you're negotiating with the Event Loop.