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
thencallbacks are always deferred to a microtask. thenreturns a new promise; returning a value continues the chain, returning a promise makes it wait, throwing sends a rejection down the chain.catchhandles 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.