← back to the section

async/await made asynchronous code look like ordinary code — so much so that many people write it without understanding the promises underneath. It works — until you hit an await that never resolves, a silently swallowed error, or sequential loading where parallel was needed. Let's break down the mechanism.

A three-state machine

A promise is an object promising a future result. There are three states, and the transition is one-time:

  • pending — waiting;
  • fulfilled — success with a value;
  • rejected — failure with a reason.

Once it moves to fulfilled or rejected (together — settled) — it's frozen forever: repeated resolve/reject calls are ignored. You subscribe to the result with then(onFulfilled, onRejected), and you can subscribe at any time, even an hour after completion: the callback will still get the result. A promise is not a "process" but a "cell holding a future value".

Promise callbacks run as microtasks — after the current synchronous code but before timers. The "synchronous code → microtasks → setTimeout" order is covered in the article on the event loop.

Chains: then always returns a new promise

The key to reading chains is three rules about what you return from then:

fetch("/api/user")
  .then((res) => res.json())     // returned a PROMISE → the chain waits for it
  .then((user) => user.name)     // returned a VALUE → it flies onward
  .then((name) => {
    console.log(name);           // returned nothing → undefined goes onward
  });
  1. Returned a value — the next then receives it.
  2. Returned a promise — the chain waits for it and takes its result (which is why chains are flat, not nested).
  3. Threw an exception — the chain switches to rejected.

The classic beginner mistake is to "nest" instead of "return":

// Bad: pyramid of doom, errors of the inner chain are invisible to the outer one
getUser().then((user) => {
  getOrders(user.id).then((orders) => { ... });
});

// Good: returned the promise — the chain is flat, errors are caught by one catch
getUser()
  .then((user) => getOrders(user.id))
  .then((orders) => { ... })
  .catch(handle);

Errors: fly down the chain to the nearest catch

reject and a thrown exception behave identically: they fly past every then without an error handler and land in the nearest catch:

fetch("/api")
  .then(parse)     // skipped on error
  .then(render)    // skipped
  .catch(showError) // ← the error is caught here
  .then(cleanup);  // and this runs: catch "healed" the chain

Two important details. First: catch returns an ordinary promise — after it the chain is "healthy" again, unless you re-throw from the catch. Second: a promise without a single catch produces an unhandled rejection on failure — an error that surfaces globally in the console and in Node can bring down the process. The rule: every chain you don't return to anyone must have a catch.

A separate grenade is fetch: it does not reject on HTTP errors. 404 and 500 are fulfilled with res.ok === false; fetch rejects only on network failures. The if (!res.ok) throw ... check is written by hand.

async/await: sugar over the same chains

An async function always returns a promise; await is "subscribe and wait" for any promise; try/catch around await is the same .catch:

async function load() {
  try {
    const res = await fetch("/api/user");
    if (!res.ok) throw new Error(res.status);
    return await res.json(); // a promise with this value will be returned
  } catch (e) {
    showError(e);
  }
}

The main await pitfall is unnecessary sequencing:

// 6 seconds: the second request waits for the first, though it doesn't depend on it
const user = await getUser();      // 3 sec
const news = await getNews();      // 3 sec

// 3 seconds: start both, wait together
const [user, news] = await Promise.all([getUser(), getNews()]);

await in a for loop is the same trouble from another angle: N requests run one at a time. If the items are independent — Promise.all(items.map(...)).

Combinators: all, allSettled, race

  • Promise.all — all or nothing: fulfilled with an array of results, but the first reject takes everything down. For "the page makes no sense without all the data".
  • Promise.allSettled — wait for all and get {status, value|reason} for each. For "show what loaded, report the rest".
  • Promise.race — the first one to settle (no matter how). The classic — a request timeout.
  • Promise.any — the first successful one. For requests to mirrors/fallbacks.

In short

  • Three states, a one-time transition; then can be called even after completion. Callbacks are microtasks.
  • then returns a new promise: a value flies onward, a returned promise is awaited, an exception switches to reject. Return, don't nest.
  • An error flies to the nearest catch; after catch the chain is healthy again. A chain without catch = unhandled rejection. fetch doesn't reject on 404/500.
  • An async function always returns a promise; try/catch around await = .catch. Independent requests — Promise.all, not sequential awaits.
  • all — all or nothing; allSettled — an outcome for each; race — the first of any kind; any — the first successful.
  • Event loop — where exactly promise callbacks run.
  • Asynchrony and the event loop in TypeScript — typing asynchronous code.
  • Iterators and generators — the final topic of the series, including for await..of.