Sam Thorogood

Async functions & microtasks

This was something that I recently helped a friend understand.

When you invoke an async function, is it 1️⃣ run in a microtask, or 2️⃣ is it executed directly? Here's an example:

console.info('a');
const p = foo();  // call async and hold Promise in p
console.info('b');
p.then(() => {
  console.info('c');
});

async function foo() {
  console.info('1');
  await Promise.resolve();  // actually do something async
  console.info('2');
}

The answer is both—the function will run its synchronous prefix immediately, but whenever you await something, the rest of its code will be put into a microtask.

(And the program will print, in this order: "a 1 b 2 c").

Why is this interesting? Well, in places that aren't already asynchronous—like event callbacks, old-style top-level code, and so on—you're often going to be taking a reference to the Promise returned when you invoke an async function, as we do above. And it's important to remember that some code will actually run immediately.

const p = foo();  // something will log before this line finishes

If you don't want that behavior, and you want nothing of that function to run until a microtask has passed, you can instead wrap it in… another microtask:

const p = Promise.resolve().then(() => foo());

And of course… if we instead await foo() (which perhaps you can do via top-level await, or as you're inside another asynchronous function), then the order is more straightforward—that await causes your program to wait for the microtask, which, to us a readers, makes the code feel a lot more "normal".

console.info('a');
await foo();  // waiting causes it all to run "now"
console.info('b');

Ok! If you've learned something, great. You can also read on for some more thoughts. 📖⬇️

Equivalence

It's important that Promise and an async function are actually pretty similar. Here's a contrived example:

async function foo() {
  console.info('1');
  await Promise.resolve();
}
await foo();
// equal to
await new Promise((r) => {
  console.info('1');
  r(Promise.resolve());
});

The function we pass into the Promise constructor (in the 2nd case) is technically called the executor. It's run synchronously—passing something which itself returns a Promise (like an async function) doesn't really make sense, as any failures will be thrown as unhandled rejections.

…that diversion notwithstanding, the relevant part here is that executor is basically equivalent to the prefix of our async function, because it too is run synchronously.

Microtasks

I've described microtasks already, but if they're not entirely clear, let's recap. In this example, what will print?

console.info('1');
Promise.resolve().then(() => console.info('2'));
console.info('3');

If you answered "1", "3", then "2", you'd be right. Any time we .then() a Promise, that callback is executed as a microtask—broadly, that code is queued to run "immediately", but after the current execution and other microtasks. (And, the same happens when we use await on them.) Phew.

Notably this also happens for Promise instances that already exist and were perhaps resolved a long time ago (the fact that we literally created a new one isn't particularly special).

Optionally Async

When you write an asynchronous function, you force the caller to use a microtask to read its result. This is the case even if you don't await inside it—it will return a Promise regardless:

async function bar() {
  console.info('This method probably won\'t use await');
  if (Math.random() < 0.01) {
    await somethingElse;
    return false;
  }
  return true;
}
const p = bar();
await p;

In the example, it's unlikely that we use await. This is just to show an example of why you might be optionally async.

In the majority of cases, once bar() returns, there's actually nothing left to do, and all possible side-effects of bar() have already occured (in this case, just logging to the console). As a caller though, you're not going to know that, because it's impossible to introspect the Promise to see whether it's done (and it's not until the microtask is done).

If you have an optional async function like this, it might be a reason to convert it back to being synchronous and only optionally return a Promise so the caller isn't forced to wait a microtask—you'd check the return type via instanceof Promise. However, this just feels hard to read and presents a particularly unusual API.

(In my experience, it's a much more common pattern to go the other way—you can use Promise.resolve(...) on any type, including another Promise, to ensure that it is a Promise).

Finished

That's it. Go reward yourself with a donut.