JavaScript Promises

6 minute read

A promise is an object that represents a value that isn’t ready yet — typically the eventual result of an async operation. It exposes a tiny API (.then, .catch, .finally) that lets you chain steps and handle errors once, rather than nesting callbacks. Promises are the foundation async/await is built on, so understanding them deeply pays off forever.

States and Lifecycle

A promise is always in one of three states:

   pending  ──resolve(v)──► fulfilled (value = v)
        │
        └──reject(e) ─────► rejected  (reason = e)

Once settled (fulfilled or rejected), a promise never changes state.

Creating a Promise

You’ll rarely write new Promise in app code — most APIs already return promises. But you do need to write one when wrapping a callback API or a non-promise event:

const wait = (ms) =>
  new Promise((resolve) => setTimeout(resolve, ms));

await wait(1000);
console.log("one second later");
function readJSON(url) {
  return new Promise((resolve, reject) => {
    fetch(url)
      .then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
      .then(resolve, reject);
  });
}

The executor (resolve, reject) => ... runs synchronously — it should kick off the async work, but the value/error gets delivered async.

Consuming a Promise — then / catch / finally

fetchUser(1)
  .then((user) => {
    console.log("got", user);
    return user.id;
  })
  .then((id) => fetchOrders(id))
  .then((orders) => console.log("orders", orders))
  .catch((err) => console.error("something failed", err))
  .finally(() => spinner.hide());

Key behaviours:

  • Each .then returns a new promise — that’s why chaining works
  • Returning a value → the new promise resolves with that value
  • Returning a promise → the chain waits for it (auto-unwrap)
  • Throwing → the chain jumps to the next .catch
  • .catch is just .then(undefined, onRejected)
  • .finally(fn) runs on both success and failure, doesn’t change the value

Auto-Unwrapping

Promise.resolve(1)
  .then(x => x + 1)                          // returns plain value → next .then gets 2
  .then(x => Promise.resolve(x * 10))        // returns a promise → chain waits → next gets 20
  .then(x => console.log(x));                // 20

You never end up with a Promise<Promise<T>> — promises flatten themselves.

Error Propagation

Errors thrown — or promises rejected — skip downstream .thens and land in the next .catch:

Promise.resolve(1)
  .then(x => x.toUpperCase())       // throws TypeError — x is a number
  .then(x => console.log("never"))  // skipped
  .catch(err => console.error("caught:", err.message));

Warning: A promise that rejects with no .catch anywhere in the chain triggers an unhandledrejection event. In modern Node, it crashes the process. Always have a .catch (or use async/await with try).

Promise.resolve / Promise.reject

Quick way to start a chain:

Promise.resolve(42);                // already-fulfilled
Promise.reject(new Error("oops"));  // already-rejected

// Useful for "wrap if not already a promise"
function ensurePromise(x) {
  return Promise.resolve(x);        // returns x if it's a promise, else wraps
}

Combining Promises — all, allSettled, race, any

Method Resolves when Rejects when Returns
Promise.all(arr) all fulfil any rejects array of values
Promise.allSettled(arr) all settle (good or bad) never array of {status,value} / {status,reason}
Promise.race(arr) first settles (either way) first rejects before any fulfils the first settled value
Promise.any(arr) first fulfils all reject (AggregateError) first fulfilled value
// All — parallel, fail fast
const [user, posts, comments] = await Promise.all([
  fetchUser(1), fetchPosts(1), fetchComments(1),
]);

// AllSettled — survive partial failures
const results = await Promise.allSettled([
  fetchUser(1), fetchOrders(1), fetchProfile(1),
]);
for (const r of results) {
  if (r.status === "fulfilled") console.log(r.value);
  else console.warn("failed:", r.reason);
}

// Race — timeout pattern
function withTimeout(p, ms) {
  return Promise.race([
    p,
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error("timeout")), ms)),
  ]);
}

// Any — first one that works
const firstReply = await Promise.any([
  fetch("/mirror-a"), fetch("/mirror-b"), fetch("/mirror-c"),
]);

