JavaScript Event Loop Explained: Call Stack, Task Queue, and Microtasks

Jay Kye
9 min read

Topic: JavaScript's event loop mechanism. Understanding how call stack, task queue, and microtask queue work together to handle asynchronous operations in a single-threaded environment.

JavaScriptEvent LoopAsynchronousWeb APIsFrontend

JavaScript Event Loop Explained: Call Stack, Task Queue, and Microtasks

While building BKRS.io, I encountered numerous asynchronous operations - API calls, user interactions, real-time updates. Understanding JavaScript's event loop became crucial for optimizing performance and avoiding blocking operations. Let's dive deep into how JavaScript handles asynchronous code execution.

JavaScript's Single-Threaded Nature

JavaScript is single-threaded with a single call stack, but it achieves non-blocking I/O through its event loop mechanism. This might seem contradictory, but it's the key to JavaScript's power in handling concurrent operations.

Key Characteristics:

  • Single Thread: Only one piece of code executes at a time
  • Single Call Stack: One execution context stack
  • Non-blocking I/O: Asynchronous operations don't halt execution

JavaScript Engine Architecture

Understanding the event loop requires knowing the JavaScript engine structure:

┌─────────────────────────────────────┐
│           JavaScript Engine         │
│  ┌─────────────┐ ┌─────────────────┐│
│  │ Memory Heap │ │   Call Stack    ││
│  │             │ │                 ││
│  │   Objects   │ │ Function Calls  ││
│  │  Variables  │ │   Execution     ││
│  └─────────────┘ └─────────────────┘│
└─────────────────────────────────────┘
           │
           ▼
┌─────────────────────────────────────┐
│            Web APIs                 │
│  • setTimeout/setInterval           │
│  • fetch/XMLHttpRequest             │
│  • DOM Events                       │
│  • localStorage/sessionStorage      │
│  • IndexedDB                        │
│  • Geolocation                      │
└─────────────────────────────────────┘
           │
           ▼
┌─────────────────────────────────────┐
│          Event Loop                 │
│                                     │
│ ┌─────────────────┐ ┌──────────────┐│
│ │ Microtask Queue │ │ Task Queue   ││
│ │ (High Priority) │ │(Low Priority)││
│ │                 │ │              ││
│ │ • Promises      │ │ • setTimeout ││
│ │ • queueMicrotask│ │ • setInterval││
│ │ • MutationObs.  │ │ • I/O ops    ││
│ └─────────────────┘ └──────────────┘│
└─────────────────────────────────────┘

Components Breakdown:

1. JavaScript Engine

  • Memory Heap: Where objects and variables are stored
  • Call Stack: Manages execution of functions (LIFO - Last In, First Out)

2. Web APIs

Browser-provided APIs that handle asynchronous operations:

  • setTimeout/setInterval
  • fetch/XMLHttpRequest
  • DOM manipulation (document, HTMLElement)
  • Storage APIs (localStorage, sessionStorage, IndexedDB)
  • And many more...

3. Event Loop Queues

  • Task Queue (Macro Task Queue): Lower priority tasks
  • Microtask Queue: Higher priority tasks

4. Event Loop

The orchestrator that manages execution order and moves tasks between queues and call stack.

Case 1: Simple Execution Without Promises

Let's start with a basic example:

console.log(1)

setTimeout(() => console.log(2), 1000)

console.log(3)

Expected Output: 1 → 3 → 2 (after 1000ms)

Execution Flow:

  1. console.log(1) → Added to call stack → Executed immediately → Output: 1

  2. setTimeout(() => console.log(2), 1000) → Added to call stack → Moved to Web APIs → Timer starts → Callback queued in Task Queue after 1000ms

  3. console.log(3) → Added to call stack → Executed immediately → Output: 3

  4. Event Loop Check → Call stack empty → Moves console.log(2) from Task Queue to Call Stack → Output: 2

Visual Timeline:

Time: 0ms
Call Stack: [console.log(1)] → Execute → []
Output: 1

Call Stack: [setTimeout] → Move to Web APIs → []
Web APIs: [Timer: 1000ms]

Call Stack: [console.log(3)] → Execute → []
Output: 3

