Module 5 · Asynchronous JavaScript ⏱ 19 min

Promises Fundamentals

By the end of this lesson you will be able to:
  • Describe a Promise as an object that stands for a single eventual result, and name its three states
  • Create a Promise with new Promise, driving it to resolution with resolve or to rejection with reject
  • Consume a result with .then and .catch, and explain why a settled Promise never changes

The last lesson ended on a wound. To run a second asynchronous step after the first, you nested its callback inside the first, then a third inside the second, until your code curled into a rightward triangle of if (err) checks. That shape, callback hell, was never a discipline problem — it was a missing data type. A callback is a function you hope gets called. There is no value to hold, no result to return, and nowhere central for errors to land.

A Promise fixes exactly that. It turns 'arrange to be told' into a real object you can store in a variable, return from a function, and hand to other code. A Promise stands for a result that may not exist yet — a value on its way, or a reason it failed. Where a callback gave you no representation of the future at all, a Promise hands you that future as a thing you can act on.

flowchart LR
  P["pending"] --> F["fulfilled<br/>(a value)"]
  P --> R["rejected<br/>(a reason)"]
  style P fill:#e0900b,color:#fff
  style F fill:#1e7f3d,color:#fff
  style R fill:#b91c1c,color:#fff
A Promise starts pending and settles once: fulfilled with a value, or rejected with a reason. The arrow points one way only.

Three states, and only one way through

Every Promise lives in exactly one of three states. It starts pending — the work has begun but has not finished. When the work succeeds it becomes fulfilled (often called resolved), carrying the value it produced. When it fails it becomes rejected, carrying a reason, usually an Error object. Once a Promise leaves pending for either of the other two, it is settled, and settled is permanent.

You create a Promise with new Promise(executor), where executor is a function you write that receives two levers handed to it by the engine: resolve and reject. Call resolve(value) to fulfil it, or reject(error) to reject it. The executor runs immediately, the instant you construct the Promise — not later, and not when someone gets around to consuming it.

Constructing a Promise runs the executor at once. Press Run.
const ok = true;
const loaded = new Promise((resolve, reject) => {
  if (ok) {
    resolve({ name: "Ada", role: "admin" });
  } else {
    reject(new Error("loading failed"));
  }
});

console.log(typeof loaded);               // "object"
console.log(loaded instanceof Promise);   // true
loaded.then((user) => console.log("hi " + user.name));

Consuming a Promise: then and catch

A Promise you never listen to is a tree falling unheard. To act on its result you attach handlers with .then() and .catch(). Hand .then() a function and it runs if the Promise fulfils, receiving the value; hand .catch() a function and it runs if the Promise rejects, receiving the reason.

These handlers do not fire the instant the Promise settles. A fulfilled Promise schedules its .then callback as a microtask, which runs after the current synchronous code finishes — the rule you met in the previous lesson. That is why a .then callback always logs after the lines beneath it, even when the value was ready immediately. Attaching a handler can also happen long after the Promise settled; a fulfilled Promise simply holds its value and calls a newly attached .then on the next microtask.

flowchart LR
  PR["producer<br/>new Promise(executor)"] --> M["the Promise<br/>(its state + value)"]
  M --> T[".then(handler)<br/>on fulfilment"]
  M --> C[".catch(handler)<br/>on rejection"]
  style M fill:#3776ab,color:#fff
  style PR fill:#b45309,color:#fff
  style T fill:#1e7f3d,color:#fff
  style C fill:#b91c1c,color:#fff
A Promise sits between the producer that settles it and the consumers that react to it.
Attach .then for the value and .catch for the reason. Both branches are covered.
function loadName(ok) {
  return new Promise((resolve, reject) => {
    if (ok) {
      resolve("Ada");
    } else {
      reject(new Error("no name found"));
    }
  });
}

loadName(true).then((name) => console.log("hello " + name));
loadName(false).catch((err) => console.log("failed: " + err.message));
A .then callback runs as a microtask, after the synchronous lines beneath it.
console.log("1: script start");

