DEV Community

Cover image for async / await: Pausing a Function Without Pausing JavaScript
Marsha Teo
Marsha Teo

Posted on • Originally published at marshateo.com

async / await: Pausing a Function Without Pausing JavaScript

This is the fourth article in a series on how JavaScript actually runs. You can read the full series here or on my website.


In the last article, we established a precise rule:

Once a macrotask finishes, JavaScript drains all microtasks before selecting the next macrotask.

Microtasks like Promises are continuations that must complete before the runtime moves on to the next macrotask. async/await is often described as syntactic sugar over Promises. If that's the case, we should already understand how await works.

Let’s see.

The Possible Mental Models

Before we look at any code, consider this question: When JavaScript reaches await, what actually happens?

It's surprisingly easy to carry one of the following mental models (I certainly did when I first started learning JavaScript). Which of these feel right?

a. await blocks the entire program before the value resolves, like sleep() in C or C++
b. await pauses the function and immediately yields to the event loop, creating a new macrotask
c. await splits the function and schedules the remainder as a microtask

Pause for a moment. Pick one and we'll test it.

Running the Experiments

You can run all code snippets in this series by pasting them into the browser console.

While some examples work in Node.js, others rely on browser APIs (like rendering or requestAnimationFrame), so the browser is the most reliable environment.

Test for Mental Model A (await Blocks)

Let's see if await pauses the entire program:

async function test() {
  console.log("Inside test");
  await Promise.resolve();
  console.log("After await");
}

console.log("Before test");
test();
console.log("After test");
Enter fullscreen mode Exit fullscreen mode

If await blocked the program, we would expect:

Before test
Inside test
After await
After test
Enter fullscreen mode Exit fullscreen mode

Instead, we observe that After test runs before After await:

Before test
Inside test
After test
After await
Enter fullscreen mode Exit fullscreen mode

It appears that await does not block JavaScript. Execution continues after calling test(). So whatever await does, it does not stop everything.

Test for Mental Model B (await Yields)

Perhaps await pauses the function and immediately hands control back to the runtime for it to choose another macrotask:

async function test() {
  console.log("Inside test");
  await Promise.resolve();
  console.log("After await");
}

setTimeout(() => console.log("timeout"), 0);

console.log("Before test");
test();
console.log("After test");
Enter fullscreen mode Exit fullscreen mode

If await yielded control to the runtime and created a macrotask boundary, the timer might run first:

Before test
Inside test
After test
timeout
After await
Enter fullscreen mode Exit fullscreen mode

But it never does. We instead observe:

Before test
Inside test
After test
After await
timeout
Enter fullscreen mode Exit fullscreen mode

The continuation after await always runs before the timer. This matches the rule from the last article:

Microtasks are drained before any macrotask is selected.

await does not create a macrotask.

Mental Model C: The Only Survivor

So far, the only model consistent with every test is:

When execution reaches await, the function pauses and the rest of the function is queued as a microtask.

When JavaScript reaches await value, the engine conceptually performs something like:

  1. Convert value into a promise if it isn’t one already.
  2. Wrap the rest of the function in a continuation.
  3. Schedule the continuation as a microtask.
  4. Return immediately to the caller.

The function simply splits at this point. This happens even if the value is already resolved:

async function test() {
  console.log("Inside test");
  await 42;
  console.log("After await");
}

console.log("Before test");
test();
console.log("After test");
Enter fullscreen mode Exit fullscreen mode

We still observe:

Before test
Inside test
After test
After await
Enter fullscreen mode Exit fullscreen mode

Even though 42 is not a promise, the remainder of the function runs later.

await will also split the function however many times it appears in the function. Consider:

async function test() {
  console.log("Inside test");
  await Promise.resolve();
  console.log("After first await");
  await Promise.resolve();
  console.log("After second await");
}

console.log("Before test");
test();
console.log("After test");
setTimeout(() => console.log("timeout"), 0);
Enter fullscreen mode Exit fullscreen mode

Observed:

Before test
Inside test
After test
After first await
After second await
timeout
Enter fullscreen mode Exit fullscreen mode

Each await causes the function to split with each await creating a new continuation that runs as a microtask. Both continuations run before the timer since the runtime drains microtasks fully before scheduling the next macrotask.

That split results in a pause in the current async function. There is no other pause - not in the call stack, the event loop nor the entire program.

With await, control immediately returns to the caller. That’s why this works:

async function loadData() {
  await fetch("/data");
  console.log("done");
}

console.log("start");
loadData();
console.log("continue");
Enter fullscreen mode Exit fullscreen mode

Output:

start
continue
done
Enter fullscreen mode Exit fullscreen mode

The function pauses and the program continues.

async/await As Syntactic Sugar Over Promises

You may have heard that async/await is syntactic sugar over promises. That’s true, but only if we are precise what the sugar expands into. At its core, await is equivalent to calling .then() but with one addition.

When JavaScript reaches await value, it registers a continuation like .then() would but it also splits the current function at that point and schedules the remainder to run later as a microtask.

With raw .then(), you manually place the continuation inside a callback. With await, the language automatically pauses the function, preserves its local variables and control flow and resumes it later in the microtask queue. It is a function split backed by the microtask system.

await is .then() plus structured function splitting and microtask resumption.

What About async?

So far, we’ve focused entirely on await. But every example also had async. If await is responsible for splitting the function, what does async actually do? Let’s see.

async function test() {
  return 42;
}

async function main() {
  const p1 = test();
  console.log("Without await:", p1);

  const p2 = await test();
  console.log("With await:", p2);
}

main();
Enter fullscreen mode Exit fullscreen mode

The output:

Without await: Promise { 42 }
With await: 42
Enter fullscreen mode Exit fullscreen mode

The main() function runs synchronously until the first await. When test() is called without await, there is no pause and no microtask. The body of test() runs immediately. The only difference is that test() returns a Promise.

async on its own does not introduce asynchronous work. Instead, it changes the return type of the function: test() returns a Promise, even if it completes synchronously.

When test() is called with await, something different happens. The call to test() still runs immediately, and it still returns a Promise. But now main() pauses at the await. The remainder of main() is wrapped into a continuation and scheduled as a microtask. When that microtask runs, the Promise returned by test() is unwrapped and its resolved value becomes the value of the await expression.

Without await, p is a Promise. With await, p is the resolved value of that Promise.

The Correct Mental Model

We can now separate the two keywords clearly:

  • async changes what the function returns.
  • await changes how the function executes.

If there is no await, an async function can run entirely synchronously. If there is an await, the function splits and resumes as a microtask continuation.

Once you see this, async/await stops being mysterious. It becomes a thin layer over the microtask system.

What This Prepares Us For Next

We now understand:

  • Macrotasks are chosen one at a time.
  • Microtasks drain completely.
  • Promise callbacks are continuations.
  • await creates microtask continuations.

There is one more piece missing:

When does rendering happen?

To answer that we need to look beyond JavaScript execution and into the browser's frame lifecycle. That is the next layer of the event loop, and that's where we go next.


This article was originally published on my website.

Top comments (0)