ECMAScript Await Continuations and Promise Jobs
await looks like a pause in the middle of a function, but the language cannot leave that function sitting on the execution stack while other JavaScript runs. It records how to resume, returns control to the caller, and schedules the resumption when the awaited promise is ready.
An ECMAScript await continuation resumes a captured async execution through a PromiseReactionJob, which a browser host places in its microtask queue after the awaited promise is fulfilled or rejected.
The Short Answer: Await Resumes Through a Promise Job
An await continuation is the work that continues an async function after an await expression. It is not a callback placed in a queue to watch a promise, and it does not occupy the browser’s microtask queue while a promise remains pending.
Say an async function reaches this line:
const profile = await loadProfile();
console.log(profile.name);
The async evaluation pauses at await. Control returns to the code that called the async function. When the promise from loadProfile() is fulfilled, the suspended evaluation resumes, profile receives the fulfillment value, and the next statement can run.
That resumption is always asynchronous. The rule still applies when the value is already available:
async function showOrder() {
console.log('before await');
await 42;
console.log('after await');
}
console.log('script start');
showOrder();
console.log('script end');
script start
before await
script end
after await
42 is not pending on anything, but after await still runs later. The containing async evaluation pauses; the rest of the script does not.
The path crosses two specifications. ECMAScript defines the promise reactions and Jobs that resume the async execution. HTML defines how a browser schedules promise-related Jobs as microtasks. Calling both layers “the microtask queue” hides the point where the language hands work to its host.
That distinction becomes useful as soon as await, .then(), queueMicrotask(), and timers meet. Async/await gives you the surface syntax. Here we follow the machinery underneath it.
Jobs, Reactions, and Microtasks Are Different Layers
Start with the names. They describe related things, but they do not describe the same thing.
- An ECMAScript Job is a unit of scheduled language work. A PromiseReactionJob is the kind that runs a promise reaction.
- A PromiseReaction record stores a fulfillment or rejection handler together with information needed to process its result. A pending promise retains these records until it settles.
- A host is the environment embedding ECMAScript. A browser is one host; another runtime can supply different scheduling rules around the language.
- An HTML microtask is work in a browser event loop’s microtask queue. HTML maps promise-related ECMAScript Jobs into this queue.
- An HTML task runs work such as a timer callback. Tasks and microtasks occupy different queues.
- A microtask checkpoint repeatedly takes the oldest microtask and runs it until the queue is empty.
The bridge is HostEnqueuePromiseJob. ECMAScript calls this host hook when a promise Job is ready to be scheduled. In a browser, HTML’s HostEnqueuePromiseJob queues a microtask that runs the Job.
A stored reaction is not an enqueued microtask. If a promise is pending, its reactions wait inside the promise. Settlement turns those records into Jobs, and the host then schedules those Jobs.
Promise construction shows the other half of the timing rule. The executor passed to new Promise() runs synchronously, but a reaction attached with .then() never runs inline:
console.log('before constructor');
const booking = new Promise((resolve) => {
console.log('inside executor');
resolve('confirmed');
});
booking.then((status) => {
console.log(status);
});
console.log('after then');
before constructor
inside executor
after then
confirmed
The executor calls resolve before booking.then(...) is reached, so the promise is already fulfilled when the handler is attached. Attaching the handler creates ready reaction work and asks the host to enqueue it. The handler still waits for a microtask checkpoint.
Promises have pending, fulfilled, and rejected states. Keep resolved separate from fulfilled: a promise can be resolved to follow another promise and remain pending until that other promise settles. Promise basics develops those states from the API side.
A browser checkpoint drains until no microtasks remain. If one microtask appends another, the new one runs during the same checkpoint, after microtasks already waiting ahead of it. That is the scheduling model described in Microtasks and connected to tasks in Event loop: microtasks and macrotasks.
What the ECMAScript Await Operation Actually Does
The Await abstract operation spells out what happens after an async function evaluates the operand on the right of await.
Translate its work into six moves.
-
PromiseResolve(%Promise%, value)produces a promise for the awaited value. Ifvalueis an appropriate native promise, it can be used directly. A plain value produces an already-fulfilled promise. -
Await records the running async execution context. This context represents the suspended evaluation that must later continue after the
awaitexpression. -
Await creates fulfillment and rejection closures. Both closures capture that async execution context. The fulfillment closure knows how to resume it with a normal completion; the rejection closure knows how to resume it with a throw completion.
-
Await passes the promise and those closures to
PerformPromiseThen. This is an internal specification operation.PerformPromiseThenattaches to the normalized intrinsic promise without calling that promise’s public.then(), althoughPromiseResolvemay read the original operand’s publicthenproperty and a laterNewPromiseResolveThenableJobmay invoke the captured method. -
Await removes the async execution context from the execution-context stack and resumes the caller. Only the containing async evaluation pauses.
-
A fulfillment or rejection closure eventually restores the captured context and resumes it. The value of the
awaitexpression is either the fulfillment value or a thrown rejection reason.
Here is the boundary you can observe:
async function prepareInvoice() {
console.log('invoice: start');
const status = await Promise.resolve('paid');
console.log(`invoice: ${status}`);
return 1999;
}
console.log('caller: before');
const result = prepareInvoice();
console.log('caller: received promise');
result.then((cents) => {
console.log(`caller: ${cents}`);
});
console.log('caller: after');
caller: before
invoice: start
caller: received promise
caller: after
invoice: paid
caller: 1999
prepareInvoice() runs synchronously until it reaches await. It then returns a promise to its caller, so caller: received promise and caller: after print before the continuation.
When the await continuation runs, it prints invoice: paid and returns 1999. That return fulfills result, making the result.then(...) reaction ready. The final handler therefore runs as later reaction work, not as part of the await continuation already in progress.
Promise chains follow the same shape. One reaction runs, its result settles another promise, and that settlement makes the next reaction eligible. A chain is successive work, not one microtask containing the entire chain. Promises chaining shows how returned values and promises feed those later reactions.
The captured execution context is the missing piece in the phrase “await resumes later.” Local bindings and the current point in the async evaluation still matter after suspension because the closures created by Await retain the context needed to continue.
The payoff is code that reads from top to bottom. The price is that every await introduces an asynchronous boundary, even when the value is already fulfilled. Async, Modules & Modern JavaScript follows that boundary through promise composition, modules, and the event loop in the offline course.
Pending Versus Fulfilled: When Is the Job Enqueued?
PerformPromiseThen takes different branches depending on the promise’s state. The difference is registration versus scheduling.
| Awaited promise state | What PerformPromiseThen does | Is a promise Job enqueued now? |
|---|---|---|
| Pending | Appends fulfillment and rejection PromiseReaction records to the promise’s internal lists | No |
| Already fulfilled | Creates a fulfillment PromiseReactionJob and calls HostEnqueuePromiseJob | Yes |
| Already rejected | Creates a rejection PromiseReactionJob and calls HostEnqueuePromiseJob | Yes |
The first row is the one that queue explanations often lose.
Say await responsePromise receives a promise that is still waiting for a response. PerformPromiseThen creates the two reaction records used by Await and stores them on that pending promise. There is no continuation microtask circling the queue and checking its state.
Later, the promise fulfills. TriggerPromiseReactions takes each stored fulfillment reaction, creates a PromiseReactionJob for it, and asks the host to enqueue that Job. The browser then adds the corresponding microtask.
Now take await Promise.resolve('ready'). The promise is already fulfilled when PerformPromiseThen sees it. The operation creates the fulfillment Job immediately and calls HostEnqueuePromiseJob. “Immediately” describes enqueueing, not running. The current synchronous code continues until the host reaches a microtask checkpoint.
A plain value follows the fulfilled side after PromiseResolve wraps it:
async function readValue() {
console.log('reading');
const value = await 'ready';
console.log(value);
}
readValue();
console.log('outside');
reading
outside
ready
The value needs no future event, but the continuation still enters scheduling machinery. That is why removing an apparently redundant await can change ordering.
A rejected promise takes the same timing branch as a fulfilled one when it is already settled. The difference appears when its rejection closure resumes the async execution with a throw completion.
Trace the Queue One Statement at a Time
One browser example makes all four scheduling forms meet. Run this as a classic script:
const ready = Promise.resolve('ready');
async function report() {
console.log('async start');
await ready;
console.log('await continuation');
Promise.resolve().then(() => {
console.log('then inside continuation');
});
}
console.log('script start');
report();
Promise.resolve().then(() => {
console.log('outer then');
});
queueMicrotask(() => {
console.log('queued microtask');
Promise.resolve().then(() => {
console.log('then from queued microtask');
});
});
setTimeout(() => {
console.log('timer task');
}, 0);
console.log('script end');
script start
async start
script end
await continuation
outer then
queued microtask
then inside continuation
then from queued microtask
timer task
Now keep a ledger. The queue shown here is the browser’s microtask queue, not a universal ECMAScript queue.
| Scheduling event | Immediate output | Browser microtask queue after the event |
|---|---|---|
console.log('script start') | script start | empty |
report() starts | async start | empty before await |
await ready sees a fulfilled promise | none | await fulfillment Job |
outer .then(...) sees a fulfilled promise | none | await fulfillment Job, outer reaction Job |
queueMicrotask(...) runs | none | await fulfillment Job, outer reaction Job, queued callback |
setTimeout(...) registers a later task | none | unchanged |
| final synchronous log runs | script end | unchanged |
The script task is now finished, so the browser can perform a microtask checkpoint. HTML repeatedly dequeues the oldest microtask while the queue remains nonempty.
The await fulfillment Job runs first. It restores report’s captured async execution context, so await continuation prints. The .then(...) inside that continuation sees an already-fulfilled promise and appends its reaction Job to the end of the queue:
| Checkpoint step | Output | Browser microtask queue afterwards |
|---|---|---|
| Await continuation | await continuation | outer reaction Job, queued callback, inner reaction Job |
| Outer reaction | outer then | queued callback, inner reaction Job |
queueMicrotask callback | queued microtask | inner reaction Job, reaction Job added by queued callback |
| Inner reaction | then inside continuation | reaction Job added by queued callback |
| Reaction created inside queued callback | then from queued microtask | empty |
The queue reaches empty only after running the two Jobs appended during the checkpoint. Then the later timer task can run and print timer task.
Nothing in this order depends on treating await as a special queue with priority. The await continuation, promise handlers, and queueMicrotask callback all participate in browser microtask ordering. Ready work is appended in enqueue order.
Change ready to a pending promise and the first ledger changes. report() stores its await reactions but adds no microtask at that point. Whichever event later fulfills ready causes its reaction Job to be enqueued at that later position.
Timers belong to the host’s task machinery, which is why the timer waits until the current task and its microtask checkpoint finish. Scheduling with setTimeout and setInterval covers what delay values do and do not promise.
Thenables, Rejections, and Extra Promise Jobs
A thenable is an object with a callable then property. It can participate in promise resolution without being a native Promise.
Thenables add another possible piece of scheduled work. PromiseResolve obtains a promise for the value, and promise resolution can create a NewPromiseResolveThenableJob. That Job invokes the captured then method after the surrounding synchronous code has completed.
Here is the observable order:
const reservation = {
then(resolve) {
console.log('thenable method');
resolve('confirmed');
},
};
async function checkReservation() {
console.log('check start');
const status = await reservation;
console.log(status);
}
checkReservation();
console.log('script end');
check start
script end
thenable method
confirmed
The thenable method does not run while the await expression is first evaluated. Its Job runs later, resolves the promise being awaited, and that settlement makes the stored await reaction ready. The continuation follows as further promise work.
Rejection changes the completion delivered to the captured context:
async function saveDraft() {
try {
await Promise.reject(new Error('storage unavailable'));
} catch (error) {
console.log(error.message);
}
}
saveDraft();
console.log('save requested');
save requested
storage unavailable
Await’s rejection closure resumes the captured execution context with a throw completion. The rejection therefore behaves like a throw at the await expression, and the surrounding try...catch receives it. Error handling with promises follows the same rejection through longer chains.
Rules for Predicting Await Order Correctly
Start with synchronous execution and write down each output until the current call stack finishes. Promise executors belong in this pass.
When code reaches await, apply PromiseResolve, then inspect the resulting promise’s state. A pending promise stores reactions. An already-fulfilled or rejected promise causes the appropriate PromiseReactionJob to be created and handed to the host.
Keep stored reactions out of the microtask ledger. Add them only when settlement turns them into Jobs.
For browser code, append ready promise Jobs and queueMicrotask callbacks to the microtask queue in enqueue order. At the checkpoint, remove the oldest entry, run it, record any new microtasks it appends, and continue until the queue is empty.
Only then move to a later task such as a timer callback. Treat runtime-specific queues separately, because ECMAScript defines the language Jobs and host hooks, while the host defines how those requests fit its event loop.
That procedure handles plain values, pending promises, fulfilled promises, rejection, thenable assimilation, and promise chains without guessing.
Frequently asked questions
Is an await continuation a microtask?
Does await always resume asynchronously?
Does awaiting a pending promise immediately add a microtask?
Does await call the promise's public then method?
.then(). For a thenable, Promise subclass instance, or cross-realm Promise, PromiseResolve can read its public then property and a later NewPromiseResolveThenableJob can invoke the captured method.