Build a promise from scratch
Build a MyPromise class with states, then chaining, microtask scheduling, thenable adoption, combinators, and Promises/A+ context.
- 01Build the core of a promiseStore a pending, fulfilled, or rejected state, keep a list of handlers, and settle only once.
- 02Make then chain correctlyReturn a new promise from
then, adopt a returned promise or thenable, and pass thrown errors on. - 03Compare with the built-in PromiseExplain when handlers run, how combinators work, what Promises/A+ covers, and what the built-in
Promiseadds.
Build a promise to see how it works
Earlier lessons showed how to use promises: their states, chains, combinators, errors, microtasks, and the event loop. In this lesson you build your own small promise class, called MyPromise. Building one shows you why promises behave the way they do.
A promise is an object that holds one result that will arrive later. To build one, you need five parts:
- A state: pending, fulfilled, or rejected. It changes only once.
- A result: the value, or the reason it failed.
- A list of handlers: the functions given to
then, waiting for the result. - A
thenmethod that returns a new promise for each handler's outcome. - A rule that handlers always run as microtasks: small jobs that run right after the current code finishes, never right away.
You build MyPromise in five versions. Each version adds one rule. Read each row of the table as: "in this version, the class learns this rule, and this is why it matters."
| Version | What the class learns | Why it matters |
|---|---|---|
| 1. States | It stores pending, fulfilled, or rejected, keeps one result, and settles only once. | An error thrown in the executor becomes the rejection reason. |
| 2. Handlers | then saves handlers while the promise is pending, and schedules them once it settles. | A handler added late still gets the stored result. |
| 3. Chaining | then returns a new promise and settles it from what the handler returns. | A missing handler passes the value or reason on. |
| 4. Resolution | A returned promise or thenable is adopted, and only its first call counts. | A promise resolved with itself rejects with a TypeError. |
| 5. Microtasks | Handlers run through queueMicrotask, never right away. | The run order matches the built-in Promise in every tested case. |
You order, pay, and get a token with your order number. While the food cooks, people can wait for it. The counter marks the order ready or sold out one time. Then it tells everyone who is waiting.
- In real life: Your token says your food is still cooking
- In JavaScript: The
pendingstate - In real life: The counter marks the order ready or sold out, once
- In JavaScript:
fulfilledorrejected, guarded so it happens once - In real life: People wait to hear about your token
- In JavaScript:
thenhandlers wait in a list - In real life: The counter tells them after it marks the order
- In JavaScript: Handlers run later, as microtasks
In real apps you will still use the built-in Promise. Building one yourself is practice in reading code. Once you have seen every part, you can explain a promise bug step by step.
Version 1: states, result, and executor
CORE STATEStart with the smallest class that works. A new promise starts pending: it has no result yet. Later it settles, which means it gets its final result. It can settle in one of two ways:
- Fulfilled, with a value. The
resolvefunction does this. - Rejected, with a reason. The
rejectfunction does this.
Both functions start with the same check, called a guard: if the state is no longer pending, stop right away. This guard is why extra calls to resolve and reject do nothing.
The executor is the function you pass to new MyPromise(...). The class calls it right away and hands it resolve and reject.
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 7 and 12 are the guards. Line 18 calls the executor. If the executor throws an error, line 20 catches it and rejects the promise. This version can't run handlers yet. But it already has the most important rule: a promise gets one outcome.
this.state starts as "pending". The first call to resolve or reject moves it once; the guards on lines 7 and 12 ignore every later call.The executor runs right away, during new MyPromise(...). It starts the work. Handlers added with then run later, after the current code finishes.
const order = [];const ticket = new MyPromise((resolve) => { order.push("executor"); resolve("ready");});order.push("after new");ticket.then((value) => console.log(order.join(" -> ") + ": " + value));Line 2 creates the promise, so line 3 runs right away and adds "executor" to the order array. Line 4 fulfills the promise with "ready". Line 6 still runs before the handler. Then the handler from line 7 prints executor -> after new: ready.
const saved = new MyPromise((resolve) => { resolve("saved"); throw new Error("late");});saved.then((value) => console.log(value));const failed = new MyPromise(() => { throw new Error("offline"); });failed.catch((error) => console.log("caught: " + error.message));const adopted = new MyPromise((resolve) => { resolve({ then: (fulfill) => setTimeout(() => fulfill("adopted"), 0) }); throw new Error("after resolve");});adopted.then((value) => console.log("adopted: " + value));This example has three executors:
- The first calls
resolveon line 2, then throws on line 3. The promise already has its value, so the throw can't replace"saved". Line 5 printssaved. - The second throws on line 6 while the promise is still pending. So the promise rejects, and line 7 prints
caught: offline. - The third calls
resolveon line 9 with a thenable: an object with athenmethod. Version 4 explains thenables. The promise is still waiting for the thenable when line 10 throws. But callingresolvealready locked the promise, so the throw is ignored. Line 12 printsadopted: adopted.
Version 2: handlers and then
STEP THROUGHA promise is useful when other code can react to its result. A handler is a function you pass to then. In this version, then does one of two things:
- If the promise is pending, it saves the handler in a list.
- If the promise has settled, it schedules the handler to run soon, with the stored result.
A promise keeps a state, a result, and a list 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");Both handlers get "order ready". Running a handler doesn't use up the value. A promise is a stored result plus a list of handlers waiting for it. It is not like an event, which is gone once its listeners have run.
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");const request = MyPromise.reject("offline");request.then(null, (reason) => console.log("handled later: " + reason));Line 1 rejects the promise before any handler is added. Line 2 adds a handler later. It still gets the stored reason in a microtask, and prints handled later: offline.
An unhandled rejection is a rejection that no handler ever catches. Browsers and Node.js warn you about those. MyPromise keeps the rejection for later handlers, but it never sends that warning.
Version 3: then returns a new promise
CHAININGThe built-in then never changes the promise you call it on. It returns a new promise right away. Later, when the handler runs, the new promise settles from what the handler does:
- The handler returns a value: the new promise fulfills with that value.
- The handler throws an error: the new promise rejects with that error.
- The handler returns a promise: the new promise waits for it and takes its result. This is called adopting the promise.
Every then returns a new promise. Follow what each handler 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 handler at a time. Line 3 returns a plain number, 5, so the next promise fulfills with 5. Line 6 returns another promise, so the next promise adopts it. Line 9 gets the adopted value, 10, not a promise object.
then gives back a new promise. Line 3 returns 5. Line 6 returns a promise for 10, so the next promise adopts it, and line 9 logs 10.MyPromise.resolve(2) .then((value) => { return value + 3; }) .then((value) => { return MyPromise.resolve(value * 2); }) .then((value) => { console.log(value); });const original = MyPromise.reject("offline");const next = original.then();console.log("new promise: " + (next !== original));next.catch((reason) => console.log("passed through: " + reason));MyPromise.resolve("tea").then(() => { throw new Error("no tea"); }) .catch((error) => console.log("caught: " + error.message));Line 1 creates a rejected promise. Line 2 calls then with no handlers at all. Line 3 checks that then returned a different promise, and prints new promise: true. Line 4 catches the same "offline" reason, because nothing handled it on the way. Line 5 throws inside a handler. Line 6 catches that new error and prints caught: no tea.
So then must also deal with missing handlers. The first function you pass to then is the fulfillment handler; it runs on success. The second is the rejection handler; it runs on failure.
- No fulfillment handler: the value passes through to the next promise.
- No rejection handler: the reason passes through, so the next promise rejects too.
- A handler that throws: the next promise rejects with that error.
These rules are all that catch needs. You will build it from then later in this lesson.
Version 4: the Promise Resolution Procedure
THENABLESStoring a value is easy. The hard part is deciding what to do with the value a handler returns. The rules for this have a name: the Promise Resolution Procedure. They say:
- If the value is the promise itself, reject with a
TypeError. A promise can't wait for itself. - If the value is a plain value, like a number, fulfill with it.
- If the value is a thenable, read its
thenonce, call it, and adopt the result. Every promise is a thenable, and so is any object with athenmethod. - Only the first call to
resolveorrejectfrom a thenable counts.
The resolution rules are the heart of chaining: adopt promises and thenables, reject a promise resolved with itself, and count only the first call.
script
then(resolve, reject) { resolve("first"); reject("second"); resolve("third"); },}; MyPromise.resolve(thenable) .then((value) => value + " adopted") .then((value) => console.log(value));"first", and line 10 turns it into "first adopted".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 happens to the promise that then returned.
let reads = 0;const order = { get then() { reads += 1; return (resolve) => resolve("tea"); },};MyPromise.resolve(order).then((value) => console.log(value + ": " + reads));A getter is a property that runs a function each time you read it. Line 1 starts a counter at 0. Line 3 makes then a getter: each read adds 1 to the counter and returns a function. MyPromise reads then once, keeps the function, and calls it. So line 5 prints tea: 1. If the code read then twice, a getter like this could give two different answers.
const order = { get then() { throw new Error("broken order"); },};MyPromise.resolve(order).catch((error) => console.log("rejected: " + error.message));Line 2 makes then a getter that throws an error. The procedure catches the error and rejects the promise. So the catch on line 4 prints rejected: broken order. A broken thenable can't crash the chain. Its error goes down the normal rejection path instead.
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);});Line 5 resolves the built-in promise with itself. Line 14 does the same with MyPromise. Both reject with a TypeError, and lines 7 and 16 print the messages.
- In Node 22 and Chrome, the built-in message is
TypeError: Chaining cycle detected for promise #<Promise>. MyPromiseuses the same message, but names#<MyPromise>.
Promises/A+, the shared rulebook you'll meet near the end of this lesson, requires the TypeError. It doesn't require the exact words.
Version 5: run handlers as microtasks
ORDER PROOFA handler must never run right away, inside then. Not even when the promise is already fulfilled. It must wait until the code that is running now has finished. MyPromise does this with queueMicrotask, a built-in function that runs a function as a microtask. In very old environments without queueMicrotask, it uses a built-in promise to do the same job.
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. Line by line:
- Line 1 runs right away.
- The
MyPromisehandler runs before the built-in one, because line 2 adds it first. - Both promise handlers and the job from line 4 are microtasks.
- The timer on line 5 is a task, a bigger job. Every waiting microtask runs before the next task, so
timeoutprints last.
The Microtasks in depth lesson explains the microtask queue. The Event loop lesson explains why the timer waits. Here you need one rule: schedule handlers, never call them right away.
catch, finally, resolve, and reject
SMALL WRAPPERSOnce then and the resolution rules work, the other methods are short:
catch(onRejected)is justthen(null, onRejected).finally(cleanup)runscleanup, then passes on the first value or reason. Ifcleanupfails, the promise rejects with that error instead.MyPromise.resolve(value)makes a promise fulfilled withvalue, or adopts it if it is a thenable. Ifvalueis already aMyPromise, it gives it back as it is.MyPromise.reject(reason)makes a promise rejected withreason.
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));Lines 1 to 3 print value: tea. Lines 5 to 7 print caught: missing. On line 11 the cleanup returns a promise, which prints cleanup. finally wraps it in MyPromise.resolve(cleanup()).then(...), so it waits for that promise. Only then does line 13 get "saved" and print after finally: saved.
Combinators: many promises, one result
ALL & RACEA combinator is a method that takes many promises and gives back one promise. You call it on the class, like MyPromise.all(...), not on one promise. Each combinator is a loop that calls MyPromise.resolve and then on every input. It needs no hidden engine features. It only needs careful counting:
- Keep the results in the same order as the inputs.
- Settle only once.
- Treat a plain value, like
"plain", as an already-fulfilled promise.
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);});Line 10 passes an empty array, so all fulfills with an empty array at once and prints empty 0 first. Line 6 waits for slow, fast, and "plain". slow finishes last, but its value stays first, so line 7 prints slow,fast,plain.
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);});fast fulfills after 5 milliseconds and slow after 20. So race fulfills with "fast", and line 9 prints fast.
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(","));});- Lines 1 to 6 print
fulfilled/rejected.allSettledwaits for every input and reports each one's status. - Lines 8 to 11 print
yes B.anyuses the first input that fulfills and skips rejections. - Lines 13 to 18 reject with an
AggregateError: an error that keeps a list of errors in itserrorsproperty. They printAggregateError: no A,no B.
- Load the user, their permissions, and their settings before showing the page
- Use whichever finishes first: the request or a timeout
- Show which of five uploads worked and which failed
- Download from the first copy server that works
Pick the method that answers the question each feature asks about its promises.
Three edge cases to remember:
Promise.allrejects with the first rejection, but it doesn't stop the other work.Promise.race([])has nothing to wait for, so it stays pending forever.Promise.any([])rejects at once with anAggregateError, because no input can succeed.
Promises/A+ and the built-in Promise
SPEC CONTEXTPromises/A+ is a short, shared rulebook for promises. Promise libraries wrote it before promises were built into JavaScript, so that their promises could work together. It covers two things:
- How
thenmust behave. - The Promise Resolution Procedure.
It does not cover:
- Methods like
allandrace. - Browser warnings for unhandled rejections.
asyncandawait.- How fast an engine runs promises.
The table compares three things. Read each row as one question, answered for MyPromise, for the built-in Promise, and for Promises/A+.
| Question | MyPromise in this lesson | Built-in Promise | Promises/A+ |
|---|---|---|---|
| What is it? | A small class for learning the core rules. | A full feature of the JavaScript engine, built for speed and real apps. | A shared rulebook for how then must behave. |
| Which methods does it have? | then, catch, finally, resolve, reject, all, race, allSettled, and any. | All of those, plus newer ones such as withResolvers. Newer browsers also have Promise.try. | Only the then method and the Promise Resolution Procedure. |
| When do handlers run? | As microtasks, through queueMicrotask, never right away. | As jobs on the browser's or Node.js's promise queue. It also warns about unhandled rejections. | After the current code finishes. It says nothing about warnings or other methods. |
| How is it tested? | A few small tests compare order, values, rejections, thenables, and combinators. | Browsers and Node.js follow the JavaScript standard for promises. | The promises-aplus-tests package checks every rule. It is not installed here. |
The built-in Promise tells the browser or Node.js when a rejection is never handled. MyPromise doesn't. It also skips other platform features and speed work. Use the built-in Promise in real code. Use MyPromise to study the rules.
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(" | ");}Pick a case and press Run comparison. MyPromise isn't meant for real apps, but on these small cases it gives the same results as the built-in Promise.
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));}Lines 1 to 6 list four small cases. Lines 8 to 11 run each case once with MyPromise and once with the built-in Promise, and print both results. Every pair matches.
The official promises-aplus-tests package is a full conformance suite: a large set of tests that checks an implementation against every A+ rule. It is not installed here, so this is only a small check. The built-in Promise also has features outside A+:
- It tracks unhandled rejections.
- Node 22 has
Promise.withResolvers. - Newer browsers have
Promise.try, but Node 22 does not.
Where this helps, and common mistakes
You will rarely ship your own promise class. But knowing how one works helps when you:
- debug tests that use promises,
- review helper functions built on promises,
- wrap old callback-style functions in promises,
- explain why a
thenhandler didn't run when someone expected.
Built-in promises are faster, work with the browser and Node.js, and are tested in every engine. MyPromise is for studying, not for replacing them.
After you build one, common bugs have a clear cause: a missing return, a handler that runs too early, or expecting Promise.all results in finishing order.
- “A fulfilled promise runs its handler right away.” It schedules the handler as a microtask.
- “then changes the original promise.” It returns a new promise.
- “Returning a promise from a handler puts a promise inside a promise.” The next promise adopts it, so you get the value.
- “A thenable can both resolve and reject.” It can try, but only the first call counts.
- “Promise.all stops the other promises when one fails.” It rejects early, but the other work keeps running.
- “Promises/A+ is the whole built-in Promise.” A+ covers only
thenand resolution. The JavaScript standard and the browser add the rest.
Each row below is a mistake people make when they build a promise. Read it as: "if you do this, you see this, and this is the fix."
| The mistake | What you see | How to fix it |
|---|---|---|
Handlers run right away, inside then. | The output order is different from built-in promises. | Always schedule handlers as microtasks. |
| A returned promise is treated as a plain value. | The next handler gets a promise object instead of its value. | Use the resolution rules to adopt thenables. |
| There is no one-call guard. | A thenable's later call can change the outcome. | Share one called flag between resolve and reject. |
all stores results in finishing order. | The results don't line up with the inputs. | Store each result at its input's 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 already fulfilled, but its handler still waits until the script has finished.
Read the code and type what it 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);
};With no fulfillment handler, fulfilled values pass through. Here the promise rejects, so the rejection handler gets offline and logs handled offline.
Which built-in function should schedule the handler, instead of calling it directly?
then(onFulfilled) {
if (this.state === "fulfilled") {
onFulfilled(this.result); // bug: runs right away
}
}if (this.state === "fulfilled") {
queueMicrotask(() => onFulfilled(this.result));
}A then handler must never run right away. Scheduling it as a microtask gives the same order as built-in promises.
You call this allSettled with one input that fulfills and one that rejects, then join the two statuses with /. What do you get?
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 always fulfills with a status object. So MyPromise.all never rejects early, and it waits for the full report.
Every input rejects. Type the line this code prints.
MyPromise.any([
MyPromise.reject("A"),
MyPromise.reject("B"),
]).catch((error) => {
console.log(error.name + ": " + error.errors.join("|"));
});Both inputs reject, so any rejects with an AggregateError. The code prints its name and the stored errors: AggregateError: A|B.
Check your understanding
8 QUESTIONSQuestion 1 of 8What does the first version of
MyPromiseneed?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 so that chaining works?Choose an answer to see the explanation.
Question 4 of 8What should happen when a handler passed to
thenis 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 does Promises/A+ cover?
Choose an answer to see the explanation.
Key takeaways
- A promise keeps one state and one result, and it settles only once.
thensaves handlers and returns a new promise for each handler's outcome.- The Promise Resolution Procedure adopts promises and thenables, rejects a promise resolved with itself, and counts only the first call.
- Handlers always run as microtasks, even when the promise is already fulfilled.
- Combinators are loops that call
resolveandthenon many promises and keep careful count. - Promises/A+ covers
thenand resolution; the built-inPromiseadds more.
One-line summary: To build a promise, let the state change only once, settle the next promise from each handler's result, and run every handler after the current code finishes.
Up next: Classic utilities. You'll build more helpers yourself, such as deep clone, deep equal, curry, and memoize.