Skip to main content

JavaScript Event Loop - Complete Guide

Table of Contentsโ€‹

  1. What is the Event Loop?
  2. Why the Event Loop Exists
  3. Core Components
  4. Execution Order
  5. Simple Examples
  6. Microtask Queue Behavior
  7. Rendering and Event Loop
  8. setTimeout(fn, 0) - Common Misconception
  9. Event Loop in Node.js
  10. Starvation Problem
  11. Event Loop vs Execution Context
  12. Real-World React Relevance
  13. Common Interview Questions
  14. Mental Model
  15. Tricky Interview Snippets

What is the Event Loop?โ€‹

The Event Loop is the mechanism that allows JavaScript (which is single-threaded) to perform non-blocking asynchronous operations by coordinating the Call Stack, task queues, and host APIs (like browser APIs or Node.js APIs).

Key Point:โ€‹

JavaScript can only execute one thing at a time, but the Event Loop enables concurrent behavior through asynchronous callbacks.


Why the Event Loop Existsโ€‹

JavaScript has fundamental constraints:

  • One Call Stack - can only execute one function at a time
  • Single-threaded - no parallel execution of JavaScript code

However, browsers and Node.js must handle:

  • โฑ๏ธ Timers (setTimeout, setInterval)
  • ๐ŸŒ Network calls (fetch, AJAX)
  • ๐Ÿ–ฑ๏ธ DOM events (clicks, scrolls)
  • โœจ Promises
  • ๐Ÿ“ก I/O operations

Solution: The Event Loop makes asynchronous operations possible without blocking the main thread.


Core Componentsโ€‹

Understanding these components is essential for mastering the Event Loop:

1. Call Stackโ€‹

The Call Stack executes JavaScript execution contexts using a LIFO (Last In First Out) structure.

function a() { b(); }
function b() { console.log('b'); }
a();

Call Stack Visualization:

| b()  |  โ† Top (executes first)
| a() |
| GEC | โ† Bottom (Global Execution Context)

2. Web APIs (Browser Environment)โ€‹

These APIs are not part of the JavaScript engine but are provided by the host environment:

  • setTimeout / setInterval
  • fetch / XMLHttpRequest
  • DOM events (addEventListener)
  • requestAnimationFrame
  • MutationObserver

In Node.js: File system, network operations, timers, etc.

3. Task Queuesโ€‹

JavaScript has two types of task queues with different priorities:

(A) Macrotask Queue (Lower Priority)โ€‹

Also called the Task Queue or Callback Queue:

  • setTimeout
  • setInterval
  • setImmediate (Node.js only)
  • I/O callbacks
  • UI rendering tasks
  • User interaction events

(B) Microtask Queue (Higher Priority) ๐Ÿ”ฅโ€‹

Also called the Job Queue:

  • Promise.then / catch / finally
  • queueMicrotask()
  • MutationObserver
  • process.nextTick() (Node.js - highest priority)

4. Event Loopโ€‹

The Event Loop continuously monitors and coordinates:

  1. Is the Call Stack empty?
  2. Run ALL microtasks (drain the microtask queue)
  3. Take ONE macrotask from the macrotask queue
  4. Render (if needed in browser)
  5. Repeat

Execution Orderโ€‹

This is the MOST IMPORTANT concept to understand:

1. Synchronous Code (Call Stack)
โ†“
2. Microtask Queue (drain completely)
โ†“
3. Macrotask Queue (execute ONE task)
โ†“
4. Render (if needed)
โ†“
5. Repeat from step 2

Priority Hierarchy:โ€‹

Highest Priority:  Call Stack (synchronous code)
โ†“
High Priority: Microtasks (Promises)
โ†“
Low Priority: Macrotasks (setTimeout)
โ†“
Lowest: Rendering

Simple Examplesโ€‹

Example 1: Basic Event Loopโ€‹

console.log('A');

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

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

console.log('D');

Execution Steps:โ€‹

  1. 'A' โ†’ logged immediately (synchronous)
  2. setTimeout โ†’ callback sent to Macrotask Queue
  3. Promise.then โ†’ callback sent to Microtask Queue
  4. 'D' โ†’ logged immediately (synchronous)
  5. Call Stack empty โ†’ drain Microtask Queue โ†’ 'C' logged
  6. Take one Macrotask โ†’ 'B' logged

Output:โ€‹

A
D
C
B

Example 2: Multiple Promisesโ€‹

console.log('Start');

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

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

console.log('End');

Output:โ€‹

Start
End
Promise 1
Promise 2
Timeout

Why? All microtasks run before any macrotask.


Microtask Queue Behaviorโ€‹

Critical Rule: Microtasks Drain Completelyโ€‹

The microtask queue must be completely empty before moving to macrotasks. This can lead to interesting behavior:

setTimeout(() => console.log('timeout'));

Promise.resolve().then(() => {
console.log('promise');
Promise.resolve().then(() => console.log('inner promise'));
});

Output:โ€‹

promise
inner promise
timeout

