Tags: web-dev concept

Promises and Async Await

Date: 2026-09-28


A promise settles once and never changes. then always returns a new promise, resolved by whatever its callback returns or throws — and async/await is 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 async function always returns a promise. return x fulfils it; throw rejects it — even a throw before the first await
  • await x wraps x in 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 await throws at that line, so ordinary try/catch works

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 Promise constructor’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 unhandledrejection in 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-level await in 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#.