Sequential vs Parallel

// ❌ Sequential without meaning to (each await blocks the next)
const a = await fetchA();
const b = await fetchB();
const c = await fetchC();      // takes a + b + c ms

// ✅ Parallel
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
// takes max(a, b, c) ms

If the steps are independent, start them all at once and Promise.all the array. If each step depends on the previous one’s result, sequential is correct.

await Recap

await pauses an async function until the promise settles. It does not block the thread — control returns to the event loop. Errors become regular thrown errors:

async function loadDashboard() {
  try {
    const user   = await fetchUser(1);
    const orders = await fetchOrders(user.id);
    render(user, orders);
  } catch (err) {
    showError(err);
  }
}

Full chapter on async/await is next — promises are the substrate it stands on.

Promisifying — Bridging Old APIs

import { readFile } from "node:fs";

function readFileP(path, encoding = "utf8") {
  return new Promise((resolve, reject) => {
    readFile(path, encoding, (err, data) => err ? reject(err) : resolve(data));
  });
}

const text = await readFileP("./data.json");

Node ships util.promisify(readFile) for the common Node-style (err, result) => … shape.

A Realistic Example — Retry With Backoff

The same retry logic from chapter 39, now in promise form:

async function retry(fn, { attempts = 3, baseDelayMs = 200 } = {}) {
  let lastErr;
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      lastErr = err;
      if (i < attempts - 1) {
        const delay = baseDelayMs * 2 ** i;
        await new Promise(r => setTimeout(r, delay));
      }
    }
  }
  throw lastErr;
}

// Usage
const config = await retry(() => fetchConfig(), { attempts: 5 });

Compare with the callback version in chapter 39. Same logic, half the code, real error propagation.

Cancellation — AbortController

JavaScript promises themselves can’t be cancelled. Modern APIs accept an AbortSignal so you can abort the underlying work:

const ctrl = new AbortController();

const req = fetch("/api/large", { signal: ctrl.signal });

setTimeout(() => ctrl.abort(), 5000);   // give up after 5s

try {
  const data = await req;
} catch (e) {
  if (e.name === "AbortError") console.log("cancelled");
  else throw e;
}

Common Mistakes

// ❌ Forgetting to return inside .then — breaks the chain
fetchUser().then(u => {
  fetchOrders(u.id);      // promise not returned!
}).then(orders => {
  console.log(orders);    // undefined
});

// ✅
fetchUser().then(u => fetchOrders(u.id))
           .then(orders => console.log(orders));

// ❌ No .catch anywhere
somethingThatMayFail();   // unhandled rejection warning / crash

// ❌ Wrapping a promise in `new Promise` ("promise constructor anti-pattern")
function bad() {
  return new Promise((resolve, reject) => {
    fetch("/x").then(resolve, reject);    // pointless
  });
}

// ✅
function good() {
  return fetch("/x");
}

// ❌ Awaiting in a tight loop sequentially when parallel is fine
for (const url of urls) await fetch(url);   // serial

// ✅
await Promise.all(urls.map(u => fetch(u)));

Best Practices Checklist

  • ✅ Return promises from .then callbacks to keep the chain flowing
  • ✅ Always end chains with .catch (or use async/await + try)
  • ✅ Use Promise.all for independent parallel work, allSettled when failure is acceptable
  • ✅ Use Promise.race for timeouts; Promise.any for “first success wins”
  • ✅ Use AbortController for cancellable work
  • ✅ Reach for async/await over manual .then chains in new code
  • ❌ Don’t wrap existing promises in new Promise
  • ❌ Don’t mix .then(...) callbacks with await for the same chain — pick one
  • ❌ Don’t ignore rejection — every async path needs a destination

What’s Next?

Promises form the API. async/await is the ergonomic sugar on top that lets you write async logic as if it were synchronous. Next chapter is where the previous five lessons pay off in spectacular code clarity.

← Previous: JS Callbacks Next: JS Async/Await →

Updated: