AniUI Academy

Promises in Depth

What sits under async and await — three states, why every then returns a new promise, fail-fast Promise.all versus allSettled, and the loop that costs you seconds.

15 min read

You have used await and it worked. Then a .then chain hands the next step undefined, or a page takes six seconds because ten independent requests queued up behind each other. Both are promise mechanics, and await hides them rather than removing them.

Three states and one result

A promise is an ordinary object that stands for a value arriving later. It is pending until it settles, then either fulfilled with a value or rejected with a reason. It settles once. After that the result is fixed, and any handler you attach later gets that same result immediately.

That last point surprises people: attaching .then does not start anything. The work began when the promise was created. .then only asks to be told.

Creating one, and when not to

new Promise takes a function receiving resolve and reject. Call one of them when the work finishes.

function wait(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

The legitimate reason to write this is wrapping an older callback-based API so the rest of your code can use await:

function readSetting(key) {
  return new Promise((resolve, reject) => {
    storage.get(key, (error, value) => {
      if (error) reject(error);
      else resolve(value);
    });
  });
}

What you should not do is wrap something that is already a promise:

// antipattern
function loadUser(id) {
  return new Promise((resolve, reject) => {
    fetch(`/api/users/${id}`)
      .then((response) => resolve(response.json()))
      .catch((error) => reject(error));
  });
}
 
// just this
function loadUser(id) {
  return fetch(`/api/users/${id}`).then((response) => response.json());
}

The wrapper adds nothing and quietly loses errors thrown outside the then callbacks. If you already have a promise, return it.

then, catch, finally

.then takes a callback for the fulfilled value. .catch handles a rejection. .finally runs either way and receives nothing — it is for cleanup like hiding a spinner.

The key fact is that every .then returns a new promise, resolved with whatever your callback returned. That is the entire mechanism behind chaining.

fetch("/api/orders/42")
  .then((response) => response.json())   // returns a promise
  .then((order) => order.customer.id)    // returns a number
  .then((id) => fetch(`/api/users/${id}`))
  .then((response) => response.json())
  .then((user) => console.log(user.name))
  .catch((error) => console.error("failed:", error.message))
  .finally(() => hideSpinner());

When a callback returns a promise, the chain waits for it and passes on its resolved value rather than the promise itself. That flattening is why the fetch on line four works.

And it is why forgetting return breaks everything:

loadOrder(42)
  .then((order) => {
    loadCustomer(order.customerId); // no return
  })
  .then((customer) => console.log(customer.name));
// TypeError: Cannot read properties of undefined

The second .then runs immediately with undefined, because the first callback returned nothing. A concise arrow — (order) => loadCustomer(...) — returns implicitly and avoids the trap.

How errors travel

A rejection skips every .then below it until it reaches a .catch. One .catch at the end therefore covers the whole chain. A throw inside any callback behaves the same way as a rejection.

loadOrder(42)
  .then((order) => {
    if (!order.paid) throw new Error("Order is unpaid");
    return order;
  })
  .then((order) => ship(order))
  .catch((error) => console.error(error.message)); // catches either failure

Put a .catch in the middle and the chain recovers: everything after it continues with whatever the catch callback returned. That is useful for fallbacks and confusing when accidental.

A promise that rejects with no handler anywhere becomes an unhandled rejection. In the browser it logs a warning you will miss; in Node it crashes the process by default. Every chain needs a terminal .catch, or an await inside a try.

There is a second form, .then(onFulfilled, onRejected), and it is not the same as .then(...).catch(...). The two-argument version cannot see an error thrown by its own success callback, because that handler is already running. The chained .catch can. Prefer .catch unless you specifically want to handle the previous step's failure while ignoring this one's.

async and await are the same machine

async makes a function return a promise no matter what you return. await pauses that function until the promise settles, then produces its value — or throws its rejection reason, so ordinary try/catch works.

async function loadUserForOrder(id) {
  try {
    const order = await loadOrder(id);
    if (!order.paid) throw new Error("Order is unpaid");
    return await loadCustomer(order.customerId);
  } catch (error) {
    console.error("failed:", error.message);
    return null;
  } finally {
    hideSpinner();
  }
}

Only the async function pauses. The rest of the page keeps running. And because the function returns a promise, the caller still has to await it — an async function called without await runs, but you get a promise and any rejection goes unhandled.

Try it yourself

Try it yourself
Loading playground...

The four combinators

Each has one situation it is for.

  • Promise.all(list) — you need every result and any failure means the whole operation failed. It rejects the moment one rejects, and the rest keep running in the background with their results discarded.
  • Promise.allSettled(list) — you want to know how each one went. It never rejects; you get { status, value } or { status, reason } per entry.
  • Promise.race(list) — first to settle wins, success or failure. Mostly used to add a timeout.
  • Promise.any(list) — first to succeed wins. Rejects only if all of them do. Use it for mirrors or fallbacks.
const [user, orders] = await Promise.all([loadUser(id), loadOrders(id)]);
 
const results = await Promise.allSettled(files.map(upload));
const failed = results.filter((result) => result.status === "rejected");
 
const data = await Promise.race([
  fetch(url),
  wait(5000).then(() => Promise.reject(new Error("Timed out"))),
]);

The loop that costs you seconds

This is the most common real performance mistake in JavaScript.

const users = [];
for (const id of ids) {
  users.push(await loadUser(id)); // each waits for the last
}
// 10 ids at 300ms each = about 3 seconds

If the requests do not depend on each other, start them all first:

const users = await Promise.all(ids.map((id) => loadUser(id)));
// about 300ms

ids.map calls loadUser immediately for every id, so all ten are in flight before Promise.all waits. Keep the sequential version only when each step genuinely needs the previous result, or when you are deliberately rate limiting.

Cancelling with AbortController

A promise has no cancel method. To stop a request in progress you pass a signal.

const controller = new AbortController();
 
const promise = fetch("/api/search?q=closures", {
  signal: controller.signal,
});
 
controller.abort(); // the fetch rejects with an AbortError
try {
  const response = await fetch(url, { signal: controller.signal });
} catch (error) {
  if (error.name === "AbortError") return; // expected, not a failure
  throw error;
}

This is how you drop stale results from a search box: keep the controller for the last request, abort it when a new keystroke arrives. AbortSignal.timeout(5000) gives you a signal that aborts itself, which is cleaner than racing a timer.

What to remember

  • A promise settles once; .then only subscribes, it does not start the work.
  • Every .then returns a new promise — always return from the callback, and use new Promise only to wrap callback APIs.
  • A rejection falls to the next .catch; a chain with none is an unhandled rejection.
  • await in a loop is sequential. Use map plus Promise.all for independent work, and AbortController to stop what you no longer need.

Check yourself

4 questions · pass 3/4 to unlock The Event Loop

up to 50
  1. 1.A .then callback runs count + 1 without returning it, and the next .then logs its argument. What is logged?

  2. 2.Five uploads run together and you need to report exactly which ones failed. Which combinator fits?

  3. 3.What does .then() return?

  4. 4.A for...of loop awaits fetchUser(id) for ten ids. What is the timing?

4 left to answer