JavaScript Event Loop, Microtasks, and Macrotasks Explained
Visualize how the JavaScript event loop works -- understand the call stack, microtask queue, macrotask queue, and execution order.
What you'll learn
- ✓How the call stack, microtask queue, and macrotask queue interact
- ✓Why Promise callbacks run before setTimeout callbacks
- ✓How to predict execution order in async JavaScript
Prerequisites
- •Basic JavaScript async concepts
- •Familiarity with Promises and setTimeout
JavaScript is single-threaded, yet it handles thousands of concurrent operations. The secret is the event loop — a mechanism that coordinates the call stack, microtask queue, and macrotask queue. Understanding it is essential for predicting how async code executes.
The big picture
The event loop follows this cycle on every iteration (called a “tick”):
- Execute all synchronous code on the call stack until it is empty.
- Drain the entire microtask queue (Promises, queueMicrotask, MutationObserver).
- Execute one task from the macrotask queue (setTimeout, setInterval, I/O, UI rendering).
- Repeat.
The critical insight: all microtasks run before the next macrotask. This is why Promise callbacks always execute before setTimeout callbacks, even if the timeout is 0.
The classic puzzle
console.log("1 - synchronous");
setTimeout(() => {
console.log("2 - macrotask (setTimeout)");
}, 0);
Promise.resolve().then(() => {
console.log("3 - microtask (Promise.then)");
});
queueMicrotask(() => {
console.log("4 - microtask (queueMicrotask)");
});
console.log("5 - synchronous");
Output:
// 1 - synchronous
// 5 - synchronous
// 3 - microtask (Promise.then)
// 4 - microtask (queueMicrotask)
// 2 - macrotask (setTimeout)
Here is what happens step by step:
"1 - synchronous"runs immediately (call stack).setTimeoutcallback is scheduled as a macrotask.Promise.thencallback is queued as a microtask.queueMicrotaskcallback is queued as a microtask."5 - synchronous"runs immediately (call stack).- Call stack is empty. Drain microtasks:
"3"then"4". - Execute next macrotask:
"2".
Microtasks vs macrotasks
Microtasks (higher priority):
Promise.then,Promise.catch,Promise.finallyqueueMicrotask()MutationObserverasync/awaitcontinuations
Macrotasks (lower priority):
setTimeout,setIntervalsetImmediate(Node.js)- I/O callbacks
- UI rendering events
requestAnimationFrame(runs before paint, but after microtasks)
Microtasks can starve macrotasks
Because all microtasks drain before the next macrotask, a microtask that schedules more microtasks can block everything.
// WARNING: This blocks the event loop!
function recursiveMicrotask(count) {
if (count > 0) {
queueMicrotask(() => recursiveMicrotask(count - 1));
}
}
setTimeout(() => console.log("I will wait a long time"), 0);
recursiveMicrotask(1000000); // 1 million microtasks before setTimeout runs
This is why you should avoid creating unbounded chains of microtasks.
Async/await and the event loop
async/await is syntactic sugar over Promises. When you await, the code after it becomes a microtask.
async function example() {
console.log("A - before await");
await Promise.resolve();
console.log("B - after await (microtask)");
}
console.log("C - start");
example();
console.log("D - end");
Output:
// C - start
// A - before await
// D - end
// B - after await (microtask)
await pauses the function and schedules the continuation as a microtask. The synchronous code after the example() call runs first.
Nested async puzzle
async function first() {
console.log("1");
await second();
console.log("2");
}
async function second() {
console.log("3");
await Promise.resolve();
console.log("4");
}
first();
console.log("5");
Output:
// 1
// 3
// 5
// 4
// 2
Breaking it down:
first()starts, logs"1", callssecond().second()logs"3", hitsawait Promise.resolve(), pauses. Continuation ("4") queued as microtask.second()returned a pending promise, sofirst()pauses atawait second(). Continuation ("2") will be queued aftersecond()resolves."5"logs (synchronous).- Microtask queue:
"4"runs.second()resolves, so"2"is now queued. - Microtask queue:
"2"runs.
setTimeout is not guaranteed timing
setTimeout(fn, 0) does not mean “run immediately.” It means “run after the current call stack and all microtasks, as the next macrotask.”
const start = performance.now();
setTimeout(() => {
const elapsed = performance.now() - start;
console.log(`setTimeout 0 ran after ${elapsed.toFixed(1)}ms`);
}, 0);
// Do some synchronous work
let sum = 0;
for (let i = 0; i < 10_000_000; i++) {
sum += i;
}
console.log(`Sync work done after ${(performance.now() - start).toFixed(1)}ms`);
The setTimeout callback will not run until the synchronous loop finishes and all microtasks are drained. In practice, setTimeout(fn, 0) usually has a minimum delay of ~4ms in browsers.
requestAnimationFrame
requestAnimationFrame runs before the browser paints but after microtasks. It is ideal for visual updates.
console.log("1 - sync");
requestAnimationFrame(() => {
console.log("2 - rAF (before paint)");
});
setTimeout(() => {
console.log("3 - setTimeout");
}, 0);
Promise.resolve().then(() => {
console.log("4 - microtask");
});
Typical output:
// 1 - sync
// 4 - microtask
// 2 - rAF (before paint)
// 3 - setTimeout
The exact ordering of rAF vs setTimeout can vary by browser, but microtasks always run first.
Practical implications
Batching DOM updates: Since microtasks run before rendering, you can batch multiple DOM changes in microtasks and the browser will only repaint once.
function batchUpdate(element, updates) {
queueMicrotask(() => {
for (const [prop, value] of Object.entries(updates)) {
element.style[prop] = value;
}
// Browser paints once after all microtasks drain
});
}
Yielding to the event loop: If you have heavy computation, break it up with setTimeout to let the browser handle user events and rendering.
async function processLargeArray(items) {
const chunkSize = 1000;
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
processChunk(chunk);
// Yield to the event loop between chunks
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
Summary
- The event loop cycles: call stack, then all microtasks, then one macrotask.
- Microtasks (Promises, queueMicrotask) always run before macrotasks (setTimeout, I/O).
async/awaitcontinuations are microtasks.- Unbounded microtask chains can starve macrotasks and freeze the UI.
setTimeout(fn, 0)is not instant — it waits for the stack and microtask queue to empty.- Use
requestAnimationFramefor visual updates,setTimeoutfor yielding to the event loop.
Understanding the event loop transforms how you reason about async JavaScript. When you can trace the execution order of any snippet, debugging async bugs becomes straightforward.
Related articles
- JavaScript JavaScript Event Loop Explained: How Async Code Really Works
Understand how the JavaScript event loop handles async operations including the call stack, microtasks, macrotasks, and execution order with practical examples.
- JavaScript JavaScript Promise Combinators: all, allSettled, race, and any
Master all four JavaScript Promise combinators -- Promise.all, allSettled, race, and any -- with practical patterns for concurrency, timeouts, and resilience.
- JavaScript JavaScript Web Workers: Run Code Off the Main Thread
Learn how to use JavaScript Web Workers to run CPU-intensive code off the main thread, keeping your UI responsive with practical examples and patterns.
- JavaScript JavaScript Error Handling Patterns and Best Practices
Master JavaScript error handling with custom error classes, async error patterns, Result types, error boundaries, and strategies for robust production code.