The Event Loop and Microtasks (and what async/await compiles down to)
Prerequisites assumed: you know promises and their states, how setTimeout schedules work, closures, and the difference between synchronous and asynchronous functions.
The execution model in one paragraph
JavaScript runs your code on a single thread, one call stack at a time. The event loop is the scheduler that keeps pulling work onto that stack. It is not part of the language itself; ES2015 specified jobs (microtasks), and hosts like browsers and Node supply the loop that drains them. Understanding the loop's drain order is the difference between guessing at async bugs and predicting them.
Tasks vs microtasks
The loop maintains at least two queues:
- Task (macrotask) queue: timers, I/O callbacks, UI events,
setImmediatein Node. The loop takes one task per iteration. - Microtask queue: promise reactions,
queueMicrotask,MutationObserver,awaitcontinuations. After each task — and between any two tasks — the loop drains the microtask queue until it is empty, including microtasks spawned by microtasks.
This is why a microtask scheduled now always runs before the next timer, no matter how long ago the timer was set.
console.log('1: sync');
setTimeout(() => console.log('4: task'), 0);
Promise.resolve().then(() => {
console.log('3: microtask');
Promise.resolve().then(() => console.log('3b: nested microtask'));
});
console.log('2: sync');
// 1: sync, 2: sync, 3: microtask, 3b: nested microtask, 4: taskNote the nested .then: microtask drainage is not a snapshot; newly queued microtasks join the current drain. An infinitely chaining promise starves timers and rendering permanently.
caption: Event loop ordering: sync code, then full microtask drain, then one task, repeat. alt: Sequence diagram showing synchronous code running first, the microtask queue draining fully including chained microtasks, then a single task from the task queue, repeating.
Where async/await lands
async/await is promise syntax, not a separate mechanism. An async function:
- Runs synchronously until the first
await. - At
await value, wrapsvaluein a promise, registers the rest of the function as a reaction (a microtask), and returns. - When the awaited promise settles, the continuation runs as a microtask.
So await pauses that function, never the thread. Code after the first await is effectively .then — meaning it is asynchronous relative to the caller, even for already-resolved values.
async function main() {
console.log('a');
const v = await Promise.resolve(42);
console.log('c', v); // microtask continuation
}
console.log('start');
main();
console.log('b');
// start, a, b, c 42Gotchas
- Zero-delay
await:awaiton an already-settled promise still yields one microtask tick. Values that are not thenables (plain numbers in older engines) may cost an extra tick — observable in shared-queue ordering, not in correctness. - Awaits in loops are sequential:
for...ofwithawaitserializes; usePromise.allfor concurrency when results are independent. - Unawaited async calls: errors become unhandled rejections; nothing enforces that callers await.
- Node vs browser: Node layers phases (timers, poll, check) on top of this model;
process.nextTickruns before promise microtasks. Keep drains-order reasoning, but don't assumesetTimeout(fn, 0)andsetImmediateinterleave identically across hosts.
Decision criteria
Use plain promises when composing with combinators (all, allSettled, race, any); use async/await for linear, error-sensitive flows where try/catch reads naturally. Never rely on microtask ordering between unrelated subsystems — treat it as an ordering guarantee within one function's flow, not a scheduling API.
Exercise
In what order do Promise.resolve().then(f), setTimeout(g, 0), and a synchronous h() run?
Show answer
h() first (current stack), then f (microtask drained before the next task), then g (task queue, one tick later).
Exercise
Does await Promise.resolve() execute the continuation synchronously?
Show answer
No. It queues the continuation as a microtask, so the caller and any synchronous code after the call run first.
Spotted a mistake, or code that does not work?