JavaScript Event Loop Explained: Understanding Async Execution

Deep dive into call stack, task queues, microtasks and macrotasks execution order

JavaScript Event Loop Explained: Understanding Async Execution

The event loop is the core of JavaScript asynchronous programming. This article explores how it works in depth.

Basic Concepts

Runtime Model

JavaScript Runtime Structure:
┌─────────────────────────────────────────────────────┐
│                                                     │
│   Heap                                              │
│   └── Memory region for object allocation          │
│                                                     │
│   Call Stack                                        │
│   └── Execution context for function calls         │
│                                                     │
│   Web APIs                                          │
│   ├── setTimeout / setInterval                     │
│   ├── fetch / XMLHttpRequest                       │
│   ├── DOM event listeners                          │
│   └── requestAnimationFrame                        │
│                                                     │
│   Task Queues                                       │
│   ├── Macrotask Queue                              │
│   └── Microtask Queue                              │
│                                                     │
└─────────────────────────────────────────────────────┘

Call Stack

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

function second() {
  console.log('Second');
  third();
  console.log('Second End');
}

function third() {
  console.log('Third');
}

first();

// Call stack changes:
// 1. first() pushed
// 2. console.log('First') pushed, runs, popped
// 3. second() pushed
// 4. console.log('Second') pushed, runs, popped
// 5. third() pushed
// 6. console.log('Third') pushed, runs, popped
// 7. third() popped
// 8. console.log('Second End') pushed, runs, popped
// 9. second() popped
// 10. console.log('First End') pushed, runs, popped
// 11. first() popped

Macrotasks and Microtasks

Task Types

Task Classification:
┌─────────────────────────────────────────────────────┐
│                                                     │
│   Macrotasks                                        │
│   ├── script (overall code)                        │
│   ├── setTimeout / setInterval                     │
│   ├── setImmediate (Node.js)                       │
│   ├── I/O                                          │
│   ├── UI rendering                                 │
│   └── requestAnimationFrame                        │
│                                                     │
│   Microtasks                                        │
│   ├── Promise.then / catch / finally               │
│   ├── async/await                                  │
│   ├── queueMicrotask()                             │
│   ├── MutationObserver                             │
│   └── process.nextTick (Node.js)                   │
│                                                     │
└─────────────────────────────────────────────────────┘

Execution Order

console.log('1: Script Start');

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

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

queueMicrotask(() => {
  console.log('5: queueMicrotask');
});

console.log('6: Script End');

// Output order:
// 1: Script Start
// 6: Script End
// 3: Promise 1
// 5: queueMicrotask
// 4: Promise 2
// 2: setTimeout

// Explanation:
// 1. Synchronous code runs first (1, 6)
// 2. Microtask queue emptied (3, 5, 4)
// 3. Macrotask runs (2)

Event Loop Flow

// One iteration of the event loop
while (true) {
  // 1. Get oldest task from macrotask queue
  const macrotask = macrotaskQueue.shift();
  if (macrotask) {
    runTask(macrotask);
  }

  // 2. Run all microtasks
  while (microtaskQueue.length > 0) {
    const microtask = microtaskQueue.shift();
    runTask(microtask);
  }

  // 3. Render if needed
  if (needsRender()) {
    // Run requestAnimationFrame callbacks
    runAnimationFrames();
    // Render
    render();
  }
}

Classic Interview Questions

Question One

console.log('1');

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

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

console.log('6');

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

Question Two

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}

async function async2() {
  console.log('async2');
}

console.log('script start');

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

async1();

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

console.log('script end');

// Output:
// script start
// async1 start
// async2
// promise1
// script end
// async1 end
// promise2
// setTimeout

Question Three

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

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

// Output: promise1, promise3, promise4, promise2, promise5
// Note: return Promise.resolve() creates extra microtasks

Node.js Event Loop

Node.js Phases

