Promises and async/await as event-loop constructs
A Promise settling does not call your callback right away — it schedules a microtask, and async/await is just syntax that hides this same scheduling behind pause-and-resume syntax, which is why every ordering puzzle involving await dissolves once you see it as microtask queuing rather than magic.
Promises and async/await as event-loop constructs
It is tempting to think of Promises and async/await as a separate concurrency system — some parallel-universe way of "waiting" that JavaScript bolted on top of its ordinary single-threaded rules. They are not. They are ergonomics built directly on top of the microtask queue: a Promise settling schedules a microtask, and await is syntax that splits a function in two and schedules the second half as a microtask. There is no new machinery underneath — just the stack, the task queue, and the microtask queue you already have a model for, arranged to look like synchronous code.
Once you see that, the ordering puzzles that make Promises feel unpredictable — "why did that .then run after my synchronous code, even though the promise was already resolved?" — stop being trivia and become predictions you can make yourself.
A promise is a state machine, not a value
A Promise holds exactly one of three states: pending (no result yet), fulfilled (succeeded, with a value), or rejected (failed, with a reason). It starts pending and can move to fulfilled or rejected exactly once — after that it is settled, permanently, and can never change state again.
const p = new Promise((resolve, reject) => {
// this executor function runs synchronously, right now
resolve(42); // moves p from pending to fulfilled
});Calling resolve or reject doesn't run any of the .then callbacks attached to the promise. It just flips the internal state and stores the value. The callbacks are handled separately — and that separation is the whole story.
Settling schedules a microtask — it does not call back synchronously
Here is the detail that explains almost everything else in this lesson: when a promise settles, its .then/.catch reactions are not invoked immediately, even if they were already attached and even if the promise was already settled. Instead, each reaction is wrapped up and pushed onto the microtask queue — the same queue from the microtask lesson, drained completely at the checkpoint after every task, before the loop moves on to rendering or the next task.
console.log('1: sync start');
Promise.resolve('value').then((v) => console.log('3: microtask', v));
console.log('2: sync end');
// Logs: 1, 2, 3 — never 1, 3, 2.Promise.resolve('value') creates an already-fulfilled promise. Attaching .then to an already-fulfilled promise still doesn't run the callback right there — it schedules a microtask for it. So the current synchronous task (which logs 1 and 2) always finishes running to completion first, and only once the stack empties does the checkpoint drain the microtask queue and run the .then callback. This is true no matter how "ready" the value already was; a promise callback is never a synchronous call.
async/await is syntactic sugar over exactly this
An async function is not a different kind of function underneath — it is ordinary JavaScript that always returns a Promise, and await is syntax for "attach a .then here and pause." Two rules cover the whole feature:
- An
asyncfunction always returns a Promise. If it returns a plain value, that value is wrapped in an already-fulfilled promise. If it throws, the returned promise rejects. await XevaluatesX. IfXis a promise, the function's execution pauses at that point, and everything after theawait— the "rest" of the function — is scheduled as a microtask reaction to run onceXsettles. Crucially, this pause does not block the thread: control returns immediately to whoever called the async function, and the event loop is free to keep running other tasks and microtasks in the meantime.
That second rule is the desugaring. This:
async function run() {
console.log('a');
await somePromise;
console.log('b'); // "the rest" — the continuation
}behaves exactly like this:
function run() {
console.log('a');
return somePromise.then(() => {
console.log('b');
});
}The body after await is, mechanically, a .then callback. await doesn't invent a new pausing mechanism — it takes a function, cuts it into "before" and "after" at each await, and wires the "after" piece up as a microtask reaction the same way .then would.
The ordering consequence: code after await is always deferred
Because await's continuation is a microtask reaction, it runs after the current synchronous task finishes — even if the awaited value was already available with no waiting involved at all.
async function demo() {
console.log('1: inside async, before await');
await 0; // 0 is not a promise, but await still yields
console.log('4: inside async, after await');
}
console.log('start');
demo();
console.log('2: after calling demo()');
setTimeout(() => console.log('5: timeout'), 0);
Promise.resolve().then(() => console.log('3: a plain microtask'));
// Logs: start, 1, 2, 4, 3, 5Walk it in order. demo() is called and runs synchronously up to the await — that's 1. Even though 0 is not a promise, the spec still treats await 0 as if it were Promise.resolve(0), so the function pauses right there and its continuation (console.log('4: ...')) is scheduled as a microtask — the first microtask queued in this whole script. Control returns to the caller immediately, which logs 2, then queues a macrotask (5, the timeout — tasks and microtasks are separate queues) and, after that, a second microtask via the explicit Promise.resolve().then. The top-level script finishes and the stack empties, so the checkpoint drains the microtask queue in the order things were queued: demo's continuation (4) went in first, so it runs before the explicit .then (3). Only once both microtasks are drained does the loop move on to the next task, which is the timeout (5).
The point to take away isn't just the exact interleaving of 3 and 4 — it's that 5 (the timeout) is guaranteed to run dead last, after every microtask, no matter how "instant" await 0 looks in the source. Yielding at an await always defers past the rest of the current task, and always resolves before the next task-queue item gets a turn.
Errors: a rejected await throws, and try/catch works normally
If the promise an await is waiting on rejects, the await expression throws at that exact point in the function — which means ordinary try/catch around it works exactly as it would for a synchronous throw:
async function loadUser() {
try {
const res = await fetch('/api/user'); // rejects on network failure
return await res.json();
} catch (err) {
console.error('load failed:', err);
return null;
}
}If you don't catch it, the rejection propagates the same way a thrown error would: it rejects the Promise that loadUser() itself returns. An uncaught rejection doesn't crash the thread — it just means the caller's .then/await on that promise sees a rejection too, and if nobody ever catches it, the runtime reports it as an "unhandled promise rejection."
Combinators: overlapping work, still one thread
Promise.all, Promise.race, and Promise.allSettled let you run several async operations "concurrently" — but that word needs care here. Nothing runs in parallel on multiple threads; each individual operation (a fetch, a timer) is handed off to the browser the same way any single awaited promise is, and your one thread is still doing exactly one thing at a time. "Concurrent" means their waiting periods overlap — you kick off three fetches back to back instead of awaiting each one before starting the next — not that three pieces of your JavaScript execute simultaneously.
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
// All three requests are in flight at once; Promise.all's own promise
// settles once every one of them has settled — fulfilled only if all
// fulfill, rejected as soon as any one rejects.Promise.race settles as soon as the first of its promises settles (fulfilled or rejected); Promise.allSettled waits for every one to settle and never short-circuits, giving you back the outcome — success or failure — of each. All three are just different ways of composing the same microtask-scheduled settlement you've already seen; none of them introduce new scheduling rules.
Where this goes next
Promises and await give you a way to sequence asynchronous work without blocking anything — but everything so far still runs on the one main thread, competing for the same stack and the same queues as your rendering and your input handling. The next lesson, Web Workers and the message boundary, covers the one real escape from that: a second OS thread with its own entirely separate event loop, reachable only by passing messages across a boundary.
Go deeper
- MDN — Promise — The full state-machine contract (pending/fulfilled/rejected), the combinators, and the exact microtask-scheduling language the spec uses.
- MDN — Making asynchronous programming easier with async and await — The desugaring of await into .then continuations, with more worked examples of the pause-and-resume model this lesson builds.
- v8.dev — Fast async functions and promises — V8's engineering account of how async/await is implemented as microtask-scheduled continuations under the hood.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- What are the three states of a Promise, and why can a settled promise never change state again?
- When a promise settles, does its .then callback run synchronously? What actually gets scheduled, and on which queue?
- State the two rules that fully desugar async/await into ordinary Promise + microtask behavior.
- Why does `await 0` still defer the rest of the function to a microtask, instead of continuing immediately?
- Walk through why a setTimeout callback queued alongside several promise resolutions always runs after all of them, even a 0ms timeout.
- What happens at an await point when the awaited promise rejects, and how does try/catch interact with it?
- In what precise sense are Promise.all's operations 'concurrent' if JavaScript is still single-threaded?