Knowledge Base/concepts/JavaScript Tasks vs Microtasks: Why Promises Run Before setTimeout

JavaScript Tasks vs Microtasks: Why Promises Run Before setTimeout

JavaScript tasks vs microtasks is the reason a resolved Promise can beat setTimeout(..., 0). Zero milliseconds does not mean "run immediately." It means "put this callback in the task queue after the current synchronous code finishes." Promise callbacks use the microtask queue, and that queue gets priority.

That one rule explains most surprising event loop output.

The naive expectation

This code looks like the timer should run first. It was scheduled before the Promise callback, and the delay is 0.

console.log("start");

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

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

console.log("end");

The actual output is:

start
end
promise
timeout

The timer did not lose because it was slow. It lost because it was in the lower-priority queue.

The event loop has more than one queue

The call stack runs synchronous code first. While that synchronous code runs, JavaScript may schedule future work into queues.

The two queues that matter here are:

QueueCommon sourcesWhen it runs
Task queuesetTimeout, setInterval, DOM events, message eventsAfter the stack is empty and microtasks are drained
Microtask queuePromise.then, Promise.catch, Promise.finally, queueMicrotask, await continuationsRight after the stack is empty, before the next task

The important rule is not "Promises are faster." The rule is stricter:

Run the current synchronous code.
Drain the entire microtask queue.
Run one task from the task queue.
Drain the entire microtask queue again.
Repeat.

Microtasks are not parallel. They still run on the same thread. They just run at a very specific checkpoint before the browser moves on to the next task.

Trace the output step by step

Use the same example, but read it as queue operations:

console.log("start");

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

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

console.log("end");

The trace is:

  1. console.log("start") runs immediately.
  2. setTimeout asks the host environment to schedule a timer task.
  3. Promise.resolve().then(...) schedules a microtask.
  4. console.log("end") runs immediately.
  5. The call stack is empty.
  6. The event loop drains the microtask queue, so "promise" logs.
  7. The microtask queue is empty.
  8. The event loop takes the timer task, so "timeout" logs.

That is the whole trick. The timer can be ready, but readiness does not let it skip the microtask checkpoint.

setTimeout(0) means minimum delay

setTimeout(callback, 0) does not mean "run this callback now." It means "make this callback eligible to run after at least zero milliseconds."

It still has to wait for:

  • The current synchronous code to finish.
  • All queued microtasks to finish.
  • Any earlier tasks already waiting in the task queue.

This code makes that visible:

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

for (let i = 0; i < 3; i++) {
  Promise.resolve().then(() => console.log(`promise ${i}`));
}

console.log("sync");

Output:

sync
promise 0
promise 1
promise 2
timeout

The timer callback was eligible quickly. It still waited until the microtask queue was empty.

Microtasks drain completely

The event loop does not run one microtask and then check timers. It drains the whole microtask queue. If a microtask schedules another microtask, the new one also runs before the next task.

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

Promise.resolve().then(() => {
  console.log("promise 1");

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

Output:

promise 1
promise 2
timeout

This is useful because Promise chains stay internally consistent before the browser handles the next timer, click, or network callback. It is also dangerous if you keep adding microtasks forever. A loop that constantly queues microtasks can delay rendering and timer callbacks. Congratulations, you made the event loop technically correct and practically miserable.

await uses the same microtask behavior

await does not create a separate scheduling system. Code after an await resumes through Promise machinery, which means it resumes as a microtask.

async function run() {
  console.log("async start");
  await Promise.resolve();
  console.log("after await");
}

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

run();
console.log("sync end");

Output:

async start
sync end
after await
timeout

The function starts synchronously. The await pauses run, returns control to the caller, and schedules the continuation as a microtask.

For the return-value side of this, see Why Async Functions Always Return a Promise.

The practical debugging rule

When you need to predict output order, classify each line:

CodeCategory
Plain function calls and console.logSynchronous stack
Promise.then, catch, finallyMicrotask
Code after awaitMicrotask continuation
queueMicrotaskMicrotask
setTimeout and setIntervalTask
Browser events like clicksTask

Then apply the rule:

  1. Synchronous code first.
  2. All microtasks next.
  3. One task.
  4. Any microtasks created by that task.

If the result still feels surprising, write the trace. The event loop is not guessing. It is following a priority order.

Key Takeaways

  • Promise.then runs before setTimeout(0) because Promise callbacks are microtasks and timers are tasks.
  • setTimeout(0) means the callback is eligible after the current code, not that it jumps the queue.
  • The event loop drains every microtask before moving to the next task.
  • Code after await resumes as a microtask, because async/await is Promise syntax underneath.
  • Too many chained microtasks can delay timers, events, and rendering. Priority is not free.
Share this article:

Practice with 1 related challenges

javascriptasyncpromisesevent-loop