JAVASCRIPT / PROMISES AND ASYNC PATTERNS
Promises and the pending-to-settled lifecycle
Create promises, settle them exactly once from pending to fulfilled or rejected, and predict when handlers see the stored result.
What you will learn
- Create a promise and settle it with resolve or reject from the executor
- Explain why a second resolve, reject, or throw after settling is a silent no-op
- Attach a handler long after settlement and still get the stored value or reason
- Stop trying to read a promise's value synchronously right after resolve
Understanding Promises and the pending-to-settled lifecycle
A promise is an object holding one internal state, which starts as pending, plus one slot for a result. The function you hand to new Promise, called the executor, runs synchronously before the constructor returns, and the resolve and reject arguments it receives are the only handles that can change that state. Calling resolve moves the promise to fulfilled and stores a value; calling reject moves it to rejected and stores a reason. Fulfilled and rejected together are called settled, which is simply the word for a state that will never change again.
That transition happens at most once and cannot be undone, and that is the entire point of the design. The engine checks the state before applying any later resolve, reject, or error thrown by the executor, and discards it, so a promise that has fulfilled with a value can never afterwards reject. The mental model that fits is a single-slot mailbox rather than an event emitter: it gets filled one time, and from then on everyone who looks inside finds the same thing. Because the result is stored rather than broadcast, attaching a handler ten seconds late works exactly as well as attaching one before settlement.
There is no supported way to read the state synchronously; the language gives you no p.state or p.value, only .then, .catch, and .finally, and those callbacks are always invoked after the currently running synchronous code has finished, even when the promise settled long ago. That guarantee exists so a function behaves the same way whether or not its promise happened to be settled already. One consequence worth internalising is that resolve is not a synonym for fulfil: passing a promise or any thenable to resolve makes your promise adopt that one's eventual outcome, so it stays pending until the inner one settles, and rejects if the inner one rejects.
Knowing the lifecycle also tells you what a promise cannot do. It cannot deliver a second value, cannot be reset, and cannot report progress, so any operation that produces more than one result over time needs a different tool. Recognising this early saves you from building event streams out of promises and wondering where the later values went.
const p = new Promise((resolve, reject) => {
console.log("executor runs immediately");
setTimeout(() => {
resolve("first");
reject(new Error("ignored"));
resolve("second");
}, 10);
});
console.log("right after construction, p is pending");
p.then(
(value) => console.log("fulfilled with:", value),
(err) => console.log("rejected with:", err.message)
);
setTimeout(() => {
p.then((value) => console.log("late subscriber still sees:", value));
}, 50);A promise makes exactly one irreversible transition out of pending, and the result stored at that moment is what every handler ever sees.
Worked examples
Throwing inside the executor
Shows that an uncaught throw in the executor rejects the promise, but only while the promise is still pending.
const bad = new Promise(() => {
throw new Error("thrown synchronously");
});
bad.catch((e) => console.log("caught:", e.message));
const sneaky = new Promise((resolve) => {
resolve("done");
throw new Error("too late");
});
sneaky.then(
(v) => console.log("fulfilled:", v),
(e) => console.log("never runs:", e.message)
);Example explained
Line 1The constructor wraps the executor call in a try/catch, so the throw in bad becomes a rejection with that Error.
Line 2bad is already rejected when .catch is attached, yet the handler still runs because the reason was stored.
Line 3In sneaky, resolve("done") settles the promise first, so the following throw is turned into a rejection attempt that is discarded.
Line 4The error object thrown after settlement is dropped entirely, with no warning, which is why order inside the executor matters.
Resolving with another promise
Demonstrates that resolve is not the same as fulfil when the argument is itself a promise.
const inner = new Promise((resolve) => {
setTimeout(() => resolve("inner value"), 20);
});
const outer = new Promise((resolve) => {
console.log("outer resolved with a promise");
resolve(inner);
});
outer.then((v) => console.log("outer fulfilled with:", v));
console.log("outer is still pending here");Example explained
Line 1resolve(inner) does not fulfil outer; it locks outer onto inner's eventual outcome.
Line 2outer therefore stays pending for about 20 milliseconds, even though resolve has already been called.
Line 3When inner fulfils, its value passes through to outer, which is why the handler receives the string and not the promise object.
Line 4Had inner rejected instead, outer would have rejected with the same reason despite resolve being the function that was called.
A resolve captured outside the executor
Shows that the state flips immediately on the first resolve call while the handler still waits for the synchronous code to finish.
let settle;
const once = new Promise((resolve) => {
settle = resolve;
});
let observed = "pending";
once.then((v) => {
observed = "fulfilled: " + v;
console.log(observed);
});
console.log("before:", observed);
settle("A");
settle("B");
console.log("after both settle calls:", observed);Example explained
Line 1Storing resolve in settle lets outside code settle the promise, a pattern used to wrap one-shot events.
Line 2settle("A") transitions the state to fulfilled at once, but the .then callback is not called during this synchronous block.
Line 3settle("B") sees a promise that is no longer pending and returns without changing anything, so B is lost.
Line 4The last synchronous log still reads pending because observed is only updated later, when the handler finally runs with A.
Important notes
Resolved is not a state name. resolve(anotherPromise) leaves your promise pending until that promise settles, and rejects it if that one rejects.
The executor body runs synchronously inside new Promise, so heavy blocking work placed there blocks the caller; only the handlers are deferred.
Common mistakes
Calling resolve on every setInterval tick or every click, expecting a stream of values; only the first call is kept and every later value vanishes with no error to point at the problem.
Treating resolve like return: the rest of the executor keeps running, so trailing side effects still happen and a following reject is silently dropped, leaving a failure invisible.
Trying to read the value straight away with let v; p.then(x => v = x); console.log(v), which logs undefined because handlers never run during the current synchronous block.
Try it yourself
Change, predict, then run
In a browser console, write delay(ms, value) that returns new Promise(resolve => setTimeout(() => resolve(value), ms)), then inside the timeout call resolve twice with different values and reject once afterwards. Attach one .then immediately and another after two seconds, and confirm both log only the first value.
Open the JavaScript workspaceCheck your understanding
A promise's executor calls resolve("A") and then reject(new Error("B")). A .then handler with both callbacks is attached 100 milliseconds later. What does that handler observe?
- Nothing, because handlers registered after a promise settles are never called
- The rejection reason B, because reject ran last and overwrote the earlier value
- The value A, because the promise settled as fulfilled and kept that result
- First A and then B, since both settle calls are delivered in order
Show answer
Settling stores the outcome on the promise, so a handler attached later is still invoked with the stored value A. Option 0 is tempting if you think of .then as subscribing to an event that has already fired, but a promise is a value container, not a broadcast. Option 1 fails because reject on an already-settled promise is a no-op, and option 3 fails because a promise can settle only once.