Event Loop: Microtasks vs Macrotasks Explained
This article breaks down how the browser's JavaScript event loop schedules and prioritizes asynchronous operations. You will learn the mechanical differences between the microtask queue and the macrotask queue, the exact step-by-step lifecycle the runtime executes on each tick, and the practical implications this prioritization has on web application performance and UI responsiveness.
The Core Concept: Single-Threaded Execution
JavaScript runs on a single main thread, meaning it can only execute one command at a time through a structure called the Call Stack. When asynchronous operations are triggered—such as network requests, timers, or DOM events—the browser delegates these actions to internal APIs. Once complete, their corresponding callback functions are placed into specialized waiting queues: the Macrotask Queue (often called the Task Queue) and the Microtask Queue.
Distinguishing Between Task Queues
The browser distinguishes asynchronous callbacks based on their urgency and origin:
Macrotasks
Macrotasks represent discrete, standalone units of work. Common sources of macrotasks include:
setTimeoutandsetIntervalsetImmediate(Node.js environments)- Native I/O events (network responses, disk reads)
- User interface events (clicks, scrolls, typing)
- Script parsing (the initial execution of a
<script>file)
Microtasks
Microtasks are lighter, deferred actions that must be executed immediately after the current script finishes and before the browser yields control back to the rendering engine. Common sources of microtasks include:
Promiseresolution handlers (.then(),.catch(),.finally())queueMicrotask()MutationObservercallbacksasync/awaitcontinuations (which desugar into promises)
The Event Loop Priority Algorithm
The event loop enforces a strict priority rule: microtasks always take precedence over macrotasks.
On every cycle ("tick"), the event loop executes the following sequence:
- Run the oldest macrotask: The runtime picks the first macrotask available in the macrotask queue (the initial execution of your script is itself the first macrotask) and pushes it onto the call stack until it is fully resolved.
- Flush the microtask queue: As soon as the call stack clears, the runtime inspects the microtask queue. It runs every pending microtask one by one until the queue is completely empty. If a microtask registers another microtask, that new microtask is executed during the same cycle.
- Render UI updates: If the screen requires a repaint, the browser runs its rendering steps (requestAnimationFrame callbacks, style recalculation, layout, and painting).
- Repeat: The event loop moves to the next macrotask in the queue and starts the process again.
Execution Flow Example
Consider the following execution pattern:
console.log('1: Synchronous');
setTimeout(() => {
console.log('2: Macrotask');
}, 0);
Promise.resolve().then(() => {
console.log('3: Microtask');
});
console.log('4: Synchronous');The output order is always:
1: Synchronous4: Synchronous3: Microtask2: Macrotask
Even though setTimeout has a delay of 0,
its callback must wait for the next macrotask turn. The promise callback
goes into the microtask queue, which is evaluated immediately when the
synchronous script finishes, before the next macrotask is picked up.
Practical Implications for Developers
Understanding this distinction prevents critical performance bugs:
- Microtask Starvation: Because the event loop will
not proceed to the next macrotask or repaint the page until the
microtask queue is empty, recursively queuing microtasks (e.g., chained
promises or unconstrained
queueMicrotaskloops) will completely block the UI and freeze the page. - State Synchronization: Microtasks ensure asynchronous state updates (like modern framework reactivity engines) settle before the browser repaints, avoiding visual layout thrashing or flicker.
- Long Task Splitting: Heavy calculations should be
broken up using macrotasks (such as
setTimeoutorscheduler.yield()) rather than microtasks, allowing the browser to process input events and paint between chunks.