Skip to content

The JavaScript Event Loop

How JavaScript handles asynchronous operations.

Single-Threaded, Non-Blocking

JavaScript runs on a single thread, but handles async operations without blocking.

┌─────────────────────────────────────────────────────┐
│                    JavaScript                        │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐ │
│  │   Call      │  │   Event     │  │   Callback  │ │
│  │   Stack     │  │   Loop      │  │   Queue     │ │
│  └─────────────┘  └─────────────┘  └─────────────┘ │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│                  Web APIs / Node APIs               │
│  (setTimeout, fetch, fs, etc. - run outside JS)     │
└─────────────────────────────────────────────────────┘

Core Components

Call Stack

Tracks function execution. LIFO (Last In, First Out).

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

// Stack progression:
// 1. first()
// 2. first() → second()
// 3. first() → second() → third()
// 4. first() → second() → third() → console.log()
// 5. first() → second() → third()
// 6. first() → second()
// 7. first()
// 8. (empty)

Event Loop

Continuously checks: "Is the call stack empty? Are there callbacks waiting?"

while (true) {
  if (callStack.isEmpty()) {
    // 1. Process all microtasks (Promises)
    while (microtaskQueue.hasItems()) {
      const microtask = microtaskQueue.dequeue();
      execute(microtask);
    }

    // 2. Process one macrotask (setTimeout, I/O)
    if (macrotaskQueue.hasItems()) {
      const macrotask = macrotaskQueue.dequeue();
      execute(macrotask);
    }

    // 3. Render if needed (browser only)
    if (shouldRender()) {
      render();
    }
  }
}

Task Queues

Microtasks (higher priority): - Promise callbacks (.then, .catch, .finally) - queueMicrotask() - MutationObserver

Macrotasks (lower priority): - setTimeout / setInterval - I/O callbacks - UI rendering - setImmediate (Node.js)

Execution Order

console.log('1 - Sync');

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

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

queueMicrotask(() => console.log('4 - Microtask'));

console.log('5 - Sync');

// Output:
// 1 - Sync
// 5 - Sync
// 3 - Promise
// 4 - Microtask
// 2 - setTimeout

Why this order? 1. Sync code runs first (1, 5) 2. Microtasks run before macrotasks (3, 4) 3. setTimeout is a macrotask (2)

Detailed Example

console.log('Start');

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

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

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

console.log('End');

// Output:
// Start
// End
// Promise 1
// Promise 2
// setTimeout 1
// Promise inside setTimeout
// setTimeout 2

Explanation: 1. Start and End (synchronous) 2. Promise 1 (microtask) 3. Promise 2 (microtask from Promise 1) 4. setTimeout 1 (macrotask) 5. Promise inside setTimeout (microtask from setTimeout 1) 6. setTimeout 2 (next macrotask)

Blocking the Event Loop

// BAD: Blocks the main thread
function heavyComputation() {
  let sum = 0;
  for (let i = 0; i < 1e9; i++) {
    sum += i;
  }
  return sum;
}

// This blocks everything for seconds!
heavyComputation();
console.log('This waits...');

Solution: Chunking

// GOOD: Process in chunks, yield to event loop
async function heavyComputationChunked() {
  let sum = 0;
  const chunkSize = 1e6;

  for (let i = 0; i < 1e9; i += chunkSize) {
    // Process chunk
    const end = Math.min(i + chunkSize, 1e9);
    for (let j = i; j < end; j++) {
      sum += j;
    }

    // Yield to event loop
    await new Promise(resolve => setTimeout(resolve, 0));
  }

  return sum;
}

Solution: Web Worker

// main.js
const worker = new Worker('worker.js');
worker.postMessage({ start: 0, end: 1e9 });
worker.onmessage = (e) => console.log('Result:', e.data);

// worker.js
self.onmessage = (e) => {
  let sum = 0;
  for (let i = e.data.start; i < e.data.end; i++) {
    sum += i;
  }
  self.postMessage(sum);
};

requestAnimationFrame

Runs before the next repaint, ideal for animations.

function animate() {
  // Update animation
  element.style.left = position + 'px';
  position += 1;

  // Schedule next frame
  requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

Timing in event loop:

[Macrotask] → [Microtasks] → [requestAnimationFrame] → [Render] → [Macrotask] → ...

Node.js Specifics

Node.js has additional phases:

   ┌───────────────────────────┐
┌─▶│           timers          │ ← setTimeout, setInterval
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │     pending callbacks     │ ← I/O callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │           poll            │ ← incoming connections, data
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
│  │           check           │ ← setImmediate
│  └─────────────┬─────────────┘
│  ┌─────────────▼─────────────┐
└──│      close callbacks      │ ← socket.on('close')
   └───────────────────────────┘

setImmediate vs setTimeout

// Order can vary
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

// Inside I/O callback, setImmediate always first
fs.readFile('file.txt', () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
  // Output: immediate, timeout
});

process.nextTick

Higher priority than microtasks in Node.js.

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

// Output: nextTick, promise

Common Pitfalls

Starving the Event Loop

// BAD: Microtasks can starve macrotasks
function recursivePromise() {
  Promise.resolve().then(recursivePromise);
}
recursivePromise();
// setTimeout callbacks never run!

Assuming setTimeout(0) is Immediate

// setTimeout has minimum delay (~4ms in browsers)
const start = Date.now();
setTimeout(() => {
  console.log(Date.now() - start); // Often 4-5ms, not 0
}, 0);

Not Handling Rejections

// BAD: Unhandled rejection
Promise.reject(new Error('Oops'));

// GOOD: Always handle
Promise.reject(new Error('Oops')).catch(console.error);

// Or use try/catch with async/await
async function safe() {
  try {
    await riskyOperation();
  } catch (error) {
    console.error(error);
  }
}

Debugging

Performance Timeline

// Mark events for Performance tab
performance.mark('start');
await operation();
performance.mark('end');
performance.measure('Operation', 'start', 'end');

Long Task Detection

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('Long task:', entry.duration);
  }
});
observer.observe({ entryTypes: ['longtask'] });

Best Practices

  1. Don't block the main thread — Keep tasks < 50ms
  2. Use microtasks wisely — Don't create infinite chains
  3. Chunk heavy work — Yield periodically
  4. Use Web Workers — For CPU-intensive tasks
  5. Understand the order — Microtasks before macrotasks
  6. Profile performance — Use browser dev tools