Promise.resolve().then(() => {
  console.log("3: microtask ran");
});

console.log("2: script end");

Why this beats nested callbacks

Step back and notice what changed. A callback forced you to decide what to do with the result at the very moment you started the work, by embedding the next step inside the call. A Promise separates starting the work from reacting to it. You can start the work in one place, store the Promise, and attach a handler somewhere entirely different — even pass the Promise to a function that does not know how the work was begun.

Because a Promise is a value, you can also return it from a function, the way you would return a number or a string. Callers that need the result attach their own .then. That single change — the future as a returnable value — is what dissolves callback hell, and it sets up the next lesson on chaining, where one Promise's result feeds the next.

Settling is final

A Promise settles once and only once. The first call to resolve or reject wins; every later call is silently ignored, and a resolve followed by a reject still leaves the Promise fulfilled. This immutability is a feature, not a limitation: once a consumer sees a fulfilled value, it can trust that value will never change underneath it.

The practical mistake is calling the wrong lever for the outcome — resolving when something failed, or rejecting on success. The Promise faithfully reports whatever you told it, so a resolve(error) looks, to every consumer, exactly like a success. Match the lever to the truth: good results resolve, failures reject. You will repair a swapped lever in the exercises below.

flowchart TD
  A["resolve(42) first"] --> S["settled: fulfilled with 42"]
  B["resolve(99) later"] -.->|"ignored"| S
  C["reject(err) later"] -.->|"ignored"| S
  style S fill:#1e7f3d,color:#fff
First settle wins. Later resolve or reject calls are ignored — the Promise keeps its first outcome.
Exercise

What does this print? A Promise is an object, even when its value was ready immediately.

const p = Promise.resolve(7);
console.log(typeof p);
console.log(p instanceof Promise);
Exercise

Write resolveLater(value) that returns a Promise which resolves to value. The simplest way is to return Promise.resolve(value).

function resolveLater(value) {
  // return a Promise that resolves to value
}
Exercise

This function should REJECT when amount is negative, but the author called resolve on the failure path. So a caller withdrawing a negative amount gets an Error object handed to their .then as if it were a success. Change the one lever on the failure path so that a negative amount rejects instead.

function withdraw(amount) {
  return new Promise((resolve, reject) => {
    if (amount < 0) {
      resolve(new Error("negative amount"));
    } else {
      resolve(amount);
    }
  });
}
Exercise

What does this print? The executor runs while the Promise is being constructed, before any .then is attached.

let runs = 0;
const p = new Promise((resolve) => {
  runs = runs + 1;
  resolve(runs);
});
console.log("executor ran " + runs + " times");
Exercise

Write makeRequest(ok) that returns a Promise. When ok is truthy, resolve with the object { status: 200, ok: true }. Otherwise reject with new Error("request failed"). This combines both levers in one executor.

function makeRequest(ok) {
  // resolve with {status: 200, ok: true} when ok is truthy,
  // otherwise reject with new Error("request failed")
}

Recap

  • A Promise is an object representing a single eventual result — a value or a reason — that you can store, return, and pass around.
  • It has three states: pending, fulfilled, rejected; once it leaves pending it is settled and never changes again.
  • Create one with new Promise((resolve, reject) => {...}); the executor runs immediately. Call resolve(value) or reject(error) to settle it.
  • Consume it with .then(handler) for the value and .catch(handler) for the reason; handlers run as microtasks.
  • Settling is final: the first lever wins, so always match the lever to the outcome, and never leave a rejection without a .catch.

Next you will chain Promises, so the result of one becomes the input to the next — the pattern that finally flattens callback hell for good.

Checkpoint quiz

A Promise is in the pending state. Which statement is true?

You run const p = new Promise((resolve) => { console.log('built'); resolve(); }); and never call p.then(). What is printed?

Go deeper — technical resources