Why?โ€‹

  1. First Promise.then runs (microtask)
  2. It logs 'promise'
  3. It creates another microtask ('inner promise')
  4. That new microtask runs immediately (queue must drain)
  5. Only then does setTimeout run (macrotask)

Trap Question:โ€‹

setTimeout(() => console.log('1'));

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

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

Output:โ€‹

2
4
1
3

Explanation:

  • All promises resolve first (microtasks)
  • Then first setTimeout runs (macrotask from beginning)
  • Then second setTimeout runs (macrotask added during microtask)

Rendering and Event Loopโ€‹

In browsers, rendering happens:

  • After a macrotask completes
  • After all microtasks are drained
  • Before the next macrotask

Example:โ€‹

const element = document.getElementById('box');

setTimeout(() => {
element.style.display = 'block';
// Rendering happens AFTER this callback finishes
}, 0);

Performance Implication:โ€‹

// BAD - Blocks rendering
for (let i = 0; i < 1000; i++) {
Promise.resolve().then(() => heavyCalculation());
}

// BETTER - Allows rendering between tasks
for (let i = 0; i < 1000; i++) {
setTimeout(() => heavyCalculation(), 0);
}

setTimeout(fn, 0) - Common Misconceptionโ€‹

Misconception: It runs immediatelyโ€‹

โŒ FALSE

Reality: It's still asynchronous and has delaysโ€‹

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

Output:

Start
End
Timeout

Why setTimeout(fn, 0) is NOT immediate:โ€‹

  1. Minimum delay - Browsers enforce a 4ms minimum delay (after 5 nested calls)
  2. Waits for Call Stack - Must wait for synchronous code to finish
  3. Waits for Microtasks - All promises must resolve first
  4. Queue position - Must wait for other macrotasks ahead of it

Event Loop in Node.jsโ€‹

Node.js has a more complex event loop with multiple phases:

Node.js Event Loop Phases:โ€‹

   โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”Œโ”€>โ”‚ timers โ”‚ (setTimeout, setInterval)
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ โ”‚ pending callbacks โ”‚ (I/O callbacks)
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ โ”‚ idle, prepare โ”‚ (internal use)
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ โ”‚ poll โ”‚ (retrieve new I/O events)
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ โ”‚ check โ”‚ (setImmediate)
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ””โ”€โ”€โ”‚ close callbacks โ”‚ (socket.on('close'))
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Node.js Priority Order:โ€‹

Highest:  process.nextTick()
โ†“
High: Promise (microtasks)
โ†“
Medium: setTimeout / setInterval
โ†“
Low: setImmediate

Node.js Example:โ€‹

setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));

Output:

nextTick
Promise
setTimeout
setImmediate

Starvation Problemโ€‹

What is Starvation?โ€‹

When microtasks keep adding more microtasks, preventing macrotasks from ever running.

Example:โ€‹

function recurse() {
Promise.resolve().then(recurse);
}
recurse();

// UI completely freezes! โŒ

Why This is Bad:โ€‹

  • Microtask queue never empties
  • Macrotasks never run
  • Browser cannot render
  • UI becomes completely unresponsive

Solution:โ€‹

function recurse() {
setTimeout(recurse, 0); // Use macrotask instead
}
recurse();

// UI can still render between tasks โœ…

Event Loop vs Execution Contextโ€‹

These are different but related concepts:

ConceptRoleStorageLifetime
Execution ContextRuns codeCall StackTemporary (popped after execution)
Call StackStores execution contextsMemoryCleared when empty
Event LoopSchedules tasksCoordinates queuesRuns continuously

Relationship:โ€‹

Event Loop โ†’ Pushes callbacks to Call Stack
Call Stack โ†’ Executes code in Execution Contexts
Execution Context โ†’ Runs JavaScript code

Real-World React Relevanceโ€‹

Understanding the Event Loop is crucial for React performance:

1. State Updates are Batchedโ€‹

function handleClick() {
setState1(value1); // Batched
setState2(value2); // Batched
setState3(value3); // Batched
// Single re-render after all updates
}

2. useEffect Runs in Microtasksโ€‹

useEffect(() => {
// This runs as a microtask after render
console.log('Effect runs');
}, []);

3. Long Microtasks Delay Paintโ€‹

// BAD - Blocks rendering
Promise.resolve().then(() => {
for (let i = 0; i < 1000000; i++) {
// Heavy computation
}
});

4. Heavy Synchronous Work Blocks Renderingโ€‹

// BAD - Freezes UI
function handleClick() {
for (let i = 0; i < 1000000; i++) {
// Blocks the main thread
}
}

// BETTER - Break into chunks
function handleClick() {
let i = 0;
function chunk() {
for (let j = 0; j < 1000 && i < 1000000; j++, i++) {
// Process small chunk
}
if (i < 1000000) {
setTimeout(chunk, 0); // Allow rendering between chunks
}
}
chunk();
}

Common Interview Questionsโ€‹

Q: Are Promises asynchronous?โ€‹

โŒ No - The Promise constructor executes synchronously

โœ… Yes - Their .then(), .catch(), .finally() callbacks are asynchronous (microtasks)

console.log('1');
new Promise((resolve) => {
console.log('2'); // Synchronous!
resolve();
}).then(() => console.log('3')); // Asynchronous (microtask)
console.log('4');

// Output: 1, 2, 4, 3

Q: Why does Promise run before setTimeout?โ€‹

Answer: Microtasks (Promises) have higher priority than macrotasks (setTimeout). The microtask queue must be completely drained before any macrotask runs.

Q: Can the Event Loop run when the Call Stack is not empty?โ€‹

โŒ No - The Event Loop only moves tasks from queues to the Call Stack when the Call Stack is completely empty.

Q: What happens if microtasks keep creating new microtasks?โ€‹

Answer: The microtask queue will never empty, causing starvation - macrotasks never run, rendering is blocked, and the UI freezes.

Q: Does the browser render between microtasks?โ€‹

โŒ No - Rendering only happens after all microtasks complete.


Mental Modelโ€‹

The Golden Rule:โ€‹

1. Run all synchronous code (Call Stack)
โ†“
2. Empty the Call Stack
โ†“
3. Drain ALL microtasks (Promises)
โ†“
4. Run ONE macrotask (setTimeout)
โ†“
5. Render (if in browser)
โ†“
6. Repeat from step 3

Visual Mental Model:โ€‹

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Call Stack (JS Code) โ”‚
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚ โ”‚ Execution Context (EC) โ”‚ โ”‚
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚
โ†“ (when empty)
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Microtask Queue (Priority) โ”‚
โ”‚ โ€ข Promise.then โ”‚
โ”‚ โ€ข queueMicrotask โ”‚
โ”‚ โ€ข MutationObserver โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ (drain completely)
โ†“
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Macrotask Queue (Regular) โ”‚
โ”‚ โ€ข setTimeout โ”‚
โ”‚ โ€ข setInterval โ”‚
โ”‚ โ€ข I/O โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ (take ONE)
โ†“
[Render if needed]
โ”‚
โ†“
[Repeat โ†ป]

Tricky Interview Snippetsโ€‹

Snippet 1:โ€‹

console.log('1');

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

Promise.resolve().then(() => {
console.log('3');
setTimeout(() => console.log('4'), 0);
});

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

console.log('6');
Click for output and explanation

Output:

1
6
3
5
2
4

Explanation:

  1. 1 - synchronous
  2. 6 - synchronous
  3. 3 - first microtask
  4. 5 - second microtask
  5. 2 - first macrotask (from beginning)
  6. 4 - second macrotask (added during microtask)

Snippet 2:โ€‹

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

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

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

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

console.log('F');
Click for output and explanation

Output:

F
B
E
C
A
D

Explanation:

  1. F - synchronous
  2. B - first microtask (first promise chain)
  3. E - second microtask (second promise)
  4. C - third microtask (continuation of first chain)
  5. A - first macrotask
  6. D - second macrotask

Snippet 3 (Advanced):โ€‹

console.log('Start');

setTimeout(() => {
console.log('Timeout 1');
Promise.resolve().then(() => console.log('Promise in Timeout 1'));
}, 0);

Promise.resolve()
.then(() => {
console.log('Promise 1');
setTimeout(() => console.log('Timeout in Promise 1'), 0);
})
.then(() => console.log('Promise 2'));

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

console.log('End');
Click for output and explanation

Output:

Start
End
Promise 1
Promise 2
Timeout 1
Promise in Timeout 1
Timeout 2
Timeout in Promise 1

Explanation:

  1. Start, End - synchronous
  2. Promise 1, Promise 2 - drain microtask queue
  3. Timeout 1 - first macrotask
  4. Promise in Timeout 1 - microtask from macrotask (runs before next macrotask)
  5. Timeout 2 - second macrotask (from beginning)
  6. Timeout in Promise 1 - third macrotask (added during first promise)

Killer Interview One-Linerโ€‹

"The event loop coordinates the call stack and task queues to enable non-blocking async execution, always prioritizing microtasks over macrotasks, ensuring the call stack is empty before processing any queued tasks."


Key Takeawaysโ€‹

  1. โœ… JavaScript is single-threaded but achieves concurrency through the Event Loop
  2. โœ… Microtasks (Promises) always run before macrotasks (setTimeout)
  3. โœ… The Call Stack must be empty before the Event Loop processes queues
  4. โœ… Microtasks drain completely before taking one macrotask
  5. โœ… Rendering happens after macrotasks and microtasks
  6. โœ… Infinite microtasks cause UI starvation
  7. โœ… setTimeout(fn, 0) is not immediate - it's still asynchronous
  8. โœ… Understanding the Event Loop is crucial for React performance and debugging async issues

Further Learningโ€‹

To deepen your understanding, explore:

  • Event Loop + Browser Rendering Pipeline - how painting and compositing work
  • Request Animation Frame - optimal timing for animations
  • Web Workers - true parallelism in JavaScript
  • Async/Await internals - how they use the Event Loop
  • Performance monitoring - using Chrome DevTools Performance tab