Time: 1000ms
Web APIs: [Timer complete] → Move callback to Task Queue
Task Queue: [() => console.log(2)]
Event Loop: Call Stack empty → Move from Task Queue
Call Stack: [console.log(2)] → Execute → []
Output: 2

Case 2: Complex Execution with Promises and Microtasks

Now let's examine a more complex scenario:

Promise.resolve()
  .then(() => console.log(1))

setTimeout(() => console.log(2), 10)

queueMicrotask(() => {
  console.log(3)
  queueMicrotask(() => console.log(4))
})

console.log(5)

Expected Output: 5 → 1 → 3 → 4 → 2 (after 10ms)

Execution Flow Analysis:

Phase 1: Initial Parsing and Queuing

  1. Promise.resolve().then(() => console.log(1))

    • Promise resolves immediately
    • Callback () => console.log(1)Microtask Queue
  2. setTimeout(() => console.log(2), 10)

    • Timer starts in Web APIs
    • After 10ms, callback → Task Queue
  3. queueMicrotask(() => { ... })

    • Callback function → Microtask Queue
  4. console.log(5)

    • Added to call stack → Executed immediately → Output: 5

Phase 2: Event Loop Processing

Priority Order: Call Stack → Microtask Queue → Task Queue

  1. Process Microtask Queue:

    • Execute () => console.log(1) → Output: 1
    • Execute microtask with console.log(3) → Output: 3
    • This creates another microtask: () => console.log(4)
    • Execute () => console.log(4) → Output: 4
  2. Process Task Queue (after 10ms):

    • All microtasks completed
    • Execute () => console.log(2) → Output: 2

Detailed Timeline:

Time: 0ms
┌─ Synchronous Execution ─┐
│ Call Stack: [console.log(5)] → Execute → Output: 5
└─────────────────────────┘

┌─ Microtask Queue Processing ─┐
│ Microtask Queue: [() => console.log(1), microtask_function]
│ 
│ Execute: () => console.log(1) → Output: 1
│ Execute: microtask_function → Output: 3
│   └─ Creates: () => console.log(4) → Added to Microtask Queue
│ Execute: () => console.log(4) → Output: 4
└─────────────────────────────────┘

Time: 10ms
┌─ Task Queue Processing ─┐
│ Task Queue: [() => console.log(2)]
│ Execute: () => console.log(2) → Output: 2
└─────────────────────────┘

Key Concepts and Rules

1. Execution Priority

Call Stack (Highest)
    ↓
Microtask Queue (High)
    ↓
Task Queue (Low)

2. Microtask Queue Characteristics

  • Higher Priority than Task Queue
  • Processes ALL microtasks before moving to Task Queue
  • Can create new microtasks during execution
  • Sources: Promises, queueMicrotask(), MutationObserver

3. Task Queue Characteristics

  • Lower Priority than Microtask Queue
  • Processes ONE task at a time
  • Sources: setTimeout, setInterval, I/O operations, UI events

4. Event Loop Rules

  1. Execute all synchronous code first
  2. Process ALL microtasks before any task
  3. Process ONE task, then check for microtasks again
  4. Repeat until all queues are empty

Real-World Applications

Performance Optimization in BKRS.io

When building the real-time comment system for BKRS.io, understanding the event loop helped optimize user experience:

// ❌ Blocking approach
function processComments(comments) {
  comments.forEach(comment => {
    // Heavy DOM manipulation
    renderComment(comment)
    updateUserStats(comment.userId)
    triggerNotifications(comment)
  })
}

// ✅ Non-blocking approach using microtasks
function processCommentsAsync(comments) {
  let index = 0
  
  function processNext() {
    if (index >= comments.length) return
    
    const comment = comments[index++]
    renderComment(comment)
    updateUserStats(comment.userId)
    triggerNotifications(comment)
    
    // Allow other tasks to run
    queueMicrotask(processNext)
  }
  
  processNext()
}

API Call Optimization

