cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Build a promise from scratch

Build a MyPromise class with states, then chaining, microtask scheduling, thenable adoption, combinators, and Promises/A+ context.

By the end, you can
  • 01
    Implement the promise coreRepresent pending, fulfilled, and rejected states, queue handlers, and settle only once.
  • 02
    Make then chain correctlyReturn a new promise from then, adopt returned thenables, and preserve thrown errors.
  • 03
    Compare 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.

Plain definition

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.

The implementation roadmap
VersionNew ruleWhy it matters
1. Statespending, fulfilled, rejected, a stored result, and a settle-once guard.Executor throws become rejection reasons.
2. Handlersthen records callbacks while pending and schedules them once settled.Late then still receives the remembered result.
3. Chainingthen returns a new promise and resolves it from the callback return.Missing callbacks pass values or reasons through.
4. ResolutionReturned promises and thenables are adopted with cycle and double-call guards.Resolving a promise with itself rejects with TypeError.
5. MicrotasksHandlers run through queueMicrotask, never inline.Ordering matches native promises for the tested cases.
Real-life analogyYour McDonald's order token

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: pending state
In real life: The counter marks ready or sold out once
In JavaScript: fulfilled or rejected with a settle-once guard
In real life: People wait for the token update
In JavaScript: then handlers 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 STATE

Start 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.

Version 1: state and executorJavaScript
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.

Executor timing

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 THROUGH

A 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.

Version 2: queue handlers, settle once
Step 0 of 9Ready
Your turn: follow the blue line

A promise stores a state, a result, and a queue of handlers. Step through a pending promise that settles once.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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");
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

The handler-queue examplePop out in the code editor (opens in a new tab)JavaScript
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

CHAINING

Native 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.

Version 3: a then chain returns new promises
Step 0 of 5Ready
Your turn: follow the blue line

Every then returns a new promise. Follow what each callback returns and what the next promise adopts.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  .then((value) => {    return value + 3;  })  .then((value) => {    return MyPromise.resolve(value * 2);  })  .then((value) => {    console.log(value);  });
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

The chain sourcePop out in the code editor (opens in a new tab)JavaScript
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

THENABLES

The 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.

Version 4: adopt thenables safely
Step 0 of 7Ready
Your turn: follow the blue line

Promise resolution is the heart of chaining: adopt promises and thenables, reject cycles, and ignore double settlement.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  then(resolve, reject) {    resolve("first");    reject("second");    resolve("third");  },}; MyPromise.resolve(thenable)  .then((value) => value + " adopted")  .then((value) => console.log(value));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Resolve, adopt, or reject?
  • handler returns `42`
  • handler returns `Promise.resolve("ok")`
  • handler returns `{ then(resolve) { resolve("ok"); } }`
  • handler throws `new Error("boom")`
  • resolve a promise with itself
  • handler returns `{ value: 1 }`
Try it yourself
0 of 6 correct

Sort each handler result by what the procedure does to the promise returned by then.

Choose a category for every card. You can change an answer at any time; Reset clears them all.
Self-resolution messagesPop out in the code editor (opens in a new tab)JavaScript
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 PROOF

A 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.

MyPromise vs native promise orderingPop out in the code editor (opens in a new tab)JavaScript
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.

Where this builds on earlier lessons

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 WRAPPERS

Once 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.

Helpers in actionPop out in the code editor (opens in a new tab)JavaScript
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 & RACE

Static 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.

Promise.all preserves order and handles empty inputPop out in the code editor (opens in a new tab)JavaScript
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);});
Promise.race uses the first settlementPop out in the code editor (opens in a new tab)JavaScript
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);});
allSettled and anyPop out in the code editor (opens in a new tab)JavaScript
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(","));});
Which combinator fits?
  • 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
Try it yourself
0 of 4 correct

Choose the method that answers the feature's group question.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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 CONTEXT

Promises/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.

MyPromise vs native Promise vs Promises/A+
QuestionMyPromise in this lessonNative PromisePromises/A+
ScopeTeaching 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 surfacethen, 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.
SchedulingUses 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.
ConformanceMini 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.
MyPromise and native Promise, side by side
Comparison harnessJavaScript
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(" | ");}
Scenario outputthen chain
MyPromisePress Run comparison.
Native PromisePress Run comparison.
Try it yourself

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.

This playground runs real code in the page. It does not evaluate arbitrary learner code.
Mini conformance comparisonPop out in the code editor (opens in a new tab)JavaScript
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.

Use native promises in production

Native promises are faster, integrated with the host, and tested across engines. MyPromise is a microscope, not a replacement.

Build one to read bugs better

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 then behavior; ECMAScript and hosts add more.
Common internals mistakes
MistakeSymptomFix
Run handlers inlineOutput order differs from native promises.Always schedule handler records as microtasks.
Resolve returned promises as plain valuesNext handler receives a promise object.Use the resolution procedure to adopt thenables.
Forget the one-call guardA hostile thenable changes outcomes twice.Wrap resolve and reject with a shared called flag.
Use completion order in allArrays do not line up with inputs.Store each result at its original index.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict microtask order

Type the three logs in order.

Starter codePop out in the code editor (opens in a new tab)JavaScript
console.log("A");
MyPromise.resolve("B").then((value) => console.log(value));
console.log("C");

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upImplement catch with then

    Study the implementation and predict what the program prints.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    MyPromise.prototype.catch = function (onRejected) {
      return this.then(null, onRejected);
    };
    
    MyPromise.reject("offline")
      .catch((reason) => console.log("handled " + reason));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFix a synchronous then bug

      Which scheduling API should replace the direct handler call?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      then(onFulfilled) {
        if (this.state === "fulfilled") {
          onFulfilled(this.result); // bug: runs synchronously
        }
      }

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeImplement allSettled from all

        What status string should this pattern produce for one fulfilled and one rejected input?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        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 }),
            )
          ));
        };

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeAggregate every rejection for any

          Predict the final line when every input rejects.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          MyPromise.any([
            MyPromise.reject("A"),
            MyPromise.reject("B"),
          ]).catch((error) => {
            console.log(error.name + ": " + error.errors.join("|"));
          });

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            8 QUESTIONS
            Build a promise quiz · 8 questionsScore: first tries count
            1. Question 1 of 8Which fields does the first MyPromise version need?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does this print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              console.log("A");
              MyPromise.resolve("B").then((value) => console.log(value));
              console.log("C");

              Choose an answer to see the explanation.

            3. Question 3 of 8What must then return for chaining to work?

              Choose an answer to see the explanation.

            4. Question 4 of 8What should happen when a handler is not a function?

              Choose an answer to see the explanation.

            5. Question 5 of 8What does the Promise Resolution Procedure do with a thenable?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              MyPromise.resolve({
                then(resolve, reject) {
                  resolve("first");
                  reject("second");
                },
              }).then((value) => console.log(value));

              Choose an answer to see the explanation.

            6. Question 6 of 8What does Promise.all preserve?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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(",")));

              Choose an answer to see the explanation.

            7. Question 7 of 8What does MyPromise.any reject with when every input rejects?

              Choose an answer to see the explanation.

            8. 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.
            • then registers 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 resolve and then.
            • Promises/A+ covers the core then contract; 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.

            CompleteFrontend Clear concepts. Working examples.