Promises and async
Promise reactions, combinators, async functions, await, async iteration, thenables, cancellation, and event-loop boundaries.
The idea
File 05 built a machine that produces values one at a time on demand, and a way to pause a function in the middle. This file adds the only missing piece: the pause is resumed by someone else, later.
Start by deleting the word “parallel”. There is one thread. A promise buys you nothing but the ability to say later precisely — to hand a value to code that will run after the current work finishes. Everything difficult about async JavaScript is an ordering question, and ordering questions have exact answers.
Three ideas do the work here. A promise is a box
that will eventually hold a value or an error, with a list of callbacks
to run once it does. A queue decides when those
callbacks run. And async/await is that same
machinery with the callbacks written out of sight — await
is file 05’s yield with the resumption scheduled for
you.
The promise, and the one part that is not deferred
A promise is in one of three states: pending, then
either fulfilled with a value or
rejected with a reason. Settling happens once and is
permanent — every resolve or reject call after
the first is silently ignored, with no error and no second chance.
“Settled” means fulfilled or rejected, either one.
The function you pass to new Promise is the
executor, and it runs right now,
synchronously, before the next line of the file. It is the only part of
a promise that is not deferred, and forgetting it explains a great deal
of confusing output order. A throw inside it becomes a
rejection, because the specification wraps it in a try/catch — so it
never escapes synchronously.
The shortcuts around the constructor are worth knowing precisely.
Promise.resolve(x) makes an already-settled promise, but
handed an existing promise it returns that same promise rather
than wrapping it. Promise.withResolvers() (ES2024) turns
the constructor inside out for the case where the resolve function is
needed somewhere the executor cannot reach.
One line in the file is not politeness but load-bearing: attaching a
.catch to a rejected promise nobody else handles. An
unhandled rejection terminates Node by default.
The chain is made of new promises
Every .then returns a new promise. Hold
that and the whole chain follows:
- returning a value from a handler fulfils the next promise with it;
- returning a promise makes the chain wait for it, rather than handing you a promise of a promise;
- throwing switches onto the rejection track;
- a
.catchthat handles an error puts you back on the success track, which is why a.thenafter a.catchstill runs; .finallytakes no argument and passes values through untouched.
The subtlety is the two-argument form.
then(onFulfilled, onRejected) registers two
siblings watching the same promise. If the
success handler throws, its sibling never sees it — that error belongs
to the next promise along, where a chained .catch
will find it. So the two forms are not stylistic variants: the
two-argument form is how you let a handler’s own failure escape.
Four combinators wait on several promises, told apart by two questions — what makes it finish early, and can it reject?
all— every result in input order, never settling order; bails on the first rejection.allSettled— never rejects; you get a record per input describing how it went.race— the first to settle, either way.any— the first to fulfil; if all reject, anAggregateErrorcarrying every reason.
async and await
An async function runs normally until the first
await, hands a promise to its caller, and finishes the rest
later. It always returns a promise, whatever you write
in the body.
That has a consequence people trip over constantly: an async
function never throws. A throw inside becomes a
rejection, so a try/catch around the
call catches nothing unless you awaited it. Inside the
function, try/catch around an
await works exactly as it looks, because await
re-throws the rejection at the pause point.
await also costs a trip through the queue even when
there was nothing to wait for — awaiting a plain value is still a
suspension. There is no fast path for something that turned out not to
be async.
The most common async mistake in real code is a placement error, not a misunderstanding: starting the work and waiting for it are separate acts.
const a = await fast, b = await slow; // starts, waits, then starts. Sum of durations.
const pa = fast, pb = slow; // both already running
const [a2, b2] = [await pa, await pb]; // max of durations.Both versions await both promises. Only one of them lets the two overlap.
Top-level await works, but only in a module, and it makes every importer wait for you — file 10 returns to what that means for module evaluation.
Async iteration is file 05 with one change
An async iterator has a
[Symbol.asyncIterator]() and a next() that
returns a promise of { value, done }.
for await…of is for…of with an
await inserted. That is the entire difference.
It also falls back to the ordinary synchronous iterator and awaits
each value on the way out, which is why for await handles a
plain array of promises.
A thenable is any object with a .then
method. The promise machinery accepts one anywhere a promise is
expected, which is how libraries with their own promise types
interoperate — and it costs an extra trip through the queue every time,
because that .then has to be called first.
The event loop
The model, in the order it happens:
- Your current run of synchronous code finishes. It is never interrupted.
- The microtask queue drains
completely — promise callbacks,
queueMicrotask— including anything added while draining. - Only then does the next macrotask get a turn: a
timer, some I/O,
setImmediate.
Two things follow. Microtasks always beat timers, no matter what delay you asked for. And a microtask that schedules more microtasks can starve the timer queue indefinitely.
Everything after an await is a microtask, which is the
practical reason async code interleaves the way it does. Not every
await costs the same, though: awaiting a plain value takes
one tick, awaiting a native promise takes another, and awaiting a
hand-written thenable costs one more still. Two async functions only
alternate evenly if they are paying the same price. Node adds
process.nextTick, which runs ahead of even the microtask
queue.
Cancellation is not a promise feature
Promises cannot be cancelled. Once started, a promise runs to
completion — there is no handle for stopping it. What
AbortController cancels is the operation,
which then rejects its own promise. The signal is a notification
channel, not an undo.
The rest of the timing family — setTimeout,
setInterval, setImmediate and their
clear* partners — are host functions rather than language
features, which is exactly why file 29 separates “what the spec
guarantees” from “what Node does”.
Error shapes
Async errors travel through the promise, never up the call stack. Every surprise in the last section of the file is that one fact:
- a
try/catcharound a call you did notawaitis dead code — the call returned a promise perfectly successfully, and the failure arrives long after the block exited; - a rejection carries whatever you threw, including
non-
Errorvalues; AggregateErroris whatPromise.anyproduces when everything rejected, with the reasons in.errors.
Where this leaves you
You can now answer the only two questions async code really asks: what has already run by this line, and which queue is this callback in. Ordering is decidable — trace the sync pass, drain microtasks, then take one macrotask.
Two threads carry forward. File 07 picks up the error half:
try/catch/ finally in full, what
finally can do to a return, and how errors
behave when they cross a boundary the language does not control. And the
queue model here is only half the story — the spec defines microtasks,
but the macrotask side belongs to the host, which is where file 29
goes.
While you read
- For each promise, separate what runs now from what got queued for later.
- For every
.then, name which promise it is attached to — the input or the output. - At every
await, say what the function has already done and what it deferred. - Ask what happens when nothing awaits a rejection.
- For each queued callback, place it in order relative to the others already waiting.
The file
Read one construct at a time and predict the order of the
output lines, not just their contents. That is the skill this file
trains. Mark anything you cannot explain with // ? in the
source.
// 06 — PROMISES, ASYNC/AWAIT, THE EVENT LOOP
//
// A reading file. Read it straight down: the comment above each construct gives
// its NAME, and `// =>` gives what the expression on its left evaluates to, so
// nothing is left hanging. Each section uses only what the lines just above it
// introduced.
//
// It still runs (`node 06-async.ts`) and prints nothing. This file is about
// ORDER, so several sections record what happened into a log array and then show
// you the finished log — that array IS the output you would otherwise have
// watched scroll past.
//
// Mark anything you can't explain out loud with // ?
// ─── 1. THE PROMISE CONSTRUCTOR ──────────────────────────────────────────────
//
// A promise is a box that will eventually hold a value or an error, plus a list
// of callbacks to run once it does. Nothing here is parallel — there is one
// thread. What a promise buys you is the ability to say "later" precisely.
const executorLog: string[] = [];
const explicit = new Promise<number>((resolve, reject) => {
// This body runs RIGHT NOW, before the next line of the file. It is the one
// part of a promise that is not deferred, and forgetting it explains a lot of
// confusing output order.
executorLog.push("executor ran");
const ok = true;
if (ok) resolve(1);
else reject(new Error("rejected"));
resolve(2); // ignored: a promise SETTLES ONCE and stays settled. No error, no
// second chance, no warning.
});
const ranBeforeTheNextLine = executorLog; // => ["executor ran"]
const settledValue = await explicit; // => 1 — the second resolve never happened
const stillAPromise = explicit; // => Promise { 1 } — the object, not the value
// A throw inside the executor becomes a REJECTION: the specification wraps the
// body in a try/catch, so it never escapes synchronously.
const throwingExecutor = new Promise(() => {
throw new Error("becomes a rejection");
});
const executorThrew = await throwingExecutor.catch((e: Error) => e.message);
// => "becomes a rejection"
const resolved = Promise.resolve(1); // an already-settled promise. Hand it an existing
const passthrough = Promise.resolve(resolved) === resolved; // => true — and it gives that
// same promise straight back rather than wrapping it again (28.4)
const rejected = Promise.reject(new Error("x"));
const handledRejection = await rejected.catch((e: Error) => e.message); // => "x"
// An unhandled rejection terminates Node by default, so attaching that handler is
// not politeness — it is the difference between running and not.
// The constructor inside out: sometimes the resolve function has to live
// somewhere the executor cannot reach, and before ES2024 you leaked it by hand.
const { promise: deferred, resolve: settleIt } = Promise.withResolvers<string>();
settleIt("resolved from outside");
const settledExternally = await deferred; // => "resolved from outside"
// ─── 2. THE .then CHAIN ──────────────────────────────────────────────────────
//
// Every .then returns a NEW promise. Hold on to that and the whole chain makes
// sense: what you RETURN from a handler decides what the next promise holds, and
// what you THROW switches it onto the error track.
const chainLog: string[] = [];
const chainResult = await Promise.resolve(1)
.then((v) => v + 1) // returning a value passes it along
.then((v) => Promise.resolve(v + 1)) // returning a promise makes the chain WAIT for it,
// rather than handing you a promise of a promise
.then((v) => {
throw new Error(`thrown at ${v}`); // a throw switches to the rejection track
})
.catch((e: Error) => `caught: ${e.message}`) // handling the error puts you back on the
// success track, which is why a .then after a .catch still runs
.finally(() => chainLog.push("finally ran")); // `finally` takes no argument and passes
// => "caught: thrown at 3" — the .catch put the chain back on the success track, and
// `finally` passed that value through untouched
const finallyRan = chainLog; // => ["finally ran"]
// Two handlers in ONE .then are siblings watching the same promise. If the
// success handler throws, its sibling never sees it — that error belongs to the
// next promise along, and only a chained .catch will see it. The two-argument
// form is how you deliberately let a handler's own failure escape (28.2).
const siblingLog: string[] = [];
const escaped = await Promise.resolve(1)
.then(
() => {
throw new Error("thrown by the success handler");
},
() => siblingLog.push("sibling reached"),
)
.catch((e: Error) => e.message); // => "thrown by the success handler"
const siblingNeverRan = siblingLog; // => [] — its sibling threw, and it was not asked
// ─── 3. COMBINATORS ──────────────────────────────────────────────────────────
//
// Four ways to wait on several promises. Tell them apart with two questions:
// what makes it finish early, and can it reject?
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const after = <T>(ms: number, value: T) => sleep(ms).then(() => value);
const failsAfter = (ms: number, message: string) =>
sleep(ms).then(() => Promise.reject(new Error(message)));
const all = await Promise.all([after(20, "slow"), after(5, "fast")]);
// => ["slow", "fast"] — results come in INPUT order, never settling order
const allRejects = await Promise.all([after(5, "ok"), failsAfter(1, "boom")]).catch(
(e: Error) => `rejected: ${e.message}`,
); // => "rejected: boom" — one rejection abandons the whole thing
const allSettled = await Promise.allSettled([after(1, "ok"), failsAfter(1, "boom")]);
// => [{ status: "fulfilled", value: "ok" }, { status: "rejected", reason: Error("boom") }]
// allSettled NEVER rejects; it reports on each input instead, and the `reason` is
// the thrown Error object itself, stack and all.
const raced = await Promise.race([after(5, "fast"), failsAfter(20, "slow failure")]);
// => "fast" — first to SETTLE wins, whichever way it settled
const racedToFailure = await Promise.race([failsAfter(1, "quick failure"), after(20, "slow")])
.catch((e: Error) => `rejected: ${e.message}`); // => "rejected: quick failure"
const anyFulfilled = await Promise.any([failsAfter(1, "boom"), after(10, "eventually")]);
// => "eventually" — first to FULFIL; rejections are ignored until none are left
const anyAllFailed = await Promise.any([failsAfter(1, "a"), failsAfter(2, "b")]).catch(
(e: AggregateError) => `${e.name} holding ${e.errors.length}`,
); // => "AggregateError holding 2"
const emptyAll = await Promise.all([]); // => [] — resolves immediately
const emptyAny = await Promise.any([]).catch((e: AggregateError) => e.name);
// => "AggregateError" — with nothing to fulfil, it can only reject
// ─── 4. ASYNC / AWAIT ────────────────────────────────────────────────────────
//
// The same machinery as above with the callbacks hidden. An async function runs
// normally until the first `await`, hands a promise to its caller, and finishes
// the rest later.
const asyncArrow = async () => 1; // every async form: arrow, expression,
const asyncExpr = async function () { return 1; }; // declaration, method, static, generator
async function asyncDeclaration() { return 1; }
class AsyncMembers {
async method() { return 1; }
static async staticMethod() { return 1; }
async *asyncGen() { yield 1; }
}
const alwaysAFunction = typeof asyncArrow; // => "function"
const alwaysReturnsAPromise = asyncDeclaration() instanceof Promise; // => true
// Always a promise, whatever you write — which has a consequence people trip
// over constantly. An async function never THROWS. A throw inside becomes a
// rejection, so a try/catch around the CALL catches nothing (30.3).
async function throwsAsync() {
throw new Error("becomes a rejection, not a synchronous throw");
}
const syncCatchLog: string[] = [];
try {
const promise = throwsAsync(); // this line succeeds: it returns a rejected promise
syncCatchLog.push("call returned normally");
await promise.catch(() => syncCatchLog.push("the rejection surfaced later"));
} catch {
syncCatchLog.push("never reached");
}
const whereItSurfaced = syncCatchLog;
// => ["call returned normally", "the rejection surfaced later"]
// `await` unwraps, and try/catch replaces .catch.
async function awaiting() {
try {
await throwsAsync();
} catch (e) {
return `caught: ${(e as Error).message}`;
}
return "not reached";
}
const caughtByAwait = await awaiting();
// => "caught: becomes a rejection, not a synchronous throw"
// Awaiting a plain value still costs a trip through the microtask queue. There is
// no fast path for something that turned out not to be async.
const awaitedPlainValue = await 42; // => 42
// The most common async mistake there is. Both functions await both tasks; only
// one lets them run at the same time. The difference is WHERE the await goes —
// starting the work and waiting for it are separate acts.
async function sequential() {
const a = await after(20, "a"); // starts, waits, and only then...
const b = await after(20, "b"); // ...starts. Total ≈ the SUM.
return [a, b];
}
async function concurrent() {
const pa = after(20, "a"); // both started before either is awaited
const pb = after(20, "b");
return [await pa, await pb]; // Total ≈ the MAX.
}
const sequentialStart = Date.now();
const sequentialResult = await sequential(); // => ["a", "b"]
const sequentialMs = Date.now() - sequentialStart;
const concurrentStart = Date.now();
const concurrentResult = await concurrent(); // => ["a", "b"] — same answer
const concurrentMs = Date.now() - concurrentStart;
const concurrentWasFaster = concurrentMs < sequentialMs; // => true — same results, and
// roughly half the wall clock. Nothing ran in parallel; the waiting overlapped.
// ─── 5. ASYNC ITERATION ──────────────────────────────────────────────────────
async function* asyncGenerator() {
for (let i = 0; i < 3; i++) {
await sleep(1);
yield i; // Symbol.asyncIterator, whose .next() returns a PROMISE of { value, done }
}
}
const asyncValues: number[] = [];
for await (const v of asyncGenerator()) asyncValues.push(v);
const collectedAsync = asyncValues; // => [0, 1, 2]
// `for await` falls back to the ordinary iterator and awaits each value on the
// way out, which is why it handles an array of promises too.
const mixedSource: unknown[] = [];
for await (const v of [Promise.resolve("a"), "b"]) mixedSource.push(v);
const awaitedEachValue = mixedSource; // => ["a", "b"] — the promise was unwrapped for you
const asyncIterable = {
async *[Symbol.asyncIterator]() {
yield "from Symbol.asyncIterator";
},
};
const customAsync: string[] = [];
for await (const v of asyncIterable) customAsync.push(v);
const fromCustomProtocol = customAsync; // => ["from Symbol.asyncIterator"]
// ─── 6. THENABLES ────────────────────────────────────────────────────────────
//
// Anything with a `.then` method counts as a promise here. That is how libraries
// with their own promise types interoperate — and it costs an extra trip through
// the queue every time, because that `.then` has to be called first (28.4).
const thenable = {
then(resolve: (v: string) => void) {
resolve("assimilated thenable");
},
};
const assimilated = await thenable; // => "assimilated thenable"
const wrapped = await Promise.resolve(thenable); // => "assimilated thenable" — Promise
// .resolve adopts it too, rather than storing the object
// ─── 7. THE EVENT LOOP ───────────────────────────────────────────────────────
//
// Here is the model. When the current run of code finishes, the MICROTASK queue
// (promise callbacks, queueMicrotask) drains COMPLETELY — including anything
// added while draining. Only then does the next MACROTASK (a timer, some I/O)
// get a turn.
//
// So microtasks always beat timers, and a microtask that schedules more
// microtasks can starve a timer indefinitely (29.3).
const order: string[] = [];
order.push("sync 1");
setTimeout(() => order.push("macrotask (setTimeout 0)"), 0);
queueMicrotask(() => order.push("microtask (queueMicrotask)"));
Promise.resolve().then(() => order.push("microtask (promise then)"));
process.nextTick(() => order.push("nextTick (Node-only queue)"));
order.push("sync 2");
// Everything after an `await` is a microtask too, which is the practical reason
// async code interleaves the way it does.
void (async () => {
order.push("sync part of an async fn");
await null;
order.push("after await — a microtask like any other");
})();
order.push("sync 3 — the async fn already returned at its await");
await sleep(5); // let every queue drain before reading the log
const observedOrder = order;
// => [
// "sync 1",
// "sync 2",
// "sync part of an async fn",
// "sync 3 — the async fn already returned at its await",
// "microtask (queueMicrotask)",
// "microtask (promise then)",
// "after await — a microtask like any other",
// "nextTick (Node-only queue)",
// "macrotask (setTimeout 0)",
// ]
// Everything synchronous first, in source order. Then the microtasks in the
// order they were queued. Then nextTick. Then the timer, last of all.
//
// That nextTick position is worth stopping on, because the rule you will read
// everywhere — "nextTick runs before promise microtasks" — is describing a
// different situation. It holds when the queues are drained at the end of a
// synchronous run, which is what a CommonJS script gives you:
//
// node -e "const a=[];process.nextTick(()=>a.push('tick'));queueMicrotask(()=>a.push('micro'));setTimeout(()=>console.log(a),0)"
//
// prints ["tick", "micro"]. An ES module body is itself evaluated from a job that
// is already at a microtask checkpoint, so the microtask queue drains first and
// nextTick waits its turn. Same runtime, same three calls, opposite answer —
// which is why "what is running me?" is part of every ordering question.
// Not every await costs the same, either. Two async functions only alternate
// evenly if they are paying the same price — see 29.5.
const ticks: string[] = [];
void (async () => {
await null;
ticks.push("awaited a non-promise");
})();
void (async () => {
await Promise.resolve();
ticks.push("awaited a native promise");
})();
void (async () => {
await { then: (r: (v: unknown) => void) => r(null) };
ticks.push("awaited a hand-written thenable");
})();
await sleep(5);
const tickOrder = ticks;
// => ["awaited a non-promise", "awaited a native promise", "awaited a hand-written thenable"]
// The thenable lands last because its `.then` has to be called from the queue
// before its result can be adopted.
// ─── 8. CANCELLATION AND TIMING ──────────────────────────────────────────────
//
// Promises cannot be cancelled: once started, a promise runs to completion. What
// AbortSignal cancels is the OPERATION, which then rejects its own promise.
const controller = new AbortController();
const abortable = new Promise((_, reject) => {
controller.signal.addEventListener("abort", () => reject(controller.signal.reason));
});
controller.abort(new Error("cancelled by controller"));
const abortReason = await abortable.catch((e: Error) => e.message);
// => "cancelled by controller"
const signalState = controller.signal.aborted; // => true
const interval = setInterval(() => {}, 1000);
clearInterval(interval); // an uncleared interval keeps the process alive forever
const timeout = setTimeout(() => {}, 1000);
clearTimeout(timeout);
const immediate = setImmediate(() => {}); // Node-only: runs after I/O, before timers
clearImmediate(immediate);
const timerHandleKind = typeof immediate; // => "object" — in Node a handle object, not
// the integer id a browser gives you
const preArmed = AbortSignal.abort().aborted; // => true — already aborted on arrival
const notYet = AbortSignal.timeout(50).aborted; // => false — it will be, in 50ms
// ─── 9. ERROR SHAPES ─────────────────────────────────────────────────────────
//
// Async errors travel through the promise, never up the call stack. Every
// surprise below is that one fact.
// A rejection carries whatever you threw, including things that are not Errors.
const bareString = await Promise.reject("a bare string").catch((e) => typeof e);
// => "string" — nothing forces a rejection reason to be an Error, which is why
// `e.message` is not always safe
const aggregate = await Promise.any([
Promise.reject(new Error("1")),
Promise.reject(new Error("2")),
]).catch((e: AggregateError) => [e.name, e.errors.length]);
// => ["AggregateError", 2] — the only combinator that collects every failure
// A finally that returns a promise delays the chain; one that THROWS replaces the
// outcome entirely, which is the quiet way a `finally` swallows a real error.
const swallowed = await Promise.reject(new Error("original"))
.finally(() => {
throw new Error("thrown in finally");
})
.catch((e: Error) => e.message); // => "thrown in finally"