Event Loop

Hard

The Event Loop is how JavaScript handles asynchronous operations despite being single-threaded. It continuously checks if the call stack is empty, then moves callbacks from the task queues to the stack for execution.

Interactive Visualization

Call Stack
<script>
Web APIs
fetch
setTimeout
URL
localStorage
sessionStorage
HTMLDivElement
document
indexedDB
XMLHttpRequest
Many more...
Event Loop
Task Queue
(empty)
Microtask Queue
(empty)
CodeSync
1console.log('1');
2
3setTimeout(() => {
4 console.log('timeout');
5}, 0);
6
7Promise.resolve()
8 .then(() => console.log('promise'));
9
10console.log('2');
Output
Script starts executing. Global EC pushed to call stack.
1 / 10
Key Insight: Microtasks (Promises) always run before macrotasks (setTimeout), even with 0ms delay!

Understanding Event Loop

The JavaScript event loop is the mechanism that allows JavaScript to perform non-blocking asynchronous operations despite being a single-threaded language. Understanding the event loop is essential for writing performant JavaScript and is one of the most common topics in senior-level interviews.

JavaScript has a single call stack, meaning it can only execute one piece of code at a time. When you call a function, it gets pushed onto the stack. When it returns, it gets popped off. Synchronous code runs entirely on this stack, one function after another. But what happens when you need to wait for a network request, a timer, or user input? This is where the event loop comes in.

When you call an asynchronous API like setTimeout or fetch, the browser delegates that work to its Web APIs layer, which runs outside the JavaScript engine. The call stack continues executing the next line of code without waiting. When the async operation completes, its callback is placed into a task queue. The event loop continuously checks: is the call stack empty? If so, it takes the first callback from the queue and pushes it onto the stack for execution.

There are actually two types of queues with different priorities. The microtask queue handles Promise callbacks, queueMicrotask, and MutationObserver callbacks. The macrotask queue (also called the task queue) handles setTimeout, setInterval, and I/O callbacks. The critical rule is that all microtasks drain completely before the event loop processes the next macrotask. This is why a resolved Promise callback always executes before a setTimeout with a delay of zero — even though both are asynchronous.

This priority difference explains a subtle but important behavior. When you write setTimeout(fn, 0), you might expect fn to run immediately, but it does not. The zero-millisecond delay is a minimum, not a guarantee. The callback must wait for the current call stack to empty and for all pending microtasks to complete before it can run.

One danger to be aware of is microtask starvation. If a microtask keeps scheduling more microtasks recursively, the microtask queue never empties and macrotasks never get a chance to run. This effectively freezes the browser because rendering updates are also scheduled as tasks that the event loop must reach.

The event loop also controls when the browser can repaint the screen. Between processing tasks, the browser checks if a repaint is needed and runs requestAnimationFrame callbacks. Long-running synchronous code blocks the event loop and prevents repaints, which is why heavy computation should be offloaded to Web Workers or broken into smaller chunks using techniques like setTimeout chunking.

Key Points

  • JavaScript is single-threaded (one call stack)
  • Web APIs handle async operations (setTimeout, fetch, etc.)
  • Task Queue (Macrotasks): setTimeout, setInterval, I/O
  • Microtask Queue: Promises, queueMicrotask, MutationObserver
  • Microtasks run before the next macrotask
  • Event loop: Stack empty? → Run all microtasks → Run one macrotask → Repeat

Code Examples

Promise vs setTimeout

console.log("1");

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

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

console.log("4");

// Output: 1, 4, 3, 2
// Microtasks before macrotasks!

Microtasks (Promise) run before macrotasks (setTimeout)

Sync Code Flow

function greet() {
  console.log("Hello");
}

console.log("Start");
greet();
console.log("End");

// Output: Start, Hello, End
// Synchronous = line by line

Synchronous code executes line by line on call stack

Chained Promises

Promise.resolve(1)
  .then(x => {
    console.log(x);  // 1
    return x + 1;
  })
  .then(x => {
    console.log(x);  // 2
    return x + 1;
  })
  .then(x => {
    console.log(x);  // 3
  });

// Each .then queues a microtask
// when the previous resolves

Each .then queues when previous resolves

async/await

async function example() {
  console.log("1");

  await Promise.resolve();
  // Everything after await becomes
  // a microtask

  console.log("2");
}

console.log("A");
example();
console.log("B");

// Output: A, 1, B, 2

await pauses function, queues continuation as microtask

Nested setTimeout

setTimeout(() => {
  console.log("First macrotask");

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

}, 0);

console.log("Sync");

// Output: Sync, First, Second
// Each setTimeout queues one
// macrotask for next loop cycle

Nested creates new macrotask for next iteration

Microtask in Macrotask

setTimeout(() => {
  console.log("Macro 1");

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

  console.log("Macro 1 end");
}, 0);

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

// Output: Macro 1, Macro 1 end,
//         Micro inside, Macro 2

Microtasks created during macrotask run before next macrotask

queueMicrotask

queueMicrotask(() => {
  console.log("Microtask 1");

  queueMicrotask(() => {
    console.log("Microtask 2");
  });
});

console.log("Sync");

// Output: Sync, Microtask 1, Microtask 2
// All microtasks drain before
// any macrotask runs

Direct microtask scheduling, can queue more microtasks

Microtask Starvation

// DANGER: This blocks forever!
function recursive() {
  Promise.resolve().then(recursive);
}

recursive();
setTimeout(() => {
  console.log("Never runs!");
}, 0);

// Microtasks keep adding more
// macrotasks are STARVED

Infinite microtasks block all macrotasks forever!

Common Mistakes

  • Thinking setTimeout(fn, 0) runs immediately
  • Not understanding microtask priority over macrotasks
  • Blocking the event loop with long-running synchronous code

Interview Tips

  • Draw the event loop diagram (stack, queues, Web APIs)
  • Know the order: sync → microtasks → macrotasks
  • Explain why Promises are faster than setTimeout

Related Concepts