The event loop becomes easier when you stop treating it like magic and start treating it like a scheduling rule.

JavaScript runs one piece of synchronous code at a time. Async APIs do not make your code run in parallel on the same thread. They schedule callbacks to run later.

Quick answer

When JavaScript finishes the current synchronous code, it runs queued promise callbacks before timer callbacks.

console.log("A");

setTimeout(() => console.log("B"), 0);

Promise.resolve().then(() => console.log("C"));

console.log("D");

The output is:

A
D
C
B

That one example explains a lot of UI timing bugs, backend queue callbacks, and “why did this run first?” moments.

The four pieces

A useful mental model has four parts:

  1. The call stack.
  2. Web APIs or runtime APIs.
  3. The task queue.
  4. The microtask queue.

The call stack is where synchronous JavaScript runs.

Runtime APIs handle things like timers, network events, file system callbacks, and browser events.

The task queue receives callbacks like setTimeout.

The microtask queue receives callbacks from promises and queueMicrotask.

The key rule

After the current synchronous code finishes, JavaScript drains the microtask queue before it takes the next task.

That rule explains a lot.

console.log("A");

setTimeout(() => {
  console.log("B");
}, 0);

Promise.resolve().then(() => {
  console.log("C");
});

console.log("D");

The output is:

A
D
C
B

Synchronous logs run first. The promise callback runs as a microtask. The timer callback runs later as a task.

Why setTimeout 0 is not immediate

setTimeout(fn, 0) does not mean “run now.” It means “schedule this callback as a future task as soon as the runtime can.”

The current call stack must finish first. Then microtasks must run. Only then can the timer callback run.

That is why a promise can beat a zero-delay timer.

Microtasks can starve tasks

Because microtasks are drained before the next task, an endless chain of microtasks can delay timers and UI events.

function loop() {
  queueMicrotask(loop);
}

loop();

setTimeout(() => {
  console.log("timer");
}, 0);

The timer may never get a turn because the microtask queue keeps refilling itself.

This is rare in normal code, but it matters when building schedulers, frameworks, or heavy async flows.

async and await use promises

await pauses the async function and resumes it later through the promise/microtask machinery.

async function run() {
  console.log("1");
  await Promise.resolve();
  console.log("2");
}

run();
console.log("3");

Output:

1
3
2

The function starts synchronously. After await, the continuation is scheduled later.

Node.js has extra queues

Node has its own details, including process.nextTick, timers, I/O callbacks, check phase, and setImmediate.

For most application debugging, start with this simpler rule:

  • Synchronous code first.
  • Promise microtasks next.
  • Timer and I/O tasks later.

Then learn Node-specific phases when you are debugging server behavior or performance.

A practical debugging checklist

When async ordering surprises you, ask:

  1. Did this line run synchronously?
  2. Did this callback come from a promise?
  3. Did this callback come from a timer, event, or I/O operation?
  4. Is a microtask chain delaying tasks?
  5. Am I assuming await blocks the whole program instead of only this function?

That checklist solves most event-loop confusion.

The mental model

Think of JavaScript as a single worker with two inboxes.

The microtask inbox gets priority and is emptied completely after each synchronous turn. The task inbox waits for the next turn.

Once that clicks, promise ordering, await, timer behavior, and many UI update bugs become much easier to reason about.