EdgeCases Logo
Feb 2026
JavaScript
Expert
7 min read

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.

javascript
event-loop
microtasks
promises
browser-internals
performance
edge-case

"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):

  1. Execute the current task (script, event handler, timer callback)
  2. When the JS stack empties, process all microtasks
  3. Render updates if needed
  4. 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        // macrotask

The 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()
  • MutationObserver callbacks
  • await resumption points

These queue macrotasks:

  • setTimeout(), setInterval()
  • setImmediate() (Node.js)
  • I/O callbacks, UI events (click, scroll, etc.)
  • requestAnimationFrame() (technically a separate queue before paint)
  • MessageChannel port 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

Related Insights

Explore related edge cases and patterns

JavaScript
Deep
Promise.withResolvers(): The Pattern You've Been Hacking Around
7 min

Advertisement