Vikram D.

The JavaScript Event Loop Explained

2026-01-0412 min read

JavaScript is single-threaded. It can only execute one piece of code at a time. Yet it handles thousands of asynchronous operations-network requests, timers, user events-without blocking. The mechanism that makes this possible is the Event Loop.

Core Components

The Event Loop relies on several components working together:

  1. Call Stack – Executes synchronous code
  2. Web APIs – Handles async operations (timers, fetch, DOM events)
  3. Callback Queue (Task Queue) – Holds callbacks from Web APIs
  4. Microtask Queue – Holds promise callbacks and queueMicrotask
  5. Event Loop – Moves tasks from queues to the call stack

The Call Stack

The call stack is where JavaScript executes functions. When you call a function, it's pushed onto the stack. When it returns, it's popped off.

function first() {
  console.log('first');
}

function second() {
  first();
  console.log('second');
}

second();

Execution order:

  1. second() is pushed onto the stack
  2. first() is called, pushed onto the stack
  3. console.log('first') executes, first() pops off
  4. console.log('second') executes, second() pops off
  5. Stack is empty

Web APIs

When you call an async function like setTimeout, fetch, or add an event listener, JavaScript hands it off to the browser's Web APIs. These run outside the JavaScript engine.

console.log('Start');

setTimeout(() => {
  console.log('Timeout');
}, 0);

console.log('End');

// Output:
// Start
// End
// Timeout

Even with a 0ms delay, setTimeout goes through the Web API and callback queue, so it runs after the synchronous code.

The Callback Queue (Task Queue)

When a Web API completes (timer fires, fetch returns, user clicks), the callback is placed in the Callback Queue (also called Task Queue or Macrotask Queue).

The Event Loop checks: "Is the call stack empty?" If yes, it takes the first callback from the queue and pushes it onto the stack for execution.

setTimeout(() => console.log('Task 1'), 0);
setTimeout(() => console.log('Task 2'), 0);

// Output:
// Task 1
// Task 2 (in order)

The Microtask Queue

Microtasks have higher priority than regular tasks. They include:

  • Promise .then(), .catch(), .finally() callbacks
  • queueMicrotask()
  • MutationObserver callbacks

Rule: All microtasks are executed before the next task from the callback queue.

console.log('Start');

setTimeout(() => console.log('Timeout'), 0);

Promise.resolve().then(() => console.log('Promise'));

console.log('End');

// Output:
// Start
// End
// Promise
// Timeout

The promise callback runs before the setTimeout callback because microtasks are processed first.

Event Loop Algorithm

Here's how the Event Loop works, step by step:

  1. Execute all synchronous code in the call stack
  2. When the stack is empty, process all microtasks
  3. Render the page (if needed) – style calculations, layout, paint
  4. Take one task from the callback queue
  5. Execute that task
  6. Repeat from step 2

Why Only ONE Task Per Loop?

You might wonder: why does the Event Loop process ALL microtasks but only ONE macrotask per iteration?

The answer: rendering and responsiveness.

If the Event Loop processed all macrotasks before rendering, a burst of setTimeout callbacks could block the page for seconds:

// If all tasks ran before render, this would freeze the page
for (let i = 0; i < 1000; i++) {
  setTimeout(() => heavyComputation(), 0);
}

By executing only one task per iteration, the browser gets a chance to:

  • Check if a render is needed
  • Process user input (clicks, typing)
  • Keep the page responsive

Microtasks are different. They're meant for short, promise-related work, and they must complete before the current "logical operation" finishes. If you create an infinite loop of microtasks, you will block the page.

The render step is important: the browser checks if a repaint is needed (typically at 60fps, every ~16ms). This is why requestAnimationFrame callbacks run before the next paint.

Common Patterns and Examples

Mixing Promises and setTimeout

console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve()
  .then(() => console.log('3'))
  .then(() => console.log('4'));

console.log('5');

// Output:
// 1
// 5
// 3
// 4
// 2

Explanation:

  1. console.log('1') – sync, runs immediately
  2. setTimeout – scheduled as task
  3. Promise .then() – scheduled as microtask
  4. console.log('5') – sync, runs immediately
  5. Stack empty → process microtasks: 3, then 4
  6. Microtasks done → process task: 2

