Someone recently asked me when an async function in JavaScript "returns". This is an interesting question because while a function like f()
below has a return
statement in it, f()
actually returns much earlier than that. To understand how this works, let's take a look at an example function. Here, a()
and c()
are normal synchronous functions and b()
is another asynchronous function.
async function f() {
a();
await b();
return c();
}
Written using promises the actual code executed is more like,
function f() {
return new Promise((resolve) => {
a();
resolve(b().then(() => {
return c();
}));
});
}
An async function returns a Promise
so it should be unsurprising to see new Promise()
being called here. The function passed to the Promise
constructor is invoked synchronously so before f()
returns the code that has actually executed is,
a();
b().then(/* a function which isn't executed yet */);
So the synchonous result of f()
is a new Promise
that will resolve when the Promise
returned by b().then()
resolves. The remaining code after the await
is captured in a new function that runs when the Promise
returned by b()
resolves. This code makes the eventual asynchronous result of f()
whatever is returned by c()
.
When an asynchronous function is used as an event handler only the synchronous part of the function's execution happens before the event dispatch is complete. The difference between synchronous execution and asynchronous execution is important if the event handler calls a function like preventDefault()
. Event dispatch is implemented as code similar to this,
function dispatchEvent() {
const e = new Event();
for (const handler of eventHandlers) {
handler(e);
}
if (!e.defaultPrevented) {
// Do the default action.
}
}
Note that the event dispatch code doesn't await the result of handler()
. This means that only the code that is executed synchronously within handler()
gets a chance to run before it checks whether preventDefault()
has been called. The following code therefore has a bug,
element.addEventListener('event', async (e) => {
await fetch("/some-resource");
e.preventDefault();
});
Since e.preventDefault()
isn't called until the fetch()
completes the event dispatch routine is long since complete and the default action was taken anyways. Writing it this way instead works as intended,
element.addEventListener('event', async (e) => {
e.preventDefault();
await fetch("/some-resource");
});
This can be a source of subtle bugs and is why I think it's important to understand how code using await
is translated into code using promises.