GoByte Skills Episode 16 of 27, track JavaScript (1 of 3)

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.

Back to top

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.

Animation for GoByte Skills #16: Tasks and microtasks queue up and run in a fixed order.
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.

Report a mistake

Your product here? Partner with us

Back to top

Discussion

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.