Cloud Tech by Victor
FrontendIntermediate

The JavaScript Event Loop

Why every queued microtask runs before the next macrotask, ever, and how that one ordering rule explains why a Promise callback always logs before a setTimeout(fn, 0), no matter how it looks in the source.

Updated 2026-07-243 min read

Overview

JavaScript runs on a single call stack, and the event loop is what decides what runs next once that stack is empty: it drains the entire microtask queue (Promise callbacks, queueMicrotask) completely before pulling even one macrotask (setTimeout, setInterval, I/O, UI events) from the task queue, then repeats. Every job, whether a task or a microtask, runs to completion once started, JavaScript cannot pause a function mid-execution to interleave another callback, which is what makes reasoning about shared state inside a single synchronous function safe without locks. The one rule that resolves almost every "which callback runs first" question is simple to state and easy to forget under pressure: microtasks always drain completely before the next macrotask, regardless of a timeout's delay value or registration order.

Quick Reference

QueueExamplesWhen it runs
Call stackCurrently executing codeImmediately, synchronously
Microtask queuePromise.then/catch/finally, queueMicrotaskFully drained before the next macrotask
Macrotask (task) queuesetTimeout, setInterval, I/O, UI eventsOne task per event loop iteration, after microtasks are drained

Syntax

javascript
console.log('sync 1');
setTimeout(() => console.log('macrotask'), 0);
Promise.resolve().then(() => console.log('microtask'));
console.log('sync 2');
// Output: sync 1, sync 2, microtask, macrotask

Examples

javascript
// Even a 0ms delay doesn't mean "immediately" - the entire
// microtask queue drains first, every time.
const p = Promise.resolve();
p.then(() => console.log('first'));   // microtask
p.then(() => console.log('second'));  // microtask
setTimeout(() => console.log('third'), 0); // macrotask
// Logs: first, second, third - always, regardless of timing.

A chain of self-scheduling microtasks can starve macrotasks

If a microtask schedules another microtask while running, the new one is still part of the queue being drained before the next macrotask. A recursive chain of microtasks can, in principle, delay timers and rendering indefinitely, this isn't hypothetical, it's a direct consequence of "drain completely," not "drain what was queued at the start."

Visual Diagram

Common Mistakes

  • Assuming setTimeout(fn, 0) runs "immediately" or before a Promise callback registered afterward; microtasks always drain first, regardless of delay value.
  • Treating "run to completion" as unique to microtasks; it applies to every job, tasks included, JavaScript never preempts a running function mid-execution.
  • Not realizing a microtask that schedules further microtasks keeps extending the current drain cycle, potentially starving macrotasks like rendering and timers.
  • Debugging async ordering by trial and error instead of applying the one rule (microtasks fully drain before the next macrotask) directly.

Performance

  • A single call stack means CPU-bound synchronous work blocks everything else, the event loop, rendering, timers, until that function returns; long synchronous functions are a direct cause of an unresponsive page.
  • An unbounded chain of self-scheduling microtasks can delay macrotasks (including rendering) indefinitely, a real performance failure mode, not just a theoretical curiosity.

Best Practices

  • Reason about async ordering from the one rule, microtasks drain completely before the next macrotask, rather than guessing from registration order or delay values.
  • Break up long synchronous, CPU-bound work (large loops, heavy computation) so it doesn't block the call stack and starve rendering/timers.
  • Be deliberate about chains of Promise callbacks that schedule further Promise callbacks; understand that they extend the current microtask drain rather than yielding to macrotasks.
  • Use setTimeout(fn, 0) (or similar) specifically when you need to yield to the macrotask queue, letting pending rendering or other tasks run, not as a "run immediately" mechanism.

Interview questions

Given a setTimeout(fn, 0) and a Promise.resolve().then(fn) registered in that order, which one runs first, and why?

The Promise callback runs first, even though the timeout was registered with a 0ms delay and appears to ask for the soonest possible execution. `setTimeout` queues a macrotask (a "task" in spec terms), while a Promise callback queues a microtask, and the event loop's rule is that the entire microtask queue is drained completely before the next macrotask is even pulled, regardless of registration order or the timeout value. A 0ms delay doesn't mean "immediately", it means "as the next task once the microtask queue is empty and the current call stack has finished."

What does "run to completion" mean for a single task or microtask, and why does it matter for reasoning about shared state?

Once a job (a task or a microtask) starts running, it executes entirely before any other job gets a chance to run, JavaScript cannot pause a running function partway through to let another callback interleave, the way a preemptively-scheduled thread in a language like C could be interrupted mid-function. This is what makes synchronous JavaScript code within a single function safe from data races on shared state without needing locks, whatever a function does to shared variables happens atomically from the perspective of any other queued job, even though the language is single-threaded and asynchronous.

If a microtask itself queues another microtask while it's running, does that new microtask wait for the next event loop iteration, or does it still run before the next macrotask?

It still runs before the next macrotask. The rule isn't "run the microtasks that were queued when this iteration started", it's "drain the microtask queue completely," and if executing a microtask adds another one, that new one is still part of the queue being drained. This means a chain of microtasks that keep scheduling more microtasks can, in principle, starve the event loop from ever reaching the next macrotask (a real, documented way to accidentally block timers and rendering), which is different from a single flat batch of microtasks all queued up front.

References

ShareXLinkedInReddit
Was this page helpful?
Suggest an improvement