The Event Loop
JavaScript runs your code on one thread. Everything that looks concurrent - timers, network calls, clicks - is the same thread picking up queued work between runs. Understanding the queue order is what turns "why did that log print second" from a mystery into arithmetic.
The model
There is a call stack, where the currently running code lives, and there are queues of work waiting for the stack to empty. The event loop is the rule that decides what to take next.
Two queues matter:
- The microtask queue: resolved promise callbacks,
queueMicrotask,MutationObserver. - The macrotask queue (the task queue):
setTimeout,setInterval, I/O completions, events.
The rule, in one sentence: after each task, the engine drains the entire microtask queue before taking the next task. Not one microtask - all of them, including any added while draining.
console.log('1 sync');
setTimeout(() => console.log('5 timeout'), 0);
Promise.resolve().then(() => console.log('3 microtask'));
queueMicrotask(() => console.log('4 microtask'));
console.log('2 sync');
// 1 sync
// 2 sync
// 3 microtask
// 4 microtask
// 5 timeout
Open this example in the editor and change it. Nothing you type is saved.
Synchronous code first, because it is already on the stack. Then every microtask. Then the timer. The numbers are not a convention - they are what the specification requires.
await is a microtask boundary
An async function runs synchronously until its first await. Everything after that await is a microtask.
async function run() {
console.log('A');
await null; // not a promise, still a microtask boundary
console.log('C');
}
console.log('start');
run();
console.log('B');
// start
// A - the body runs synchronously up to the await
// B - run() returned at the await; the caller continues
// C - queued as a microtask, runs when the stack empties
Open this example in the editor and change it. Nothing you type is saved.
await null does not wait for anything, and it still defers. That is why inserting an await into a function can reorder logs that had nothing to do with the thing being awaited.
setTimeout(fn, 0) is not zero
setTimeout(() => console.log('later'), 0);
Open this example in the editor and change it. Nothing you type is saved.
The 0 is a minimum delay, and three things add to it:
- Clamping. Nested timers - a
setTimeoutscheduled from inside a timer callback - are clamped to 4ms after the fifth level of nesting, by specification. A recursivesetTimeout(fn, 0)loop therefore runs at about 250 iterations a second, not as fast as possible. - Queue position. The callback goes to the back of the task queue, behind anything already there.
- The stack. Nothing runs until the current synchronous work has finished. A 500ms calculation delays every timer by 500ms.
const start = Date.now();
setTimeout(() => console.log('fired after', Date.now() - start, 'ms'), 0);
// A deliberately slow loop, on the same thread.
let n = 0;
while (Date.now() - start < 200) n++;
console.log('blocked for 200ms');
// blocked for 200ms
// fired after 200ms or so - the timer was ready at 0 and could not run
Open this example in the editor and change it. Nothing you type is saved.
When you genuinely want "after this, but before the next paint", the tools are queueMicrotask (as soon as the stack empties) and requestAnimationFrame (just before the next frame). setTimeout(fn, 0) is neither.
Why this matters in practice
A long task blocks everything. Not just other JavaScript - rendering, scrolling, clicks, the lot. Browsers call anything over 50ms a long task, and a run of them is what "janky" means. The fix is to break the work up:
async function processAll(items, handle) {
for (const [i, item] of items.entries()) {
handle(item);
// Yield every 100 items so the browser can paint and respond.
if (i % 100 === 99) await new Promise((r) => setTimeout(r, 0));
}
}
Open this example in the editor and change it. Nothing you type is saved.
In a browser, scheduler.yield() is the purpose-built version of that line and keeps your place in the queue rather than going to the back.
A microtask loop starves everything. Because the loop drains microtasks completely, a microtask that queues another microtask forever is an infinite loop that never lets a timer, an event or a paint through:
// Do not run this: the page will stop responding entirely.
function spin() { Promise.resolve().then(spin); }
Open this example in the editor and change it. Nothing you type is saved.
The equivalent with setTimeout is merely busy; this one is fatal. That asymmetry is the practical reason to know which queue you are on.
A worked example
// The complete ordering, in one runnable file. Predict each line
// before running it; the ones you get wrong are the ones worth
// re-reading above.
const log = [];
const note = (s) => log.push(s);
note('1: script start');
setTimeout(() => {
note('8: timeout A');
Promise.resolve().then(() => note('9: microtask inside timeout A'));
}, 0);
setTimeout(() => note('10: timeout B'), 0);
Promise.resolve()
.then(() => {
note('4: promise then 1');
// Returning a promise adds TWO extra microtask ticks before the
// next .then runs - this is why 7 lands after 6.
return Promise.resolve();
})
.then(() => note('7: promise then 2'));
queueMicrotask(() => note('5: queueMicrotask'));
(async () => {
note('2: async body, before await');
await null;
note('6: async body, after await');
})();
note('3: script end');
// Report once every queue has drained.
setTimeout(() => { for (const line of log) console.log(line); }, 10);
// 1: script start
// 2: async body, before await
// 3: script end
// 4: promise then 1
// 5: queueMicrotask
// 6: async body, after await
// 7: promise then 2
// 8: timeout A
// 9: microtask inside timeout A
// 10: timeout B
Open this example in the editor and change it. Nothing you type is saved.
Three things in that output are worth defending.
Line 6 before line 7. Both are microtasks queued in the first drain, but then 1 returned a promise, which costs two extra ticks to adopt. The async function's continuation was queued first and had no such cost.
Line 9 before line 10. Timeout A and timeout B were both queued before either ran. After A's callback finishes, the engine drains microtasks - which now contains A's promise callback - before taking B. Microtasks beat the next task, always.
Lines 1 to 3 in order. The async IIFE printed line 2 synchronously. Nothing about async defers the start of a function; only the await defers the rest.
Node and the browser differ
The microtask rule is identical. The task side is not: Node has phases, and two extra queues that the browser does not have.
// Node only, and inside a callback, which is where the order is
// actually defined.
setTimeout(() => {
process.nextTick(() => console.log('1 nextTick'));
Promise.resolve().then(() => console.log('2 promise'));
setTimeout(() => console.log('4 timeout'), 0);
setImmediate(() => console.log('3 setImmediate'));
});
// 1 nextTick - its own queue, drained first
// 2 promise - then the microtask queue
// 3 setImmediate - the check phase, this time round the loop
// 4 timeout - the timers phase, next time round
Open this example in the editor and change it. Nothing you type is saved.
process.nextTick has its own queue, drained before the promise microtask queue - so a nextTick that queues another nextTick starves promises, the same way a self-queueing microtask starves timers.
Inside a callback, setImmediate runs before setTimeout(fn, 0), because the check phase comes after the current phase and the timers phase does not come round again until the next iteration. At the top level the order of those two is genuinely non-deterministic - it depends on how long the process took to start, and whether the 0ms timer is already due on the first pass. It is a famous interview question and it almost never matters in real code.
One more top-level subtlety, since you may try it: in an ES module the module body itself runs as a promise job, so a top-level process.nextTick is queued from inside a microtask and can print after a Promise.resolve().then. In CommonJS the same four lines print nextTick first. The rule above holds in both; what changes is what counts as "the current callback".
The browser has one more wrinkle worth knowing: rendering. The loop is roughly task, microtasks, then possibly a render - style, layout, paint - before the next task. requestAnimationFrame callbacks run inside that render step, which is why they are the right place for visual updates and the wrong place for data processing.
When it goes wrong
| Symptom | Cause |
|---|---|
| A log printed in an order you did not expect | Microtasks drain before the next task |
| The UI froze for a second | A long synchronous task; break it up and yield |
| The page stopped responding entirely | A microtask queueing itself forever |
setTimeout(fn, 0) took 4ms or more |
Nested-timer clamping |
| A timer fired much later than asked | The stack was busy; the delay is a minimum |
| A DOM read after a write gave stale values | The render step had not run yet |
| Node ordering differs from the browser | nextTick and setImmediate are Node-only phases |
An await reordered unrelated logs |
Every await is a microtask boundary |
A check you can run
Take the worked example and move the queueMicrotask call to the very top of the file, above everything. Predict the output, then run it.
It still prints fifth. Nothing about writing it first matters, because the whole synchronous body runs before any microtask does. That is the single most useful thing to internalise here: the order you write asynchronous calls in affects only the order they are queued, and the queue they land in decides everything else.