Asynchronous JavaScript · Lesson 50 of 77 · concept
Promises: what they actually model
About 65 minutes.
By the end of this lesson you can
- Describe a promise as an object standing in for a value that has not arrived, and name its three states.
- Explain what "settles once, then never changes" rules out.
- Read a `.then` / `.catch` / `.finally` chain and say what each link receives.
- Explain why `.then` returns a new promise, and what returning a value or a promise inside a handler does.
- Explain why a `.then` callback never runs synchronously, even on a promise that has already fulfilled.
Read, not run. The sandbox on this site has no promise callbacks (nothing after a .then runs), so the examples in this lesson are shown rather than executed. Where an example cannot run, it says so and explains why instead of leaving you to find out.
The problem, restated
Explanation
With callbacks, the answer to "where is my value?" is: nowhere you can point at. You handed a function over and the value will be delivered into it, some time, by someone else. You cannot store the answer, pass it to another function, or say "give me both of these when they are ready" — because there is no thing to hold.
A promise is that missing thing. It is an ordinary JavaScript object that stands in for a value which has not arrived yet. It exists immediately, so you can put it in a variable, return it from a function, keep it in an array, or hand it to `Promise.all`.
That is the whole invention. Not new powers — everything a promise does could be done with callbacks — but a value you can hold and pass around while the real value is still in transit.
A cloakroom ticket
An analogy
You hand your coat over at a cloakroom and get a numbered ticket. The ticket is not the coat. You cannot wear it. But it is real, it is yours right now, and it is exactly what you need in order to get the coat later.
You can put the ticket in your pocket, give it to a friend so they can collect the coat, or collect two coats with two tickets. None of that was possible if all you had was a promise from the attendant to shout your name across the room.
Two more details make the analogy exact. The ticket can also come back as "sorry, your coat was damaged" — a rejection is a legitimate outcome, not a crash. And once your ticket has been honoured, it is spent: the attendant cannot later change their mind about which coat it was.
Three states, and only one transition
Explanation
A promise is in exactly one of three states. Pending: the answer is not in yet. Fulfilled: it succeeded, and it carries a value. Rejected: it failed, and it carries a reason — normally an `Error` object, for the same purpose an `Error` serves in a `throw`.
"Settled" is the umbrella word for fulfilled or rejected. The one rule that makes promises worth trusting is that a promise settles once and then never changes. Pending can become fulfilled or rejected; nothing else is possible. A settled promise cannot flip to the other outcome, cannot settle again with a different value, and cannot go back to pending.
That immutability is what lets you attach a handler whenever you like. Attach it before the answer arrives and it runs when the answer arrives; attach it an hour after the answer arrived and it still gets the same answer. Contrast a callback, which you must register before the event or miss it entirely.
then, catch and finally — what each one receives
Explanation
`.then(onFulfilled)` registers what to do with the value if the promise fulfils. `.then` also takes a second argument for the rejection case, but almost nobody writes it that way, because `.catch` reads better and covers more.
`.catch(onRejected)` registers what to do with the reason if the promise rejects. It is defined as `.then(undefined, onRejected)` — the same mechanism, spelled for humans. Placed at the end of a chain, it catches a rejection from any link in that chain, which is exactly the deduplication that nested callbacks could not achieve.
`.finally(onFinally)` runs either way — cleanup that has to happen whatever the outcome. Its callback receives no arguments at all, deliberately: it is for the case where you do not care what happened. And it is transparent, so the value or reason passes straight through it unchanged. `Promise.resolve(2).finally(() => 77)` still fulfils with 2, not with 77.
Every .then returns a NEW promise
A mental model
This is the sentence to memorise: `.then` does not return the promise you called it on. It returns a brand-new promise standing for "the result of running this handler". Chaining works because of that and for no other reason.
Whatever your handler does decides how that new promise settles. Return an ordinary value and the new promise fulfils with it. Throw and the new promise rejects with what you threw. Return a promise and the new promise adopts it — it waits for yours and then settles the same way, which is how you flatten a second asynchronous step into the same flat chain instead of nesting it.
So a chain is a pipeline: each link receives the previous link's output and produces the next link's input, and a rejection anywhere skips the remaining `.then` handlers until it finds a `.catch`. Read a chain by asking, at every link, what does this handler return?
Four things beginners get wrong
A common mistake
Forgetting the `return` inside a handler. `.then(url => { fetch(url); })` starts the request and returns `undefined`, so the next link receives `undefined` and nothing waits for the request. MDN calls the abandoned promise "floating". If a link starts asynchronous work, that work must be returned.
Expecting the handler to run now. Even on an already-fulfilled promise, the callback is queued as a microtask; MDN guarantees `then()` callbacks are never called synchronously. Any code you wrote after the `.then` line runs first.
Treating the promise as the value. `const total = getTotal();` where `getTotal` returns a promise leaves an object in `total`. `total + 1` becomes nonsense and `if (total)` is always true, because every object is truthy. You will meet this symptom again, in full, in the last lesson of this module.
Wrapping something that is already a promise. `new Promise((resolve) => { getThing().then(resolve); })` adds a layer that does nothing except lose the rejection. `new Promise` is only for wrapping an older callback-based API; if you already hold a promise, use it.
What promises fixed about callbacks
Why it matters
Compare with the nested example from the first lesson, point by point. Indentation: a chain stays flat no matter how many steps. Error handling: one `.catch` at the end instead of an `if (error)` at every level. Composition: `Promise.all` can take an array of promises, which was impossible when the pending answer was not an object.
The one that matters most is the quiet one. A callback error is a value in a parameter, and nothing forces anybody to look at it — an ignored error is silence. A rejection propagates down the chain on its own until something handles it, and a rejection that reaches the end unhandled is reported loudly by both browsers and Node. Failure became hard to ignore, and that is a language-level improvement, not a stylistic one.
Recap
Recap
A promise is an object you hold now, standing for a value that comes later. Pending, then fulfilled or rejected, once and permanently. `.then` handles the value, `.catch` the reason, `.finally` runs either way and receives nothing. Every `.then` returns a new promise, which is why chains flatten and why the `return` inside a handler matters. Handlers are always queued, never immediate.
Worked examples
Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.
Proof that a promise is not the value
The one thing about promises this sandbox can demonstrate honestly, and it happens to be the thing beginners most need to see. `Promise.resolve(42)` is fulfilled the instant it exists, and it is still an object — not 42.
1const ticket = Promise.resolve(42);2 3console.log(typeof ticket);4console.log(ticket instanceof Promise);5console.log(ticket === 42);6console.log(typeof ticket.then);7console.log(String(ticket));8 - line 5
`false`. The promise has already fulfilled with 42 and is still not 42. Reaching for the value means calling `.then`, or `await` in the next lesson.
- line 6
`.then` is just a method on an ordinary object. There is nothing exotic in here.
- line 7
`[object Promise]` — worth recognising, because it is what you see when a promise ends up somewhere a string was expected.
What it prints
objecttruefalsefunction[object Promise]
Wrapping an old callback API
The only place `new Promise` belongs: turning an error-first callback function into a promise. Compare it with `parseAge` from lesson 1 — same two outcomes, moved from a callback into `resolve` and `reject`.
1function readFilePromise(path) {2 return new Promise(function (resolve, reject) {3 readFile(path, function (error, contents) {4 if (error) {5 reject(error);6 return;7 }8 resolve(contents);9 });10 });11}12 13readFilePromise("notes.txt")14 .then(function (contents) {15 console.log("read " + contents.length + " characters");16 })17 .catch(function (error) {18 console.log("could not read it: " + error.message);19 });20 - line 2
The function you pass to `new Promise` runs immediately and synchronously. `resolve` and `reject` are handed to you; calling one of them settles the promise.
- line 5
The error-first branch becomes `reject`. Note the `return` on line 6 — settle once is a rule the wrapper has to keep too.
- line 17
One `.catch` at the end covers a rejection from anywhere above it. That is what replaced the repeated `if (error)` at every nesting level.
This one does not run here`readFile` does not exist here — the sandbox has no filesystem — and the handlers could not run anyway, because the promise job queue is never drained. Read it for the shape: callback in, promise out.
Chaining, and the missing return
Two chains, identical except for one keyword. The first passes its value along; the second loses it. This is the single most common promise bug, and the symptom — `undefined` in the next handler — never mentions the real cause.
1// Correct: each handler RETURNS, so the next link receives its result.2loadUser(7)3 .then(function (user) {4 return loadOrders(user.id);5 })6 .then(function (orders) {7 return orders.length;8 })9 .then(function (count) {10 console.log("orders: " + count);11 })12 .catch(function (error) {13 console.log("failed: " + error.message);14 });15 16// Broken: no return on the first handler.17loadUser(7)18 .then(function (user) {19 loadOrders(user.id);20 })21 .then(function (orders) {22 console.log(orders.length);23 });24 - line 4
Returning a promise makes the next link wait for it. Without this, the chain would move on immediately with `undefined`.
- line 19
The bug. `loadOrders` runs, but its promise is dropped — MDN calls it "floating". Nothing waits for it and its rejection is unhandled.
- line 21
`orders` is `undefined` here, so this throws "cannot read properties of undefined" — an error whose message points nowhere near line 19.
This one does not run here`loadUser` and `loadOrders` are asynchronous stand-ins that do not exist, and the sandbox never drains the job queue, so no handler in either chain would run.
finally passes the outcome through
`finally` is for cleanup that must happen either way. Two details surprise people: its callback gets no arguments, and it does not change the value flowing down the chain.
1startSpinner();2 3loadReport()4 .then(function (report) {5 console.log("rows: " + report.rows);6 })7 .catch(function (error) {8 console.log("failed: " + error.message);9 })10 .finally(function () {11 stopSpinner();12 });13 14// Transparency, stated exactly:15// Promise.resolve(2).finally(() => 77) fulfils with 2, not 7716// Promise.reject(3).finally(() => 88) rejects with 3, not 8817 - line 10
No parameters, on purpose. `finally` is for when you do not care which way it went — if you do care, use `.then` and `.catch`.
- line 11
The spinner stops whether the report loaded or blew up. Putting `stopSpinner()` in both handlers instead would work, and would be one edit away from a bug.
This one does not run here`loadReport` and the spinner functions are asynchronous UI stand-ins with no counterpart in the sandbox, and no handler would run without a job-queue drain.
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
`Promise.resolve(10)` produces a promise that has already fulfilled with 10. What do these three lines print?
const ticket = Promise.resolve(10);console.log(typeof ticket);console.log(ticket === 10);console.log(typeof ticket.then);
What does `.then(handler)` return?
You call `.then(f)` on a promise that fulfilled some time ago. When does `f` run?
Inside `new Promise((resolve, reject) => { ... })`, the code calls `resolve(1)` and then, on the next line, `reject(new Error("too late"))`. What is the outcome?
No exercise in this lesson
Every observable thing a promise does happens in a handler, and this sandbox never drains the promise job queue, so no handler runs (spec item RP-1). A test could only confirm that a promise object exists, which the worked example already shows.
Worth remembering
- Name a promise's three states and the rule about changing between them.
Pending, fulfilled (carries a value), rejected (carries a reason). Pending may become fulfilled or rejected exactly once; a settled promise never changes again, which is why a handler attached after it settles still receives the result.
- What does `.then(handler)` return, and why does it matter?
A new promise standing for the handler's result. Returning a value fulfils it, throwing rejects it, and returning a promise makes it adopt that promise — which is why chains stay flat and why a missing `return` sends `undefined` to the next link.
- What does `.finally(f)` pass to `f`, and how does it affect the chain?
Nothing — `f` receives no arguments. It is transparent: the original value or rejection reason flows straight through, so `Promise.resolve(2).finally(() => 77)` still fulfils with 2.
Check this lesson against the source
We wrote the explanation; we did not invent the facts. This is the page that backs them.
Check there: The three states (pending, fulfilled, rejected), that a settled promise never changes state again, and that then/catch/finally each return a new promise.
Check there: Under "Timing", that functions passed to then() are never called synchronously even for an already-resolved promise; and the chaining section on returning versus dropping a promise ("floating").
MDN — Promise.prototype.finally()
Check there: That the onFinally callback receives no argument, and that Promise.resolve(2).finally(() => 77) fulfils with 2.
MDN — Promise.prototype.then()
Check there: That then() returns a new promise, and how the handler's return value settles it.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown