The JavaScript event loop is the mechanism that decides what runs next. Your code runs on a single call stack, and when that stack empties, the runtime picks up queued callbacks — first the microtask queue, then the task queue — and runs them one at a time. Once you can picture those three parts, the ordering of promises, timers and await stops being surprising and starts being predictable.
What are the moving parts of the event loop?
There are three of them, and keeping them separate is the whole trick.
- The call stack holds the function calls currently in progress. Your script starts on the stack and stays there until the last statement finishes.
- The microtask queue holds callbacks that must run before anything else. Promise reactions and
queueMicrotaskcalls go here. - The task queue (sometimes called the macrotask queue) holds callbacks scheduled by timers, I/O events and other browser APIs.
When the call stack becomes empty, the runtime checks the microtask queue. If it holds anything, the entire queue is drained — including callbacks added by those callbacks — before the runtime looks at the task queue again. Only then does one task run, and the cycle restarts.
Why does a resolved promise run before a zero-delay timer?
Because setTimeout(fn, 0) does not mean run immediately
. It means put this in the task queue and run it in a later turn of the loop
. A promise callback goes into the microtask queue, and microtasks always drain before the next task is chosen. So even a timer with a delay of zero loses to an already-resolved promise.
console.log('1 sync start');
setTimeout(() => console.log('2 timer callback'), 0);
Promise.resolve().then(() => console.log('3 promise callback'));
queueMicrotask(() => console.log('4 queued microtask'));
console.log('5 sync end');
The output is:
1 sync start
5 sync end
3 promise callback
4 queued microtask
2 timer callback
Everything up to 5 sync end is ordinary synchronous code on the stack. Then the stack empties, both microtasks run in the order they were queued, and only after that does the timer callback get its turn.
What does the order actually look like with real timings?
performance.now() gives you a millisecond timer with sub-millisecond precision, which is enough to see the difference between the queues in a real run.
const start = performance.now();
const at = (label) => console.log(`${label} at ${(performance.now() - start).toFixed(1)}ms`);
at('script start');
setTimeout(() => at('timer 0ms'), 0);
Promise.resolve().then(() => at('promise callback'));
queueMicrotask(() => at('microtask callback'));
at('script end');
A typical run prints the script start and end within the same millisecond, then the two microtasks a fraction of a millisecond later, and the timer a little after that. The exact numbers change on every machine and every run, and the first call to performance.now() in a fresh process can even include module loading overhead. The order is the reliable part, not the milliseconds.
Where does await fit into the queues?
An await splits an async function in two. The code before the await runs synchronously, exactly like any other function. The code after it is scheduled as a microtask, so it resumes on a later turn of the loop.
async function work() {
console.log('a: inside, before await');
await null;
console.log('c: inside, after await');
}
console.log('b: outside, before work()');
work();
console.log('d: outside, after work()');
The output is b, a, d, c. Calling an async function runs its body immediately up to the first await, so a prints before d even though the function was called first. The await null suspends the function and hands control back to the caller, and the remainder of the function resumes later as a microtask.
What happens when your code blocks the event loop?
Nothing else can run while the call stack is busy. A timer scheduled for zero milliseconds still waits for whatever synchronous work is in front of it, and a mouse click waits for the same reason. This is the single most common cause of a page that freezes
for a moment and then suddenly processes a queue of clicks.
const start = Date.now();
setTimeout(() => {
console.log(`timer fired after ${Date.now() - start}ms`);
}, 0);
const until = Date.now() + 200;
while (Date.now() < until) {
// simulate a long synchronous calculation
}
console.log('busy work finished');
The busy loop runs to completion first, then the timer fires roughly 200 milliseconds late instead of immediately. Animations driven by requestAnimationFrame stutter for the same reason. If a piece of work is genuinely heavy — parsing a huge file, sorting hundreds of thousands of records — move it off the main thread with a worker, or break it into chunks that yield between pieces.
What to remember about the event loop
- Synchronous code runs on the call stack and blocks everything else until it finishes.
- When the stack empties, the whole microtask queue drains before the next task runs.
- Promise callbacks and
queueMicrotaskare microtasks; timers and I/O callbacks are tasks. - A zero-millisecond timer still runs after every pending microtask.
awaitresumes an async function as a microtask, so the rest of the function is always later than the call that started it.- Long synchronous work delays timers, clicks and animations alike.
Try it yourself: Run the first block and confirm the printed order. Then move the setTimeout line to the very top of the file and run it again — the order stays the same, because a timer only cares about when it fires, not where you wrote it. Finally, change the busy loop's 200 to 1000 and watch the timer report a delay of about a second: that is the event loop showing you exactly what your synchronous code costs everyone else.