Skip to main content

Event Loop and Microtasks in JavaScript

Published: December 18, 2025 Updated: August 30, 2026 Larry Qu 6 min read

The event loop is the mechanism that allows JavaScript — a single-threaded language — to be non-blocking. Understanding it explains why async code behaves the way it does, why some promises resolve before timeouts, and why long synchronous operations freeze the browser.

The Call Stack and the Execution Model

JavaScript executes code on a single thread. Synchronous code goes directly onto the call stack. Function calls push frames onto the stack; returns pop them off. The key constraint: nothing else runs while the call stack is occupied.

When an async operation (network request, timer, I/O) finishes, it doesn’t immediately jump back into execution. Instead, a callback is placed in a queue. The event loop’s job is to pick items off that queue and put them on the call stack — but only when the stack is empty.

function first() { console.log('First'); second(); }
function second() { console.log('Second'); third(); }
function third() { console.log('Third'); }

first();
// Call stack: first → second → third → (unwind)
// Output: First, Second, Third

When the entire script finishes executing synchronously, the call stack empties and the event loop can process queued callbacks.

Two Queues: Microtasks and Macrotasks

Not all async callbacks are equal. JavaScript has two distinct queues with different priorities:

Queue Contents When processed
Microtask queue Resolved Promises (.then/.catch/.finally), queueMicrotask(), MutationObserver After every task, before rendering
Macrotask queue setTimeout, setInterval, setImmediate (Node.js), I/O events One task per loop iteration

The critical rule: all microtasks are drained before the next macrotask runs. This is why Promise callbacks always execute before setTimeout callbacks, even a setTimeout(fn, 0):

console.log('Script start');

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

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

console.log('Script end');

// Output:
// Script start    ← synchronous
// Script end      ← synchronous
// Promise 1       ← microtask (before any macrotask)
// Promise 2       ← microtask (chained .then creates another microtask)
// setTimeout      ← macrotask (runs after microtask queue is empty)

The Full Execution Order

The event loop algorithm in plain terms:

  1. Execute all synchronous code on the call stack
  2. Drain the microtask queue completely (keep running microtasks until the queue is empty)
  3. Render if needed (browser only)
  4. Pick one macrotask from the queue and execute it
  5. Go to step 2
console.log('1. Synchronous');

setTimeout(() => {
  console.log('2. Macrotask (setTimeout)');
  // A Promise inside a macrotask creates a microtask
  Promise.resolve().then(() => console.log('3. Microtask inside macrotask'));
}, 0);

Promise.resolve().then(() => {
  console.log('4. Microtask');
  // A setTimeout inside a microtask creates a new macrotask
  setTimeout(() => console.log('5. Macrotask created from microtask'), 0);
});

console.log('6. Synchronous');

// Output:
// 1. Synchronous
// 6. Synchronous
// 4. Microtask             ← microtask queue drained first
// 2. Macrotask (setTimeout) ← first macrotask
// 3. Microtask inside macrotask ← microtask queue drained after macrotask
// 5. Macrotask created from microtask ← second macrotask

Work through this output mentally — it’s the single most important exercise for understanding the event loop.

queueMicrotask

queueMicrotask() explicitly schedules a microtask without wrapping it in a Promise. It runs with the same priority as Promise callbacks:

console.log('Start');

queueMicrotask(() => console.log('Microtask via queueMicrotask'));
Promise.resolve().then(() => console.log('Microtask via Promise'));
setTimeout(() => console.log('Macrotask'), 0);

console.log('End');

// Output:
// Start
// End
// Microtask via queueMicrotask  (execution order between these two depends on insertion order)
// Microtask via Promise
// Macrotask

queueMicrotask is useful when you need to schedule high-priority work without creating an unnecessary Promise wrapper.

The Main Thread Blocking Problem

Since JavaScript is single-threaded, anything that occupies the call stack blocks everything else — rendering, user input, other callbacks. This is the “blocking the main thread” problem:

// ❌ This freezes the browser for 2 seconds — no UI updates, no input handling
const start = performance.now();
while (performance.now() - start < 2000) {
  // Busy wait — the event loop cannot process anything during this time
}
console.log('Done');

// ✅ Break long work into chunks using setTimeout or requestIdleCallback
function processChunk(items, index = 0) {
  const CHUNK_SIZE = 100;
  const end = Math.min(index + CHUNK_SIZE, items.length);

  for (let i = index; i < end; i++) {
    doWork(items[i]);
  }

  if (end < items.length) {
    // Yield to the event loop between chunks so the UI stays responsive
    setTimeout(() => processChunk(items, end), 0);
  }
}

Microtask Starvation

Because microtasks run to completion before any macrotask, infinite microtask recursion starves the macrotask queue (and the render loop):

// ❌ This creates infinite microtasks — setTimeout never runs
function infiniteMicrotasks() {
  Promise.resolve().then(() => infiniteMicrotasks());
}
infiniteMicrotasks();
setTimeout(() => console.log('This never runs'), 0);

// ✅ Use macrotasks for work that should yield to the event loop
function limitedWork(count = 0) {
  if (count >= 10) return;
  Promise.resolve().then(() => limitedWork(count + 1));
}

Batching Updates with Microtasks

React and other UI libraries use microtasks to batch state updates — schedule all updates as microtasks, then flush them together before rendering:

class BatchUpdater {
  constructor() {
    this.pendingUpdates = [];
    this.scheduled = false;
  }

  schedule(update) {
    this.pendingUpdates.push(update);

    if (!this.scheduled) {
      this.scheduled = true;
      // Flush after all synchronous code (and other microtasks) run
      queueMicrotask(() => this.flush());
    }
  }

  flush() {
    const updates = this.pendingUpdates.splice(0);
    this.scheduled = false;
    console.log(`Flushing ${updates.length} batched updates`);
    updates.forEach(fn => fn());
  }
}

const updater = new BatchUpdater();
updater.schedule(() => console.log('Update 1'));
updater.schedule(() => console.log('Update 2'));
updater.schedule(() => console.log('Update 3'));
// All three are collected first, then flushed in one microtask
// Output: Flushing 3 batched updates, Update 1, Update 2, Update 3

Measuring Event Loop Lag

In production Node.js servers, event loop lag indicates when your application is becoming unresponsive. A lag over 50ms typically means something is blocking the thread:

function measureEventLoopLag() {
  let lastCheck = Date.now();

  setInterval(() => {
    const now = Date.now();
    const lag = now - lastCheck - 1000; // Expected interval: 1000ms
    if (lag > 50) {
      console.warn(`Event loop lag: ${lag}ms — something is blocking the thread`);
    }
    lastCheck = now;
  }, 1000);
}

Summary

The event loop is why JavaScript can be asynchronous despite running on a single thread. The key rules:

  • Synchronous code runs first and blocks everything while it runs
  • Microtasks (Promises, queueMicrotask) run after each task and before rendering
  • Macrotasks (setTimeout, setInterval) run one per event loop iteration
  • All microtasks drain before the next macrotask — never setTimeout(fn, 0) to “run before promises”
  • Long synchronous operations block the entire thread; break them into chunks with setTimeout
  • Infinite microtask recursion starves macrotasks and the render loop

Resources

Comments

👍 Was this article helpful?