Skip to content
academia.sh

Lesson 05 / 17

The Promise Concept

A promise's three-state machine, the result being decided exactly once, callbacks being deferred to a microtask, and each step of chaining producing a new promise.

Contents

The previous lesson closed with a gap: in the callback pattern, an asynchronous operation is not a value. It cannot be stored, returned, or handed to someone else. Closing that gap requires an object that represents “an operation whose result is not yet known.”

This object is called a promise. This lesson builds the promise’s state machine, how it is produced, and the rules of chaining; the measurement source also moves, in this lesson, from a callback interface to a promise interface.

The Three-State Machine

A promise is in exactly one of three states:

State Meaning
pending The result is not yet known
fulfilled Settled with a value
rejected Settled with a reason

Fulfilled and rejected share a common name: the settled state. Transitions are one-way: a pending promise can settle, but a settled promise never returns to pending and can never change its state.

These two constraints directly remove two of the ambiguities from the previous lesson: the result cannot be reported twice, because the second report is ignored; and it cannot be changed after being reported, because the state is permanent.

Producing a Promise

A function that returns a promise starts the work and immediately returns the object representing the result. The function given to the constructor is called the executor; it receives two functions: one that settles the result, one that rejects it.

const SOURCE = {
  A1: { delay: 30, value: 21.4 },
  B2: { delay: 10, value: 19.8 },
  C3: { delay: 20, value: 23.1 },
  D4: { delay: 15, error: "sensor fault" },
};

function requestMeasurement(station) {
  return new Promise((resolve, reject) => {
    const record = SOURCE[station];
    if (record === undefined) {
      setTimeout(() => reject(new Error(`unknown station: ${station}`)), 0);
      return;
    }
    setTimeout(() => {
      if (record.error) reject(new Error(`${station}: ${record.error}`));
      else resolve({ station, value: record.value });
    }, record.delay);
  });
}

requestMeasurement("B2").then(
  (measurement) => console.log("fulfilled:", measurement.station, measurement.value),
  (error) => console.log("rejected:", error.message),
);

requestMeasurement("D4").then(
  (measurement) => console.log("fulfilled:", measurement.station, measurement.value),
  (error) => console.log("rejected:", error.message),
);

console.log("both promises are pending");
both promises are pending
fulfilled: B2 19.8
rejected: D4: sensor fault

A faulty station was added to the source table; D4 rejects on every call. This station will be used to test error paths for the rest of the course.

The executor’s body runs synchronously; the timer is set up before the requestMeasurement call even returns. The returned value is a pending promise. This is why the message on the last line is printed first.

One point deserves attention: the work starts when the promise is created. A promise represents not “work to be started” but “the result of work that has already started.” The next lesson shows the practical consequence of this distinction.

The Result Is Decided Exactly Once

The state machine’s permanence is guaranteed by the language itself. However many times a decision is made inside the executor, the first one holds.

const promise = new Promise((resolve, reject) => {
  resolve("first decision");
  resolve("second decision");
  reject(new Error("late rejection"));
});

promise.then(
  (value) => console.log("value:", value),
  (error) => console.log("error:", error.message),
);
value: first decision

The second decision and the rejection were silently ignored. The job that the hand-written done flag did in the callback pattern is done here by the language itself.

Callbacks Are Always a Microtask

Even if a promise has already settled, its then callback does not run immediately; it is placed in the microtask queue. This removes the “sometimes synchronous, sometimes asynchronous” ambiguity from the previous topic.

const promise = Promise.resolve("ready value");

promise.then((value) => console.log("2 — first then:", value));
promise.then((value) => console.log("3 — second then:", value));
console.log("1 — synchronous");
queueMicrotask(() => console.log("4 — microtask registered after"));
1 — synchronous
2 — first then: ready value
3 — second then: ready value
4 — microtask registered after

The promise produced by Promise.resolve was already fulfilled the moment it was written; even so, its callbacks ran only after all the synchronous code had run. More than one then can be attached to the same promise, and all of them run, in registration order, in the same microtask drain.

This rule has a side effect too: since promise callbacks are microtasks, a long chain of promises calling one another can keep the task queue waiting. The starvation warning from the previous topic applies to promises as well.

Chaining: Every Step Produces a New Promise

A then call does not change the promise; it returns a new promise. The new promise’s result depends on what the callback returns:

  • If it returns an ordinary value, the new promise fulfills with that value.
  • If it returns a promise, the new promise waits for that promise’s result and settles with it.
  • If it throws, the new promise rejects with that error.

A rejection flows down the chain until it finds a callback that handles it. catch is just a form of then that handles rejection only; finally runs in both cases without changing the result.

const SOURCE = {
  A1: { delay: 30, value: 21.4 },
  B2: { delay: 10, value: 19.8 },
};

function requestMeasurement(station) {
  return new Promise((resolve) => {
    const record = SOURCE[station];
    setTimeout(() => resolve({ station, value: record.value }), record.delay);
  });
}

requestMeasurement("B2")
  .then((measurement) => {
    console.log("1 — raw measurement:", measurement.value);
    return measurement.value;
  })
  .then((value) => {
    if (value > 25) throw new Error("value out of range");
    return Math.round(value);
  })
  .then((rounded) => {
    console.log("2 — rounded:", rounded);
    return requestMeasurement("A1");
  })
  .then((measurement) => {
    console.log("3 — chain's second measurement:", measurement.station, measurement.value);
    throw new Error("error in processing step");
  })
  .catch((error) => {
    console.log("4 — caught:", error.message);
    return "recovery value";
  })
  .then((value) => console.log("5 — chain continues after catch:", value))
  .finally(() => console.log("6 — finally runs in every case"));
1 — raw measurement: 19.8
2 — rounded: 20
3 — chain's second measurement: A1 21.4
4 — caught: error in processing step
5 — chain continues after catch: recovery value
6 — finally runs in every case

The third step returned a promise, and the chain waited for it to settle; the error in the fourth step reached catch; since catch returned a value, the chain returned to the fulfilled state and continued.

This last behavior produces a common misconception: catch swallows the error and repairs the chain. If the error is meant to propagate, it has to be re-thrown inside catch.

Any object whose result is a promise can join this chain. The standard accepts, for this purpose, any object that has a method named then; such objects are called thenables. When one enters the chain, it is treated exactly like a real promise.

What Changed Compared to Callbacks

Three of the previous lesson’s four gaps are directly closed. The result is now a value: it can be stored, returned, put in an array. The error path can be written for the entire chain with a single catch. Contract violations are prevented by the language itself — a decision cannot be made twice, and a callback cannot be called synchronously.

The one gap that stays open is the possibility that the operation never settles at all. A promise stuck in pending can stay pending forever; the mechanism that solves this is a timeout, and it will be built in a later lesson.

Summary

  • A promise is a value representing an operation whose result is not yet known; it has three states, and its state does not change once settled.
  • The work starts when the promise is created; a promise represents the result of work that has already started.
  • The executor runs synchronously, but then callbacks are always deferred to a microtask.
  • then returns a new promise; returning a value continues the chain, returning a promise makes it wait, throwing sends a rejection down the chain.
  • catch handles rejection and returns the chain to the fulfilled state; if the error is meant to continue, it has to be re-thrown.

Next Step

Now that a single promise is a value, multiple promises can be treated as a collection. The next lesson introduces the combinators that wait for three stations together, take the first response, or pick the first successful response; the coordination function hand-written with a counter and a flag in the previous topic comes down to a single line. The same lesson also compares the combinators’ different behavior in the face of errors.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close