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:
| Queue | Common sources | When it runs |
|---|---|---|
| Task queue | setTimeout, setInterval, DOM events, message events | After the stack is empty and microtasks are drained |
| Microtask queue | Promise.then, Promise.catch, Promise.finally, queueMicrotask, await continuations | Right 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:
console.log("start")runs immediately.setTimeoutasks the host environment to schedule a timer task.Promise.resolve().then(...)schedules a microtask.console.log("end")runs immediately.- The call stack is empty.
- The event loop drains the microtask queue, so
"promise"logs. - The microtask queue is empty.
- 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:
| Code | Category |
|---|---|
Plain function calls and console.log | Synchronous stack |
Promise.then, catch, finally | Microtask |
Code after await | Microtask continuation |
queueMicrotask | Microtask |
setTimeout and setInterval | Task |
| Browser events like clicks | Task |
Then apply the rule:
- Synchronous code first.
- All microtasks next.
- One task.
- 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.thenruns beforesetTimeout(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
awaitresumes as a microtask, becauseasync/awaitis Promise syntax underneath. - Too many chained microtasks can delay timers, events, and rendering. Priority is not free.