Promises and Async Await
Date: 2026-09-28
A promise settles once and never changes.
thenalways returns a new promise, resolved by whatever its callback returns or throws — andasync/awaitis that same machinery written as straight-line code. Get those two facts right and the error handling follows.
A promise is an object standing for the eventual result of an operation, in one of three states — pending, fulfilled or rejected — that settles exactly once; async/await is syntax for writing promise-returning functions that suspend at each await until the awaited promise settles.
This note is the language’s model. How promises compare with callbacks and observables, the combinators and the concurrency traps are in Async Models; when the callbacks run is in Tasks and Microtasks.
The state machine
resolve(value)
┌──────────────► FULFILLED (value) ┐
PENDING ───┤ ├─ settled: final, forever
└──────────────► REJECTED (reason) ┘
reject(err) or a throw
later calls to resolve / reject → ignored
.then on an already-settled promise → callback still runs, but always
asynchronously, as a microtask
Never synchronous, even when already settled. Code after .then(...) always runs before the callback. That consistency is deliberate — a function that is sometimes sync and sometimes async is the source of the worst ordering bugs.
then returns a new promise
p.then(callback) returns p2, and p2 settles according to what callback does:
callback returns a plain value → p2 fulfils with it
callback returns a promise → p2 ADOPTS it: waits, then settles the same way
callback throws → p2 rejects with the error
no callback for this outcome → p2 settles the same way as p (passes through)
Adoption is why chains stay flat — returning a promise from a then doesn’t give you a promise of a promise. It works on any thenable (any object with a then method), which is how libraries’ promise types interoperate. Pass-through is why one .catch at the end covers the whole chain: a rejection skips every then without a rejection handler until one catches it.
async / await is the same thing
Chain and async function — equivalent:
function loadBasket(id) {
return getUser(id)
.then(user => getBasket(user.basketId))
.then(basket => basket.items)
.catch(err => { log(err); return []; });
}
async function loadBasket(id) {
try {
const user = await getUser(id);
const basket = await getBasket(user.basketId);
return basket.items; // resolves the returned promise
} catch (err) { // catches a rejection from EITHER await
log(err);
return [];
}
}- An
asyncfunction always returns a promise.return xfulfils it;throwrejects it — even a throw before the firstawait await xwrapsxin a promise, suspends the function, and resumes it as a microtask when it settles — the rest of the program keeps running meanwhile — The Event Loop- A rejected
awaitthrows at that line, so ordinarytry/catchworks
The error-handling trap: return versus return await
async function getPrice(sku) {
try {
return fetchPrice(sku); // ✗ returns the promise un-awaited:
// if it rejects, it rejects AFTER this try
// has exited — the catch never runs
} catch { return FALLBACK_PRICE; }
}
async function getPrice(sku) {
try {
return await fetchPrice(sku); // ✓ settles inside the try; catch runs
} catch { return FALLBACK_PRICE; }
}Outside a try, return await is redundant; inside one, it’s the difference between handled and unhandled. Many lint configs enforce it only inside try for exactly this reason.
Other things the model explains
- The
Promiseconstructor’s executor runs synchronously.new Promise(r => { console.log('now') })logs immediately. A throw inside the executor rejects the promise - The explicit-construction anti-pattern — wrapping something that already returns a promise in
new Promise(...). It adds nothing and usually loses rejections. Only wrap callback APIs - Unhandled rejections. A rejection that ends with no handler fires
unhandledrejectionin browsers (a console error, nothing else), and crashes the process by default in Node since v15. Report them to error tracking in both — Error Tracking - Top-level
await— allowed in modules only. It pauses the evaluation of every module that imports this one, so a slow top-levelawaitin a shared module delays the whole app’s start — Modules - No cancellation. A promise can’t be cancelled, only ignored; cancel the underlying operation with an
AbortController— Race Conditions
vs C#: async/await there is built on Task, which can run on another thread; here the continuation always comes back to the one JavaScript thread. Same syntax, different concurrency — async in C#.