Node.js Event Loop Phases:
┌─────────────────────────────────────────────────────┐
│                                                     │
│   ┌───────────────────────────────┐                │
│   │         timers               │                │
│   │   setTimeout / setInterval   │                │
│   └───────────────────────────────┘                │
│                 ↓                                   │
│   ┌───────────────────────────────┐                │
│   │     pending callbacks        │                │
│   │   System operation callbacks │                │
│   └───────────────────────────────┘                │
│                 ↓                                   │
│   ┌───────────────────────────────┐                │
│   │      idle, prepare           │                │
│   │   Internal use               │                │
│   └───────────────────────────────┘                │
│                 ↓                                   │
│   ┌───────────────────────────────┐                │
│   │          poll                │                │
│   │   I/O callbacks, new events  │                │
│   └───────────────────────────────┘                │
│                 ↓                                   │
│   ┌───────────────────────────────┐                │
│   │         check                │                │
│   │   setImmediate callbacks     │                │
│   └───────────────────────────────┘                │
│                 ↓                                   │
│   ┌───────────────────────────────┐                │
│   │     close callbacks          │                │
│   │   Close handlers             │                │
│   └───────────────────────────────┘                │
│                                                     │
└─────────────────────────────────────────────────────┘

setTimeout vs setImmediate

// In I/O callbacks, setImmediate always runs first
const fs = require('fs');

fs.readFile('file.txt', () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});

// Output: immediate, timeout (stable in I/O callback)

// In main module, order is non-deterministic
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Could be timeout, immediate OR immediate, timeout

process.nextTick

// process.nextTick has highest priority
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'), 0);

// Output: nextTick, promise, timeout, immediate

// Warning: Too many nextTick calls can block I/O
function recursive() {
  process.nextTick(recursive); // Dangerous! Blocks event loop
}

Practical Applications

Avoiding Blocking

// Blocking example
function processLargeArray(array) {
  array.forEach(item => {
    // Time-consuming operation
    heavyComputation(item);
  });
}

// Non-blocking improvement
async function processLargeArrayAsync(array) {
  for (let i = 0; i < array.length; i++) {
    heavyComputation(array[i]);

    // Yield control every 100 items
    if (i % 100 === 0) {
      await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
}

// Using requestIdleCallback (browser)
function processWhenIdle(items) {
  let index = 0;

  function process(deadline) {
    while (index < items.length && deadline.timeRemaining() > 0) {
      heavyComputation(items[index]);
      index++;
    }

    if (index < items.length) {
      requestIdleCallback(process);
    }
  }

  requestIdleCallback(process);
}

Batched DOM Updates

// Using microtasks for batched updates
let pending = false;
const updates = [];

function scheduleUpdate(update) {
  updates.push(update);

  if (!pending) {
    pending = true;
    queueMicrotask(() => {
      pending = false;
      flushUpdates();
    });
  }
}

function flushUpdates() {
  const batch = updates.splice(0);
  batch.forEach(update => update());
}

// Using requestAnimationFrame
let rafPending = false;
const rafUpdates = [];

function scheduleRafUpdate(update) {
  rafUpdates.push(update);

  if (!rafPending) {
    rafPending = true;
    requestAnimationFrame(() => {
      rafPending = false;
      rafUpdates.splice(0).forEach(fn => fn());
    });
  }
}

Best Practices Summary

Event Loop Best Practices:
┌─────────────────────────────────────────────────────┐
│                                                     │
│   Avoid Blocking                                    │
│   ├── Break long tasks into chunks                 │
│   ├── Use Web Workers                              │
│   ├── Avoid synchronous blocking operations        │
│   └── Use requestIdleCallback                      │
│                                                     │
│   Task Scheduling                                   │
│   ├── Understand micro vs macro task difference    │
│   ├── Use setTimeout(fn, 0) appropriately          │
│   ├── Use requestAnimationFrame for animations     │
│   └── Use microtasks for batched updates           │
│                                                     │
│   Performance                                       │
│   ├── Monitor long tasks                           │
│   ├── Avoid deep Promise chains                    │
│   └── Watch for nextTick/queueMicrotask abuse      │
│                                                     │
└─────────────────────────────────────────────────────┘
Task TypeWhen Executed
SynchronousImmediately
MicrotaskAfter current macrotask
MacrotaskNext event loop iteration
requestAnimationFrameBefore render

Understanding the event loop is key to mastering JavaScript async programming.