Nested Microtasks

Promise.resolve().then(() => {
  console.log('Promise 1');
  Promise.resolve().then(() => {
    console.log('Promise 2');
  });
});

setTimeout(() => console.log('Timeout'), 0);

// Output:
// Promise 1
// Promise 2
// Timeout

The nested promise is a new microtask, so it runs before the setTimeout.

queueMicrotask()

console.log('Start');

queueMicrotask(() => console.log('Microtask'));
setTimeout(() => console.log('Timeout'), 0);

console.log('End');

// Output:
// Start
// End
// Microtask
// Timeout

queueMicrotask() adds a microtask directly, without creating a promise.

async/await and the Event Loop

async/await is syntactic sugar over promises. When you await, the function pauses and the code after await becomes a microtask.

async function example() {
  console.log('1');
  await Promise.resolve();
  console.log('2'); // This becomes a microtask
}

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

// Output:
// A
// 1
// B
// 2

Explanation:

  1. console.log('A') – sync
  2. example() called, console.log('1') – sync
  3. await pauses example(), rest becomes microtask
  4. console.log('B') – sync
  5. Stack empty → execute microtask: console.log('2')

Blocking the Event Loop

Since JavaScript is single-threaded, long-running synchronous code blocks everything:

// This blocks the entire page for 5 seconds
const start = Date.now();
while (Date.now() - start < 5000) {
  // Blocking loop
}
console.log('Done');

During this time:

  • No user events are processed
  • No timers fire
  • The page is unresponsive

Solution: Break work into smaller chunks or use Web Workers.

// Non-blocking approach
function processChunk(items, index = 0) {
  const chunkSize = 100;
  const end = Math.min(index + chunkSize, items.length);
  
  for (let i = index; i < end; i++) {
    // Process item
  }
  
  if (end < items.length) {
    setTimeout(() => processChunk(items, end), 0);
  }
}

Node.js Event Loop Differences

Node.js has a similar but more complex event loop with additional phases:

  1. Timers – setTimeout, setInterval
  2. Pending callbacks – I/O callbacks
  3. Idle, prepare – internal use
  4. Poll – retrieve new I/O events
  5. Check – setImmediate callbacks
  6. Close callbacks – socket.on('close')

Node.js also has process.nextTick(), which runs before any other microtasks:

Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));

// Output:
// nextTick
// Promise

Interview Question: What is the order of execution for setTimeout, Promise, and process.nextTick in Node.js?

Answer: process.nextTick runs first (before microtasks), then Promise callbacks (microtasks), then setTimeout (macrotask). The order is: nextTick → Promise → setTimeout.

Common Interview Questions

Interview Question: What is the difference between the Callback Queue and the Microtask Queue?

Answer: The Microtask Queue has higher priority. After the call stack is empty, ALL microtasks are processed before the Event Loop takes even ONE task from the Callback Queue. Microtasks include Promise callbacks and queueMicrotask. The Callback Queue holds setTimeout, setInterval, and I/O callbacks.

Interview Question: Why does a setTimeout with 0ms delay not execute immediately?

Answer: setTimeout(fn, 0) schedules the callback as a task in the Callback Queue. Even with 0ms, the callback must wait for: (1) the call stack to be empty, (2) all synchronous code to finish, and (3) all microtasks to be processed. Only then does the Event Loop pick up the setTimeout callback.

Interview Question: Can you block the Event Loop? What are the consequences?

Answer: Yes, any long-running synchronous code blocks the Event Loop. Consequences: the page becomes unresponsive, user events aren't processed, animations freeze, and timers are delayed. To avoid this, break heavy computation into chunks using setTimeout or requestAnimationFrame, or move work to a Web Worker.

Summary

ComponentDescriptionPriority
Call StackExecutes sync codeImmediate
Microtask QueuePromise callbacks, queueMicrotaskHigh
Callback QueuesetTimeout, I/O, eventsNormal

Key rules:

  1. Synchronous code runs first
  2. When the stack is empty, ALL microtasks run
  3. Then ONE task from the callback queue runs
  4. Repeat

Understanding the Event Loop is essential for writing performant, non-blocking JavaScript code.

Loading comments...