How it works
EvaluateAsyncFunctionBody does the following:
- Create a promise capability with
NewPromiseCapability(%Promise%).
- Run
FunctionDeclarationInstantiation, which binds parameters and evaluates defaults. If this throws, call the capability's reject function with the error. This is why bad arguments reject instead of throwing.
- Otherwise call
AsyncFunctionStart, which runs the body.
- Return the capability's promise.
AsyncBlockStart is what AsyncFunctionStart uses. It runs the body in the async function's own execution context, using RunSuspendedContext. When the body completes without suspending, the closure calls the capability's resolve with the return value (or undefined), or reject with the thrown value. Then control goes back to the caller context. That means the body, up to its first await, ran inside the call, synchronously.
Await(value) does the following:
- Remember the async context.
promise = ? PromiseResolve(%Promise%, value). If value is already a native promise with the same constructor, this is the same object.
- Create
onFulfilled and onRejected closures. Each one calls RunSuspendedContext(asyncContext, completion) and ignores the result. onFulfilled resumes with a normal completion of the value; onRejected resumes with a throw completion of the reason.
PerformPromiseThen(promise, onFulfilled, onRejected), and then return to the caller context.
PerformPromiseThen enqueues a reaction job right away if the promise is already settled. Otherwise the job is enqueued when it settles. In both cases the resumption goes through HostEnqueuePromiseJob, which is the microtask queue in browsers and Node.js.
A teaching model: a driver that resumes a generator whenever the yielded promise settles. It reproduces the logic of steps 3 and 4 above, but it is not how engines implement it.
function run(generatorFunction, ...args) {
return new Promise((resolve, reject) => {
const generator = generatorFunction(...args);
function step(method, input) {
let result;
try {
result = generator[method](input);
} catch (error) {
reject(error);
return;
}
if (result.done) {
resolve(result.value);
return;
}
Promise.resolve(result.value).then(
(value) => step('next', value),
(reason) => step('throw', reason),
);
}
step('next');
});
}
run(function* (n) {
const a = yield Promise.resolve(n);
try {
yield Promise.reject(new Error('nope'));
} catch (error) {
console.log('caught', error.message);
}
return a * 3;
}, 21).then(console.log);
// → caught nope
// → 63
Edge cases
Returning a promise costs extra ticks, because return runs the function's resolve function, which sees a thenable and schedules NewPromiseResolveThenableJob. return await does not use that path, but it pays a different cost. Run the following in Node 24:
async function returnsPlain() { return 1; }
async function returnsPromise() { return Promise.resolve(1); }
async function returnsAwaited() { return await Promise.resolve(1); }
returnsPlain().then(() => console.log('plain'));
returnsPromise().then(() => console.log('return promise'));
returnsAwaited().then(() => console.log('return await'));
let chain = Promise.resolve();
for (let i = 1; i <= 4; i++) chain = chain.then(() => console.log('tick', i));
// → plain
// → tick 1
// → return await
// → tick 2
// → return promise
// → tick 3
// → tick 4
Do not use these counts to choose between forms. Choose by behaviour. The behavioural difference is error handling:
async function withReturn() {
try { return Promise.reject(new Error('x')); } catch { return 'caught'; }
}
async function withAwait() {
try { return await Promise.reject(new Error('x')); } catch { return 'caught'; }
}
console.log(await withReturn().catch((error) => 'not caught: ' + error.message));
console.log(await withAwait());
// → not caught: x
// → caught
Awaiting a thenable is slower than awaiting a native promise. PromiseResolve wraps the thenable in a new promise, which calls thenable.then from a job. The then method is not called synchronously:
const thenable = { then(resolve) { console.log('then called'); resolve('v'); } };
async function g() { console.log('awaiting'); const v = await thenable; console.log('got', v); }
g();
Promise.resolve().then(() => console.log('tick 1')).then(() => console.log('tick 2'));
// → awaiting
// → then called
// → tick 1
// → got v
// → tick 2
Trade-offs
async/await gives straight-line code, try/catch/finally, and loops over asynchronous steps, at the cost of one promise per call and at least one microtask hop per await. For hot paths that run millions of times, a function that might finish synchronously should not be async. Each await is also a point where other code can run, so state read before an await may be stale after it. Do not assume atomicity across an await.