Back to Blogs
javascript

JavaScript Event Loop Explained

Understanding JavaScript's Event Loop, call stack, microtasks, and macrotasks for asynchronous programming

Aaron

JavaScript is single-threaded, meaning it can only execute one task at a time. When one task blocks, subsequent computations cannot be executed. The Event Loop allows JavaScript to perform asynchronous operations.

Call Stack (Last In First Out)

When executing a function, it gets pushed onto the call stack. When the function completes execution, it gets popped from the call stack.

console.log(1);
console.log(2);

Execution Steps:

  1. Push console.log(1) to call stack || call-stack: [console.log(1)]
  2. Execute console.log(1), then pop || call-stack: []
  3. Push console.log(2) to call stack || call-stack: [console.log(2)]
  4. Execute console.log(2), then pop || call-stack: []

Microtasks vs Macrotasks

Task Types

  • Microtasks: Promise.then(), queueMicrotask(func), async/await
  • Macrotasks: setTimeout(), setInterval(), setImmediate(), I/O operations

Execution Priority

When microtasks or macrotasks are encountered, they get pushed to their respective queues. When the call stack becomes empty, microtasks are processed first, then macrotasks.

Event Loop Example

Let’s trace through this complex example:

console.log(1);

setTimeout(() => console.log(2));

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

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

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

setTimeout(() => console.log(6));

console.log(7);

Step-by-Step Execution

  1. Push console.log(1) to call stack → execute → pop
  2. Push setTimeout(() => console.log(2)) to macrotask queue
  3. Push () => console.log(3) to microtask queue
  4. Push () => setTimeout(() => console.log(4)) to microtask queue
  5. Push () => console.log(5) to microtask queue
  6. Push setTimeout(() => console.log(6)) to macrotask queue
  7. Push console.log(7) to call stack → execute → pop
  8. Call stack empty → Process microtask queue first
  9. Dequeue console.log(3) → execute → pop
  10. Dequeue setTimeout(() => console.log(4)) → push to macrotask queue
  11. Dequeue console.log(5) → execute → pop
  12. Microtask queue empty → Process macrotask queue
  13. Dequeue console.log(2) → execute → pop
  14. Dequeue console.log(6) → execute → pop
  15. Dequeue console.log(4) → execute → pop

Output: 1 7 3 5 2 6 4

Visual Representation

┌─────────────────┐
│   Call Stack    │ ← JavaScript execution
└─────────────────┘

┌─────────────────┐
│   Event Loop    │ ← Coordinates task execution
└─────────────────┘
    ↗          ↖
┌──────────┐  ┌──────────┐
│Microtask │  │Macrotask │ ← Task queues
│  Queue   │  │  Queue   │
└──────────┘  └──────────┘

Key Rules

  1. Call stack has priority: Nothing from queues executes while call stack isn’t empty
  2. Microtasks always go first: All microtasks execute before any macrotask
  3. One macrotask per cycle: After each macrotask, check for microtasks again

Common Patterns

Promise Chain vs setTimeout

// This executes in order: A, C, B
console.log('A'); // Synchronous - immediate

setTimeout(() => console.log('B'), 0); // Macrotask - deferred

Promise.resolve().then(() => console.log('C')); // Microtask - priority

Nested Promises and Timeouts

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

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

// Output: timeout1, promise1, timeout2, promise2

Practical Implications

1. UI Responsiveness

// Bad - blocks UI
for (let i = 0; i < 1000000; i++) {
  // heavy computation
}

// Good - allows UI updates
function processChunk(index = 0) {
  const chunkSize = 1000;
  // Process chunk
  if (index < 1000000) {
    setTimeout(() => processChunk(index + chunkSize), 0);
  }
}

2. Promise Resolution Order

Promise.resolve().then(() => {
  console.log('promise1');
  return Promise.resolve();
}).then(() => {
  console.log('promise2');
});

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

// Output: promise1, promise3, promise2

Best Practices

  1. Understand execution order for debugging async code
  2. Use microtasks (Promises) for high-priority async operations
  3. Use macrotasks (setTimeout) for lower-priority or UI-related operations
  4. Avoid blocking the call stack with heavy synchronous operations
  5. Test async code thoroughly as execution order can be counterintuitive

Common Misconceptions

  • setTimeout(fn, 0) executes immediately
  • ❌ Promises are synchronous
  • ❌ All async operations have the same priority
  • ✅ Event loop coordinates between sync and async code
  • ✅ Microtasks have higher priority than macrotasks
  • ✅ Understanding this helps debug timing-related bugs

The Event Loop is fundamental to JavaScript’s asynchronous nature and understanding it is crucial for writing efficient, non-blocking applications.