How it works
The spec gives every promise four internal slots: [[PromiseState]], [[PromiseResult]], [[PromiseFulfillReactions]] and [[PromiseRejectReactions]], plus [[PromiseIsHandled]], used for rejection tracking (see error handling).
The constructor throws a TypeError if called without new or without a callable executor. It creates the promise as pending, calls CreateResolvingFunctions(promise), and then calls your executor with those two functions. A throw becomes a call to reject.
CreateResolvingFunctions creates resolve and reject that share one record, promiseOrEmpty. The first call to either one sets it to empty, which is why later calls do nothing. That empty record is also the exact meaning of "resolved". The steps of resolve(resolution) are:
- If
resolution is the promise itself, reject with a new TypeError.
- If
resolution is not an object, fulfill with it.
- Read
resolution.then. If the property access throws, reject with that error.
- If
then is not callable, fulfill with resolution.
- Otherwise enqueue a
NewPromiseResolveThenableJob and return. The job calls then.call(resolution, resolveFn, rejectFn) with a fresh pair of resolving functions for the promise, and rejects if the call throws.
FulfillPromise and RejectPromise store the result, clear both reaction lists, set the state, and call TriggerPromiseReactions, which enqueues one PromiseReactionJob per stored reaction.
then runs PerformPromiseThen. If the promise is pending, it appends a reaction record to each list. If it is already settled, it enqueues the reaction job right away. Either way the handler runs from the job queue. In browsers, HTML defines HostEnqueuePromiseJob to queue these jobs on the microtask queue; V8-based runtimes such as Node also run them from the microtask queue.
Edge cases
const self = new Promise((resolve) => setTimeout(() => resolve(self)));
self.catch((e) => console.log(e.constructor.name)); // → TypeError
const badGetter = Promise.resolve({
get then() { throw new Error('boom'); },
});
badGetter.catch((e) => console.log('rejected:', e.message)); // → rejected: boom
const thenable = { then(resolve) { console.log('then called'); resolve('x'); } };
const adopted = Promise.resolve(thenable);
console.log('sync');
adopted.then(console.log);
// → sync
// → then called
// → x
The thenable's then is not called in the line that resolved the promise. A job calls it later, so adoption costs extra microtask ticks (see chaining). The then property is read once, at resolution time. That is also why a plain object with a then method is treated as a promise by accident.
Promise.resolve(p) returns p only when p is a native promise and p.constructor matches the receiver. new Promise((r) => r(p)) always creates a new promise that follows p:
const a = Promise.resolve(1);
console.log(Promise.resolve(a) === a); // → true
console.log(new Promise((r) => r(a)) === a); // → false
class MyPromise extends Promise {}
console.log(MyPromise.resolve(a) === a); // → false
Trade-offs
Always-async handlers avoid the "Zalgo" problem, where a callback is sometimes synchronous and sometimes not, so you can reason about ordering without knowing the promise's state. The cost is a microtask hop even for an already-known value.
Eagerness means a promise is a running operation, not a recipe: to retry or delay it, wrap the creation in a function. Single settlement makes a promise safe to share between many consumers, but it cannot model streams or repeated events. Use async iterators or event emitters for those.