Build a promise from scratch
Build a MyPromise class with states, then chaining, microtask scheduling, thenable adoption, combinators, and Promises/A+ context.
- 01Implement the promise coreRepresent pending, fulfilled, and rejected states, queue handlers, and settle only once.
- 02Make then chain correctlyReturn a new promise from
then, adopt returned thenables, and preserve thrown errors. - 03Compare with the platformExplain microtask timing, combinator behavior, Promises/A+, and where native promises do more.
Build the mental model before the class
The earlier promise lessons taught how to use promises: states, chains, combinators, errors, microtasks, and the event loop. This lesson turns those behaviors inside out by building a small, real MyPromise implementation.
A promise implementation stores one pending or settled state, remembers one result, queues reactions from then, resolves the promise returned by each handler, and schedules all handlers asynchronously.
| Version | New rule | Why it matters |
|---|---|---|
| 1. States | pending, fulfilled, rejected, a stored result, and a settle-once guard. | Executor throws become rejection reasons. |
| 2. Handlers | then records callbacks while pending and schedules them once settled. | Late then still receives the remembered result. |
| 3. Chaining | then returns a new promise and resolves it from the callback return. | Missing callbacks pass values or reasons through. |
| 4. Resolution | Returned promises and thenables are adopted with cycle and double-call guards. | Resolving a promise with itself rejects with TypeError. |
| 5. Microtasks | Handlers run through queueMicrotask, never inline. | Ordering matches native promises for the tested cases. |
You order and pay, then get a token with your order number. While the food is being made, people can wait for it; the counter marks ready or sold out once and tells everyone waiting.
- In real life: Your token is waiting
- In JavaScript:
pendingstate - In real life: The counter marks ready or sold out once
- In JavaScript:
fulfilledorrejectedwith a settle-once guard - In real life: People wait for the token update
- In JavaScript:
thenhandlers wait in a queue - In real life: The counter tells them after marking it
- In JavaScript: Microtasks run callbacks later
Where the analogy stops: A paper token can be lost or replaced. A promise represents one operation and never changes after fulfillment or rejection.
We will still use native Promise in production. Rebuilding one is a professional reading exercise: it makes promise bugs feel mechanical instead of mysterious.
Version 1: states, result, executor
CORE STATEStart with the smallest useful shell. A promise begins pending. It can become fulfilled with a value or rejected with a reason. Both transitions use the same guard: if the state is no longer pending, return immediately. That guard is why extra resolve and reject calls are ignored.
class MyPromise { constructor(executor) { this.state = "pending"; this.result = undefined; const resolve = (value) => { if (this.state !== "pending") return; this.state = "fulfilled"; this.result = value; }; const reject = (reason) => { if (this.state !== "pending") return; this.state = "rejected"; this.result = reason; }; try { executor(resolve, reject); } catch (error) { reject(error); } }}Line 3 stores the state. Lines 6 and 11 enforce “settle once.” Lines 16–18 run the executor and convert a synchronous throw into a rejection. This version cannot call handlers yet, but it already captures the heart of a promise: one outcome.
The executor runs synchronously during new MyPromise(...). It starts the work. Handlers registered by then run later; the executor does not wait for them.
Version 2: handlers and then
STEP THROUGHA promise becomes useful when other code can register reactions. then has two jobs in this version: if the promise is pending, store a handler record; if it is already settled, schedule that record with the remembered result.
A promise stores a state, a result, and a queue of handlers. Step through a pending promise that settles once.
script
const ticket = new MyPromise((resolve) => { release = resolve;}); ticket.then((value) => { console.log("first: " + value);});ticket.then((value) => { console.log("second: " + value);}); release("order ready");release("ignored");The important detail is that the value is not consumed. Both handlers receive "order ready". A promise is closer to a stored result plus a notification queue than to an event that disappears after the first listener runs.
let release;const ticket = new MyPromise((resolve) => { release = resolve;}); ticket.then((value) => { console.log("first: " + value);});ticket.then((value) => { console.log("second: " + value);}); release("order ready");release("ignored");Version 3: then returns a new promise
CHAININGNative then never mutates the original promise. It returns a new promise immediately. Later, when the handler runs, that new promise is resolved from the handler’s return value or rejected from a thrown error.
Every then returns a new promise. Follow what each callback returns and what the next promise adopts.
script
.then((value) => { return value + 3; }) .then((value) => { return MyPromise.resolve(value * 2); }) .then((value) => { console.log(value); });Read the chain one callback at a time. Line 3 returns a plain number, so the next promise fulfills with that number. Line 6 returns another promise, so the next promise adopts it. Line 9 receives the adopted value rather than a promise object.
MyPromise.resolve(2) .then((value) => { return value + 3; }) .then((value) => { return MyPromise.resolve(value * 2); }) .then((value) => { console.log(value); });Non-function handlers matter too. If onFulfilled is not a function, the value passes through. If onRejected is not a function, the reason keeps rejecting. That is the basis for catch.
Version 4: the Promise Resolution Procedure
THENABLESThe hard part is not storing a value. The hard part is deciding what to do with the value a handler returns. The Promise Resolution Procedure says: reject cycles, fulfill ordinary values, and adopt promise-shaped values by calling their then carefully.
Promise resolution is the heart of chaining: adopt promises and thenables, reject cycles, and ignore double settlement.
script
then(resolve, reject) { resolve("first"); reject("second"); resolve("third"); },}; MyPromise.resolve(thenable) .then((value) => value + " adopted") .then((value) => console.log(value));handler returns `42`handler returns `Promise.resolve("ok")`handler returns `{ then(resolve) { resolve("ok"); } }`handler throws `new Error("boom")`resolve a promise with itselfhandler returns `{ value: 1 }`
Sort each handler result by what the procedure does to the promise returned by then.
let nativeResolve;const native = new Promise((resolve) => { nativeResolve = resolve;});nativeResolve(native);native.catch((error) => { console.log("native: " + error.name + ": " + error.message);}); let myResolve;const mine = new MyPromise((resolve) => { myResolve = resolve;});myResolve(mine);mine.catch((error) => { console.log("mine: " + error.name + ": " + error.message);});In Node 22 and Chrome’s V8 engine, native self-resolution rejects with TypeError: Chaining cycle detected for promise #<Promise>. This lesson’s MyPromise rejects with the same shape and names #<MyPromise>. The exact wording is not part of Promises/A+, but the TypeError rejection is the required behavior.
Version 5: schedule handlers as microtasks
ORDER PROOFA promise implementation that calls handlers inline is broken, even when the promise is already fulfilled. Handlers must wait until the current stack finishes. Our implementation uses queueMicrotask with a promise fallback for older environments.
console.log("sync");MyPromise.resolve("mine").then((value) => console.log("my " + value));Promise.resolve("native").then((value) => console.log("native " + value));queueMicrotask(() => console.log("queued"));setTimeout(() => console.log("timeout"), 0);The output is sync, my mine, native native, queued, then timeout. The MyPromise handler is queued before the native handler because line 2 registers it first. Both promise handlers and the direct microtask beat the timer task.
The Microtasks in depth lesson explains the queue. The Event loop lesson explains why the timer waits. Here we only need the implementation consequence: schedule, do not call inline.
catch, finally, resolve, and reject
SMALL WRAPPERSOnce then and resolution work, the helpers are small. catch is then(null, onRejected). finally runs cleanup and then passes through the original value or reason, unless cleanup itself fails.
MyPromise.resolve("tea").then((value) => { console.log("value: " + value);}); MyPromise.reject("missing").catch((reason) => { console.log("caught: " + reason);}); MyPromise.resolve("saved") .finally(() => { return MyPromise.resolve("cleanup").then((value) => console.log(value)); }) .then((value) => console.log("after finally: " + value));The finally example returns a promise from cleanup. MyPromise.resolve(cleanup()).then(...) makes the final then wait before it sees "saved".
Combinators: many promises, one result
ALL & RACEStatic combinators are loops around MyPromise.resolve and then. They do not need secret engine hooks. They need careful bookkeeping: preserve input order, settle once, and treat plain values like already-fulfilled inputs.
const slow = new MyPromise((resolve) => { setTimeout(() => resolve("slow"), 20);});const fast = MyPromise.resolve("fast"); MyPromise.all([slow, fast, "plain"]).then((values) => { console.log(values.join(","));}); MyPromise.all([]).then((values) => { console.log("empty " + values.length);});const slow = new MyPromise((resolve) => { setTimeout(() => resolve("slow"), 20);});const fast = new MyPromise((resolve) => { setTimeout(() => resolve("fast"), 5);}); MyPromise.race([slow, fast]).then((value) => { console.log(value);});MyPromise.allSettled([ MyPromise.resolve("ok"), MyPromise.reject("bad"),]).then((results) => { console.log(results.map((item) => item.status).join("/"));}); MyPromise.any([ MyPromise.reject("no A"), MyPromise.resolve("yes B"),]).then((value) => console.log(value)); MyPromise.any([ MyPromise.reject("no A"), MyPromise.reject("no B"),]).catch((error) => { console.log(error.name + ": " + error.errors.join(","));});- Need user, permissions, and settings before rendering
- Whichever settles first: request or timeout
- Show which of five uploads succeeded and failed
- Use the first CDN mirror that succeeds
Choose the method that answers the feature's group question.
Promise.all rejects with the first rejection in time, but it does not cancel other work. Promise.race([]) has no first settlement, so it stays pending. Promise.any([]) rejects immediately with AggregateError because success is impossible.
Promises/A+ and native promises
SPEC CONTEXTPromises/A+ standardized the interoperable core: the shape of then and the resolution procedure. It did not define every native static method, browser unhandled-rejection events, async/await, or engine performance work.
| Question | MyPromise in this lesson | Native Promise | Promises/A+ |
|---|---|---|---|
| Scope | Teaching implementation for the core rules in this lesson. | Production-grade engine feature with host tracking and optimized scheduling. | Community spec for interoperable then behavior. |
| API surface | then, catch, finally, resolve, reject, all, race, allSettled, any. | Adds newer platform features such as withResolvers; modern browsers also have Promise.try. | Standardizes only the then method and resolution procedure. |
| Scheduling | Uses queueMicrotask so handlers never run synchronously. | Uses the host's promise job queue and unhandled rejection reporting. | Requires callbacks after the current stack; it does not define browser events or static methods. |
| Conformance | Mini tests compare ordering, values, rejection, thenables, and combinators. | Browsers and Node implement ECMAScript promises. | promises-aplus-tests can test compliant implementations; it is not installed in this repo. |
async function runScenario(PromiseLibrary) { const chain = await PromiseLibrary.resolve(2) .then((value) => value + 3) .then((value) => PromiseLibrary.resolve(value * 2)); const recovered = await PromiseLibrary.reject("lost") .catch((reason) => "fixed " + reason); const all = await PromiseLibrary.all([ new PromiseLibrary((resolve) => setTimeout(() => resolve("slow"), 20)), PromiseLibrary.resolve("fast"), "plain", ]); const race = await PromiseLibrary.race([ new PromiseLibrary((resolve) => setTimeout(() => resolve("slow"), 20)), new PromiseLibrary((resolve) => setTimeout(() => resolve("fast"), 5)), ]); return [chain, recovered, all.join(","), race].join(" | ");}The point is not that MyPromise is production-ready; it is that the core resolution, scheduling, and combinator rules agree on these small conformance cases.
const cases = [ ["value chain", (P) => P.resolve(1).then((value) => value + 1)], ["rejection recovery", (P) => P.reject("lost").catch(() => "fixed")], ["thenable adoption", (P) => P.resolve({ then(resolve) { resolve("thenable"); } })], ["all order", (P) => P.all([P.resolve("A"), "B"]).then((values) => values.join("-"))],]; for (const [label, run] of cases) { run(MyPromise).then((value) => console.log("my " + label + ": " + value)); run(Promise).then((value) => console.log("native " + label + ": " + value));}The official promises-aplus-tests package is a real conformance suite for A+ implementations. It is not installed here, so our tests are intentionally described as a mini suite. Native promises also include platform behavior outside A+, such as unhandled rejection tracking, Promise.withResolvers in Node 22, and Promise.try in newer browsers but not this Node runtime.
Practical use and misconceptions
You rarely ship a promise implementation. You do use this knowledge when debugging tests, reviewing promise utilities, adapting old callback APIs, and explaining why then callbacks do not run where someone expected.
Native promises are faster, integrated with the host, and tested across engines. MyPromise is a microscope, not a replacement.
After building one, a missing return, accidental synchronous handler, or confused Promise.all result order has a concrete cause.
- “A fulfilled promise calls then immediately.” It schedules a microtask.
- “then changes the original promise.” It returns a new promise.
- “Returning a promise creates a nested promise.” Resolution adopts it.
- “A thenable can call resolve and reject.” It can try, but only the first call counts.
- “Promise.all cancels the rest.” It fails fast without canceling other work.
- “Promises/A+ is the whole native API.” A+ standardized the core
thenbehavior; ECMAScript and hosts add more.
| Mistake | Symptom | Fix |
|---|---|---|
| Run handlers inline | Output order differs from native promises. | Always schedule handler records as microtasks. |
| Resolve returned promises as plain values | Next handler receives a promise object. | Use the resolution procedure to adopt thenables. |
| Forget the one-call guard | A hostile thenable changes outcomes twice. | Wrap resolve and reject with a shared called flag. |
Use completion order in all | Arrays do not line up with inputs. | Store each result at its original index. |
Practice exercises
5 EXERCISESType the three logs in order.
console.log("A");
MyPromise.resolve("B").then((value) => console.log(value));
console.log("C");The output is A, C, B. The promise is fulfilled, but the handler still waits for the microtask checkpoint.
Study the implementation and predict what the program prints.
MyPromise.prototype.catch = function (onRejected) {
return this.then(null, onRejected);
};
MyPromise.reject("offline")
.catch((reason) => console.log("handled " + reason));MyPromise.prototype.catch = function (onRejected) {
return this.then(null, onRejected);
};A missing fulfillment handler passes fulfilled values through. The rejection handler receives offline and logs handled offline.
Which scheduling API should replace the direct handler call?
then(onFulfilled) {
if (this.state === "fulfilled") {
onFulfilled(this.result); // bug: runs synchronously
}
}if (this.state === "fulfilled") {
queueMicrotask(() => onFulfilled(this.result));
}then callbacks must never run inline. Scheduling preserves native promise ordering.
What status string should this pattern produce for one fulfilled and one rejected input?
MyPromise.allSettled = function (values) {
const input = Array.from(values);
return MyPromise.all(input.map((value) =>
MyPromise.resolve(value).then(
(result) => ({ status: "fulfilled", value: result }),
(reason) => ({ status: "rejected", reason }),
)
));
};MyPromise.allSettled = function (values) {
const input = Array.from(values);
return MyPromise.all(input.map((value) =>
MyPromise.resolve(value).then(
(result) => ({ status: "fulfilled", value: result }),
(reason) => ({ status: "rejected", reason }),
)
));
};Each mapped promise fulfills with a status object, so MyPromise.all can wait for the whole report without fail-fast rejection.
Predict the final line when every input rejects.
MyPromise.any([
MyPromise.reject("A"),
MyPromise.reject("B"),
]).catch((error) => {
console.log(error.name + ": " + error.errors.join("|"));
});Both inputs reject, so any rejects with AggregateError. The demo logs AggregateError: A|B from the stored errors.
Check your understanding
8 QUESTIONSQuestion 1 of 8Which fields does the first
MyPromiseversion need?Choose an answer to see the explanation.
Question 2 of 8What does this print?
Read the code, then predictconsole.log("A"); MyPromise.resolve("B").then((value) => console.log(value)); console.log("C");Choose an answer to see the explanation.
Question 3 of 8What must
thenreturn for chaining to work?Choose an answer to see the explanation.
Question 4 of 8What should happen when a handler is not a function?
Choose an answer to see the explanation.
Question 5 of 8What does the Promise Resolution Procedure do with a thenable?
Read the code, then predictMyPromise.resolve({ then(resolve, reject) { resolve("first"); reject("second"); }, }).then((value) => console.log(value));Choose an answer to see the explanation.
Question 6 of 8What does
Promise.allpreserve?Read the code, then predictconst slow = new MyPromise((resolve) => setTimeout(() => resolve("slow"), 20)); const fast = MyPromise.resolve("fast"); MyPromise.all([slow, fast, "plain"]).then((values) => console.log(values.join(",")));Choose an answer to see the explanation.
Question 7 of 8What does
MyPromise.anyreject with when every input rejects?Choose an answer to see the explanation.
Question 8 of 8What did Promises/A+ standardize?
Choose an answer to see the explanation.
Key takeaways
- A promise implementation stores one state and one result, then settles once.
thenregisters handlers and returns a new promise for the callback outcome.- The Promise Resolution Procedure adopts promises and thenables, rejects cycles, and ignores double settlement.
- Handlers are microtasks, so even already-fulfilled promises never call callbacks inline.
- Combinators are bookkeeping around many calls to
resolveandthen. - Promises/A+ covers the core
thencontract; native promises add more platform behavior.
One-line summary: Build promises by protecting one state transition, resolving each returned value correctly, and scheduling every reaction after the current stack.
Up next: Classic utilities. The same build-it-yourself method will guide deep clone, deep equal, curry, memoize, and related helpers.