// Understanding promise resolution order
async function fetchUserData(userId) {
  // This creates microtasks
  const userPromise = fetch(`/api/users/${userId}`)
  const postsPromise = fetch(`/api/users/${userId}/posts`)
  
  // These will be processed in microtask queue
  const [user, posts] = await Promise.all([userPromise, postsPromise])
  
  // This setTimeout will wait for all microtasks to complete
  setTimeout(() => {
    console.log('All API calls completed')
  }, 0)
  
  return { user, posts }
}

Common Pitfalls and Debugging Tips

1. Infinite Microtask Loop

// ❌ This will block the event loop!
function infiniteMicrotasks() {
  queueMicrotask(() => {
    console.log('Microtask')
    infiniteMicrotasks() // Creates infinite microtasks
  })
}

// ✅ Better approach
function controlledMicrotasks(count = 0) {
  if (count >= 100) return // Limit iterations
  
  queueMicrotask(() => {
    console.log('Microtask', count)
    controlledMicrotasks(count + 1)
  })
}

2. setTimeout(0) vs queueMicrotask

console.log('start')

setTimeout(() => console.log('timeout'), 0)
queueMicrotask(() => console.log('microtask'))

console.log('end')

// Output: start → end → microtask → timeout

3. Promise vs setTimeout Timing

// Common misconception
setTimeout(() => console.log('timeout 0'), 0)
Promise.resolve().then(() => console.log('promise'))

// Promise always executes first due to microtask priority
// Output: promise → timeout 0

Browser DevTools for Event Loop Debugging

Performance Tab Analysis

  1. Open Chrome DevTools → Performance tab
  2. Record execution
  3. Look for:
    • Call Stack visualization
    • Task and Microtask timing
    • Long tasks that block the main thread

Console Debugging

// Add timing to understand execution order
console.time('execution')

Promise.resolve().then(() => {
  console.timeLog('execution', 'Promise resolved')
})

setTimeout(() => {
  console.timeLog('execution', 'Timeout executed')
}, 0)

queueMicrotask(() => {
  console.timeLog('execution', 'Microtask executed')
})

console.timeLog('execution', 'Synchronous code')

Best Practices

1. Avoid Blocking the Main Thread

// ❌ Blocking
function heavyComputation(data) {
  return data.map(item => expensiveOperation(item))
}

// ✅ Non-blocking
async function heavyComputationAsync(data) {
  const results = []
  
  for (let i = 0; i < data.length; i++) {
    results.push(expensiveOperation(data[i]))
    
    // Yield control every 10 items
    if (i % 10 === 0) {
      await new Promise(resolve => setTimeout(resolve, 0))
    }
  }
  
  return results
}

2. Use Appropriate Async Patterns

// For immediate execution after current stack
queueMicrotask(() => {
  // High priority, executes before any setTimeout
})

// For deferred execution
setTimeout(() => {
  // Lower priority, allows other tasks to run
}, 0)

// For promise-based operations
Promise.resolve().then(() => {
  // Microtask queue, high priority
})

3. Error Handling in Async Code

// Microtask errors need proper handling
queueMicrotask(() => {
  try {
    riskyOperation()
  } catch (error) {
    console.error('Microtask error:', error)
  }
})

// Promise error handling
Promise.resolve()
  .then(() => riskyOperation())
  .catch(error => console.error('Promise error:', error))

Conclusion

Understanding JavaScript's event loop is crucial for writing efficient, non-blocking code. The key insights are:

Core Concepts:

  • JavaScript is single-threaded but achieves concurrency through the event loop
  • Execution priority: Call Stack → Microtask Queue → Task Queue
  • Microtasks always complete before tasks from the Task Queue

Practical Applications:

  • Optimize heavy computations by yielding control
  • Use appropriate async patterns for different scenarios
  • Debug performance issues using browser DevTools
  • Avoid blocking the main thread with infinite microtasks

Real-World Impact: In building BKRS.io, proper event loop understanding helped achieve smooth real-time updates for 7K+ users without blocking the UI. The difference between a laggy and responsive application often comes down to how well you work with JavaScript's asynchronous nature.

Next Steps: Having mastered the event loop fundamentals, I'm planning to explore advanced async patterns like Web Workers and the upcoming Temporal API for even better performance optimization.


References:

This post is based on practical experience optimizing real-time applications and deep research into JavaScript's execution model. The examples are simplified for learning but follow production-ready patterns.