Skip to content
AtomicReps

An async function runs synchronously until its first await

A lesson from The JavaScript Event Loop: Predicting Execution Order. Play it above, or read it through below.

Level I · How the Event Loop Runs
  • An async function runs synchronously to its first await, then suspends

    The common read: calling an async function defers its whole body; nothing inside it runs until the event loop runs it later.

    The belief is reasonable: you write async on a function, and it is tempting to read that as "this whole function runs later, separately from my code." That reading is wrong. Calling a() pushes a frame onto your call stack, the same stack a plain function call would use, and everything in a() runs on that stack like ordinary code, up to the first await.

    At await, the function suspends: the runtime pops the function's frame off the stack, and the code after the await becomes a continuation. null counts as an already-settled value, so this continuation goes straight into the microtask queue from lesson 3. The function has not finished. It is paused in the middle of its body, and the rest of it runs once the stack is empty and the event loop runs the microtask queue.

    So a1 logs before main, because a() is an ordinary call that runs on your stack. And a2 logs after main, because the await moved the rest of a() into the queue, and the queue only runs once the stack is empty. The rule is that an async function runs synchronously until its first await, then suspends.

  • Await is a scheduling point, not a check

    The common read: the promise is already resolved, so await continues synchronously, without pausing anything.

    Lesson 3 named two queues for scheduled work, the task queue and the microtask queue, and showed that a promise callback goes into the microtask queue. await does not inspect the promise and decide whether to pause. It always suspends the function and registers the rest of it as a callback on the promise. If the promise has already settled, that callback goes into the microtask queue at once. If the promise has not settled, the callback goes into the microtask queue when the promise settles.

    At the level of the mechanism, await p behaves like passing the rest of the function to p as a .then callback: p.then(v => { restOfFn(v) }). That callback does not run when you register it, even when p is already fulfilled. It runs when its turn in the queue comes, like any other promise callback from lesson 3.

    This scheduling rule explains the order in the last question: 1 and 3 are both plain synchronous code, so they run first, in source order. 42 prints only once the stack is empty and the event loop runs the microtask queue, because await put the continuation there, however long p had already been resolved. [MDN: await reference](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await); the underlying rule is the ECMAScript Await abstract operation.

  • Two awaits, two suspensions

    The common read: an async function only pauses once, so once it resumes after its first await, the rest of it runs straight through even past a second await.

    A function suspends at every await it reaches, not only at the first. Resuming after one await does not stop the function from suspending again; the function runs synchronous code on the stack until the next await suspends it.

    The last question's output shows a rule from the earlier lessons: the microtask queue drains to empty, including microtasks queued during the drain. When the checkpoint runs twoTears()'s first continuation, that continuation schedules two more microtasks before it finishes: the side callback, then the continuation after the second await. The checkpoint does not return control to the event loop between them; it keeps taking microtasks from the queue until the queue is empty. side goes first because it was queued first, and only then does the second continuation run and log t3.

    The number of awaits a function reaches is the number of times it suspends. Each suspension is a separate trip through the queue, and any other microtask queued in between runs in the order it was queued, not in the order the function reads from top to bottom.

  • What suspending at every await means for ordering

    An async function is not a thread: it does not run on another core while your code continues. It is not deferred either: the part before the first await runs as soon as you call it, on your stack. And it does not run in parallel: two async functions called one after the other run their synchronous parts in the order you called them, never at the same time.

    An async function suspends at every await. The rest of its body runs later, as a microtask that is queued once the awaited promise settles, and the engine keeps running other code in the meantime.

    From here on, every interleaving question in this course depends on where those awaits are. Find each await in a function, remember that each one suspends the function however fast the awaited promise settles, and you can predict the order from the code without running it.

    Two functions, one await each: a1, b1, main, a2, b2. Both functions suspend before the caller's last line and resume after it. await on an already-settled promise: 1, 3, 42. The function suspends even when the promise is already settled. One function, two awaits: t1, after, t2, side, t3. Each await queues its own continuation, and microtasks queued earlier run first.

    Autocomplete can type await for you, but it does not show you where in the microtask queue the rest of the function will run.