The event loop: await does not block
GoByte Skills #16: await suspends one function, not the thread. Six log lines, two queues, one fixed order, and why a setTimeout of 0 still waits for every microtask.
Zoe is one of GoByte's characters. This post was drafted by AI agents in Zoe's voice, then fact checked, run and edited by the GoByte team.
Await suspends one function, not the thread: the rest of the program runs in the gaps. JavaScript has one call stack and, here, two queues. ECMAScript only requires promise jobs to run in the order they were queued; the host (the HTML spec in browsers, Node's loop on libuv) decides how tasks and microtasks interleave. In CommonJS, Node drains process.nextTick callbacks before promise jobs; at the top level of an ES module, after them.
Transcript
Callbacks drop into a task queue and a microtask queue as the code types in, then move onto the call stack in the order the event loop drains them, microtasks first, to show await yielding instead of blocking.
const log = (s) => console.log(s);
log("1 script starts");
setTimeout(() => log("6 timeout"), 0);
Promise.resolve().then(() => log("4 then"));
async function job() {
log("2 job starts");
await null;
log("5 job resumes");
}
job();
log("3 script ends");
Node 20 prints, every time:
1 script starts
2 job starts
3 script ends
4 then
5 job resumes
6 timeout
The mechanism#
The script is one task, and a task runs to completion. setTimeout puts its callback in the task queue. A settled promise's then callback goes in the microtask queue. An async function runs synchronously until its first await, so line 2 prints before line 3. There it returns a pending promise to its caller, and once the awaited value settles, its continuation (the rest of job) is queued as a microtask.
When the stack empties, the loop drains the whole microtask queue before it takes the next task. So 4 then (queued first), then 5 job resumes, and only then the timer, even with a delay of 0.
Await always yields#
await null awaits a value that is already there and still pauses. So the order never depends on whether the value was ready.
Version note#
Since ES2019 (V8 7.2, Node 12) awaiting a native promise costs one microtask tick instead of three (per the V8 blog; on Node 20 we counted one). Code that depends on tick counts was already a bug.
The boundary#
Microtasks can starve the loop: a chain that keeps queueing them runs to the end before any timer, I/O callback or browser render. With a setTimeout(0) registered first, one million chained queueMicrotask calls all ran before it fired.
And await does not make CPU work concurrent: a long for loop in an async function blocks everyone. Use a worker, or chunk it across tasks.
Rule of thumb#
await hands the thread back and asks to be called when the value is ready. Code between two awaits runs atomically, code across them does not, so re-check shared state after every one. Await is a bookmark, not a lock: when you come back, someone may have rearranged the page.
Your product here? Partner with us
Back to topDiscussion
No comments yet. Signed in GoByte members with a verified e-mail can join. Community guidelines
Reading is open to everyone. Commenting and voting need a GoByte account with a verified e-mail.