Overview

Most developers learn Promises by copying patterns. .then().catch(), Promise.all, and if they're lucky, they never think about it again. That works until it doesn't — until a promise resolves too early, an error gets swallowed, or a loop fires off a hundred requests when the intent was to run them one at a time.

This is the mental model that makes all of that obvious.

A promise is a state machine

Three states, and two transitions:

StateMeaning
pendingStill running
fulfilledCompleted with a value
rejectedFailed with a reason

A promise starts pending. It becomes fulfilled or rejected exactly once, and that transition is permanent. Calling .then() on an already-fulfilled promise schedules the callback to run — it doesn't re-run the original operation. This is why a promise is safe to pass around and attach multiple handlers to.

.then() returns a new promise

The single most important thing to internalize: .then() does not mutate the promise, it returns a new one. Chaining works because each .then() resolves to whatever the previous callback returned.

const result = fetch("/api/user")
  .then(res => res.json())          // returns a promise
  .then(user => user.name)          // returns a string
  .then(name => name.toUpperCase()); // returns a string

Because res.json() returns a promise, the next .then() waits for it. If a callback returns a plain value, the next .then() receives it immediately. If it returns a promise, the next .then() waits.

This is what makes async chaining work without nesting:

// Bad — nesting
fetch("/api/user").then(res => {
  res.json().then(user => {
    fetch(`/api/posts/${user.id}`).then(posts => {
      render(posts);
    });
  });
});

// Good — flat
fetch("/api/user")
  .then(res => res.json())
  .then(user => fetch(`/api/posts/${user.id}`))
  .then(res => res.json())
  .then(posts => render(posts));

Errors skip ahead

When a promise rejects, every subsequent .then() is skipped until a .catch() is found:

fetch("/api/user")
  .then(res => {
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  })
  .then(user => { /* skipped if the throw above happens */ })
  .then(user => { /* also skipped */ })
  .catch(err => console.error(err));   // catches it

This is a feature, not a bug. It's the async equivalent of try/catch, without the nesting. It also means .catch() placed at the end catches errors from anywhere in the chain above it — which is usually what you want, and occasionally what you don't.

A .catch() returns a new promise. If it returns a value, the chain continues as fulfilled. If it throws, the chain continues as rejected.

fetch("/api/user")
  .catch(() => ({ name: "unknown" }))   // recover from failure
  .then(user => render(user));          // runs with the fallback

The four combinators

MethodResolves whenRejects when
Promise.allAll fulfilledAny rejects (immediately)
Promise.allSettledAll settled, regardless of outcomeNever
Promise.raceFirst one settlesFirst one rejects
Promise.anyFirst one fulfillsAll reject

Promise.all is the one people reach for and the one that causes the most surprise. If three of five requests succeed and one fails, the whole promise rejects — you never see the successes. When you want to know the outcome of every request individually, use allSettled:

const results = await Promise.allSettled(urls.map(u => fetch(u)));

results.forEach(r => {
  if (r.status === "fulfilled") console.log("ok", r.value);
  else console.error("failed", r.reason);
});

Promise.race is the classic timeout pattern, though AbortSignal.timeout() is now the better approach. Promise.any is for "try several mirrors and use whichever responds first" — it resolves on the first success rather than the first settle.

The executor runs synchronously

This one surprises people who haven't thought about it. The function passed to the Promise constructor runs immediately, before the constructor returns.

console.log("1");

const p = new Promise(resolve => {
  console.log("2");
  resolve();
});

console.log("3");

p.then(() => console.log("4"));

// Output: 1, 2, 3, 4

The promise body runs in line with the rest of the code. Only the .then() callbacks are deferred to the microtask queue. This is why wrapping a synchronous throw in a promise doesn't help you — the throw happens synchronously, before you have a promise to catch on.

Microtasks run before macrotasks

Promise callbacks go on the microtask queue, which drains completely before the next task on the macrotask queue (timers, I/O callbacks).

setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("sync");

// Output: sync, promise, timeout

Even a zero-delay timer runs after all pending microtasks. This is why a promise chain that resolves immediately still logs after synchronous code. And it's why a promise that never resolves will not just "wait" — it blocks every pending .then() in the queue behind it.

The async/await translation

Everything above translates directly:

Promiseasync/await
.then(v => ...)const v = await ...
.catch(e => ...)try/catch
.finally(() => ...)finally { ... }
Returning a promise from .thenAwaiting it directly

An async function always returns a promise, even if its body returns a primitive. So async function f() { return 5; } is exactly equivalent to function f() { return Promise.resolve(5); }.

Mistakes that keep happening

MistakeWhat actually happens
arr.forEach(async item => { await ... })Does not wait. Use for...of or Promise.all(arr.map(...)).
new Promise(async (resolve) => {...})Errors inside the async executor are lost. Never use async in an executor.
No .catch() or try/catchUnhandledPromiseRejection. In Node, this can terminate the process.
Returning a value without return inside .thenThe chain resolves to undefined.
Using Promise.all for sequential dependenciesThe dependency doesn't exist yet — the promise resolves with garbage.

The rule that prevents most bugs

If you take nothing else from this: whenever you write .then(), ask what the callback returns. If the answer is "a promise I didn't await," you've found your bug before the debugger does.