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.