Browser Event Loop: Why Promise Callbacks Don't Always Run First
Microtasks run when the JS stack empties, not just at task boundaries. This changes behavior with event bubbling, .click(), and recursive promises.
"Promise callbacks run before setTimeout callbacks." That's the simplified mental model. The full picture: microtasks run after each callback, not just at the end of a task. This distinction breaks intuition in event handlers, mutation observers, and nested promise chains.
The Canonical Example
console.log("script start");
setTimeout(() => console.log("setTimeout"), 0);
Promise.resolve()
.then(() => console.log("promise1"))
.then(() => console.log("promise2"));
console.log("script end");Output:
script start
script end
promise1
promise2
setTimeout
setTimeout queues a macrotask (also called a "task").
Promise.then() queues a microtask. After the current script
completes, the microtask queue drains before the next macrotask runs.
The Rule: Microtasks After Each Callback
The HTML spec defines when microtasks run (simplified):
- Execute the current task (script, event handler, timer callback)
- When the JS stack empties, process all microtasks
- Render updates if needed
- Pick the next task from the queue, repeat
The key insight: microtasks run when the call stack empties, not just at task boundaries. This means microtasks execute between event listener callbacks, between mutation observer batches, and even mid-way through what you might consider a single "task."
Event Listeners: The Subtle Case
Click handlers registered on nested elements both fire during the same dispatch task. But microtasks run between them:
<div class="outer">
<div class="inner"></div>
</div>
<script>
const outer = document.querySelector(".outer");
const inner = document.querySelector(".inner");
new MutationObserver(() => console.log("mutate"))
.observe(outer, { attributes: true });
function onClick() {
console.log("click");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
outer.setAttribute("data-x", Math.random());
}
inner.addEventListener("click", onClick);
outer.addEventListener("click", onClick);
</script>Click on .inner. Expected output:
click // inner handler
promise // microtask from inner
mutate // MutationObserver microtask
click // outer handler (event bubbles)
promise // microtask from outer
mutate // MutationObserver microtask
timeout // macrotask
timeout // macrotaskThe promise and mutation observer callbacks run between the inner and outer click handlers because the JS stack empties after each handler returns, triggering a microtask checkpoint.
The .click() Gotcha
Now trigger the same click programmatically:
inner.click();Output changes:
click
click
promise
mutate
promise
timeout
timeout
When you call .click(), the dispatch happens synchronously.
Both handlers run while the calling script is still on the stack. The JS stack never empties
between handlers, so microtasks don't run until both handlers complete.
This is spec-compliant but counterintuitive. dispatchEvent() behaves the same way.
Microtask Sources
These APIs queue microtasks:
Promise.then(),.catch(),.finally()queueMicrotask()MutationObservercallbacksawaitresumption points
These queue macrotasks:
setTimeout(),setInterval()setImmediate()(Node.js)- I/O callbacks, UI events (click, scroll, etc.)
requestAnimationFrame()(technically a separate queue before paint)MessageChannelport messages
queueMicrotask(): The Direct API
queueMicrotask() schedules a microtask without Promise overhead:
queueMicrotask(() => console.log("microtask"));
console.log("sync");
// Output:
// sync
// microtask
Use it when you need microtask timing but aren't working with promises. It's marginally
faster than Promise.resolve().then() because it avoids promise allocation.
Nested Microtasks: The Starvation Problem
Microtasks that queue more microtasks all run before the next macrotask. This can starve the event loop:
function recursiveMicrotask() {
queueMicrotask(recursiveMicrotask);
}
recursiveMicrotask();
// This setTimeout NEVER runs
setTimeout(() => console.log("timeout"), 0);The microtask queue never empties, so the macrotask queue is never processed. UI becomes unresponsive because rendering is also blocked.
In practice, this happens with runaway promise chains or mutation observers that trigger mutations. Always ensure microtask recursion has a termination condition.
async/await Timing
await suspends execution and resumes as a microtask:
async function test() {
console.log("1");
await Promise.resolve();
console.log("2"); // Runs as microtask
}
test();
console.log("3");
// Output: 1, 3, 2
Everything after await is essentially in a .then() callback.
The function returns a pending promise immediately, and the rest runs as microtasks.
The Render Checkpoint
Browsers render between tasks, but after microtasks. Heavy microtask work delays painting:
button.addEventListener("click", async () => {
// Change visible state
element.classList.add("loading");
// Heavy microtask work
for (let i = 0; i < 1000; i++) {
await Promise.resolve();
// Each iteration is a microtask
heavyComputation();
}
element.classList.remove("loading");
});
// Problem: The "loading" class might never visually appear
// because painting waits for microtasks to drain
For long-running operations, break work into macrotasks with setTimeout(fn, 0)
or scheduler.postTask() to allow renders between chunks.
requestAnimationFrame Timing
requestAnimationFrame callbacks run before paint, in their own queue.
Microtasks queued during rAF run before paint, but after all rAF callbacks:
requestAnimationFrame(() => {
console.log("rAF 1");
Promise.resolve().then(() => console.log("promise in rAF"));
});
requestAnimationFrame(() => console.log("rAF 2"));
// Output:
// rAF 1
// rAF 2
// promise in rAF
// [paint]All rAF callbacks batch together, then microtasks drain, then paint. This is why DOM measurements in rAF see the pre-paint state.
Debugging Event Loop Issues
Chrome DevTools Performance panel shows task timing. Look for:
- Long Tasks (>50ms) blocking the main thread
- Microtask duration in the "Run Microtasks" entries
- Forced style recalculation during microtasks (layout thrashing)
performance.mark() and performance.measure() help pinpoint
which phase is slow.
Key Takeaways
- Microtasks run when the JS stack empties—after each callback, not just at task end
- Programmatic event dispatch (
.click(),dispatchEvent) changes microtask timing because handlers run synchronously - Recursive microtasks block the event loop entirely—always have a termination condition
queueMicrotask()is the direct API for microtask scheduling without Promise overhead- Rendering happens after microtasks drain—heavy microtask work delays paint
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
MDN: Using microtasks with queueMicrotask()
MDN guide explaining microtask timing and the queueMicrotask API
HTML Spec: Event Loop Processing Model
The authoritative specification for event loop, task, and microtask processing
ECMAScript: Jobs and Job Queues
The ECMAScript specification's definition of Jobs (microtasks in ES terms)
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement