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:
- The Call Stack: Executes synchronous functions in a Last-In, First-Out (LIFO) order. When a function finishes, it is popped off the stack.
- The Microtask Queue (Job Queue): Holds short, high-priority background tasks that must execute immediately after the current synchronous script finishes, before the browser renders or processes the next macrotask.
- The Macrotask Queue (Task Queue): Holds discrete, independent chunks of work scheduled for later execution by runtime APIs.
Categorizing Tasks
Different asynchronous Web APIs and JavaScript features feed into either the microtask or macrotask queue.
Microtasks include:
Promise.prototype.then(),.catch(), and.finally()callbacksqueueMicrotask()MutationObservercallbacksprocess.nextTick()(in Node.js environments)
Macrotasks include:
- Initial script execution (
<script>tag or main entry point) setTimeout()andsetInterval()setImmediate()(Node.js)requestAnimationFrame()(browser rendering pipeline)- I/O operations and DOM event listeners
The Event Loop Coordination Algorithm
The event loop continuously cycles through a deterministic sequence to coordinate between these queues:
- Execute Synchronous Script: The engine runs all synchronous code currently on the call stack until the stack is completely empty.
- 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.
- 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.
- Execute One Macrotask: The event loop selects the oldest task waiting in the macrotask queue and pushes it onto the call stack for execution.
- 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.