JavaScript Event Loop: Macrotasks vs Microtasks

The JavaScript event loop is the concurrency mechanism that enables single-threaded JavaScript to handle non-blocking asynchronous operations. This article explains how the event loop coordinates execution between synchronous code, microtasks, and macrotasks, detailing the precise order of operations, the queues involved, and why understanding this lifecycle is critical for predictable code behavior and optimal performance.

The Core Components

JavaScript executes code in a single-threaded runtime environment. To manage asynchronous behavior, the runtime uses three primary data structures:

Categorizing Tasks

Different asynchronous Web APIs and JavaScript features feed into either the microtask or macrotask queue.

Microtasks include:

Macrotasks include:

The Event Loop Coordination Algorithm

The event loop continuously cycles through a deterministic sequence to coordinate between these queues:

  1. Execute Synchronous Script: The engine runs all synchronous code currently on the call stack until the stack is completely empty.
  2. Drain the Microtask Queue: Once the call stack clears, the event loop inspects the microtask queue. It executes the microtasks one by one in a First-In, First-Out (FIFO) order until the queue is completely empty. If a microtask schedules another microtask, the newly added microtask is also executed during this same cycle.
  3. Render the UI (Browsers only): If the runtime is in a browser environment, the browser updates the DOM, recalculates styles, and renders frame updates if a paint is required.
  4. Execute One Macrotask: The event loop selects the oldest task waiting in the macrotask queue and pushes it onto the call stack for execution.
  5. Repeat: Once that single macrotask completes, the loop immediately returns to step 2 to process any microtasks generated by that macrotask before picking the next macrotask.

Practical Execution Example

Consider the following snippet:

console.log('1: Sync');

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

Promise.resolve().then(() => {
  console.log('3: Microtask');
});

console.log('4: Sync');

The output order is: 1. 1: Sync (Call stack) 2. 4: Sync (Call stack) 3. 3: Microtask (Microtask queue drained immediately after call stack empties) 4. 2: Macrotask (Next event loop iteration pulls from the task queue)

Key Takeaway: Microtask Starvation

Because the event loop drains the entire microtask queue before moving to UI rendering or the next macrotask, recursively queuing microtasks (e.g., an infinite promise chain or continuous queueMicrotask() calls) will starve the macrotask queue and freeze the user interface. Conversely, macrotasks yield control back to the engine after each individual task, ensuring the event loop remains responsive.