InterviewsVector
advancedVery common7 min read · Updated Jul 18, 2026

Build a Promise from Scratch (Promises/A+)

Implement a spec-faithful Promise in JavaScript: state machine, microtask timing, handler passthrough, and the thenable resolution procedure that makes chaining work.


The problem

Implement a MyPromise class that passes for the real thing: new MyPromise(executor), then/catch/finally, chaining, and correct async ordering.

This is the definitive senior async question because it has three difficulty cliffs, and interviewers watch which ones you fall off:

  1. The state machine — pending → fulfilled/rejected, one-way, settle-once. Most candidates get this.
  2. Timing — handlers must run as microtasks, never synchronously, even for already-settled promises. Many candidates miss this; the interviewer catches it with one output-prediction snippet.
  3. The resolution procedure — when a handler returns a promise, the chain must wait for it and adopt its state. Almost everyone misses this, and without it then(() => fetch(...)) — the whole point of promises — doesn't work.

There's a fourth, subtler cliff: handler passthrough. then called with non-function handlers must forward the value/reason. Get it wrong and p.then(fn).catch(handleErr) breaks — catch is literally then(undefined, fn), so a fulfilled promise flowing through catch hits your undefined "handler."

Implementation

class MyPromise {
  #state = "pending";
  #result = undefined;
  #handlers = []; // queued { onFulfilled, onRejected, resolve, reject }
 
  constructor(executor) {
    try {
      executor(this.#resolve.bind(this), this.#reject.bind(this));
    } catch (err) {
      this.#reject(err); // a throwing executor rejects the promise
    }
  }
 
  // ---- The Promise Resolution Procedure (the third cliff) ----
  #resolve(value) {
    if (this.#state !== "pending") return; // settle-once
 
    if (value === this) {
      return this.#reject(new TypeError("Chaining cycle detected"));
    }
 
    // If value is a thenable, ADOPT its eventual state instead of
    // fulfilling with the thenable itself. This is what makes
    // `then(() => anotherPromise)` wait.
    if (value !== null && (typeof value === "object" || typeof value === "function")) {
      let then;
      try {
        then = value.then; // getter may throw — spec says reject then
      } catch (err) {
        return this.#reject(err);
      }
      if (typeof then === "function") {
        let called = false; // a hostile thenable may call back twice
        try {
          then.call(
            value,
            (v) => { if (!called) { called = true; this.#resolve(v); } }, // recurse: v may itself be thenable
            (r) => { if (!called) { called = true; this.#reject(r); } }
          );
        } catch (err) {
          if (!called) { called = true; this.#reject(err); }
        }
        return;
      }
    }
 
    this.#state = "fulfilled";
    this.#result = value;
    this.#flush();
  }
 
  #reject(reason) {
    if (this.#state !== "pending") return;
    this.#state = "rejected";
    this.#result = reason;
    this.#flush();
  }
 
  #flush() {
    this.#handlers.forEach((h) => this.#schedule(h));
    this.#handlers = [];
  }
 
  // ---- Microtask timing + handler passthrough (cliffs 2 and 4) ----
  #schedule({ onFulfilled, onRejected, resolve, reject }) {
    queueMicrotask(() => {
      try {
        if (this.#state === "fulfilled") {
          if (typeof onFulfilled === "function") {
            resolve(onFulfilled(this.#result));
          } else {
            resolve(this.#result); // passthrough: then(null).then(v => ...) works
          }
        } else {
          if (typeof onRejected === "function") {
            resolve(onRejected(this.#result)); // handled rejection → chain RECOVERS
          } else {
            reject(this.#result); // passthrough: rejection tunnels to the next catch
          }
        }
      } catch (err) {
        reject(err); // a throwing handler rejects the derived promise
      }
    });
  }
 
  then(onFulfilled, onRejected) {
    return new MyPromise((resolve, reject) => {
      const handler = { onFulfilled, onRejected, resolve, reject };
      if (this.#state === "pending") {
        this.#handlers.push(handler); // settle later → flush will schedule
      } else {
        this.#schedule(handler); // already settled → still a microtask, never sync
      }
    });
  }
 
  catch(onRejected) {
    return this.then(undefined, onRejected);
  }
 
  finally(onFinally) {
    return this.then(
      (value) => MyPromise.resolve(onFinally()).then(() => value),
      (reason) => MyPromise.resolve(onFinally()).then(() => { throw reason; })
    );
  }
 
  static resolve(value) {
    return value instanceof MyPromise
      ? value
      : new MyPromise((resolve) => resolve(value));
  }
 
  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason));
  }
}

Verified behavior — the snippets interviewers test with

// 1. Microtask timing (cliff 2)
const p = MyPromise.resolve("A");
p.then(console.log);
console.log("B");
// B, then A — handlers NEVER run synchronously, even when already settled
 
// 2. catch on a FULFILLED promise (cliff 4 — the passthrough)
MyPromise.resolve("ok")
  .then((v) => v + "!")
  .catch(() => "never runs")
  .then(console.log); // "ok!" — value tunnels through catch untouched
 
// 3. Returning a promise from then (cliff 3 — resolution procedure)
MyPromise.resolve(1)
  .then((v) => new MyPromise((res) => setTimeout(() => res(v + 1), 10)))
  .then(console.log); // 2 — the chain WAITED for the inner promise
 
// 4. Error recovery
MyPromise.reject(new Error("boom"))
  .catch((e) => "recovered")
  .then(console.log); // "recovered" — a handled rejection fulfills the chain

If your implementation passes these four, you've cleared every cliff. (The full Promises/A+ compliance suite has 872 tests; these four cover the categories that fail most.)

Why each piece exists

  • queueMicrotask — the spec requires handlers to run on a clean stack. Without it, then callbacks on settled promises run synchronously and reorder against the event loop — the "Zalgo" bug: an API that's sometimes sync, sometimes async, so callers can't reason about ordering.
  • The called flag — a malicious or buggy thenable can invoke both callbacks, or one callback twice. Settle-once must survive that. This is a real interoperability rule from Promises/A+ §2.3.3.3.3, not paranoia.
  • resolve(onRejected(...)) not reject(...) — a rejection handler that returns normally recovers the chain. This asymmetry (fulfill on return, reject on throw) is the entire error-handling model.
  • Recursion in #resolve — a thenable can resolve to another thenable; adoption must chase the chain to a plain value.

Test yourself

Predict the outputWhat does this code print?
Promise.resolve("ok")
.catch(() => "rescued")
.then((v) => console.log(v));

Edge cases interviewers probe

  • resolve called twice / after reject — first settlement wins, silently.
  • Self-resolutionp's resolve called with pTypeError: Chaining cycle detected (yes, the spec names this).
  • Executor throws after resolving — the throw is swallowed: the promise already settled. Follows directly from settle-once; saying so shows the model has clicked.
  • then called multiple times on one promise — every handler runs, in registration order. That's why #handlers is an array, not a single slot.
  • finally passthroughfinally forwards the original value/reason, but if onFinally throws or returns a rejected promise, that error wins. Both behaviors fall out of the then-based implementation above.

Common mistakes

  • Calling handlers synchronously for settled promises (fails snippet 1; the most common bug in blog implementations).
  • No handler passthrough — catch on a fulfilled promise crashes with undefined is not a function (fails snippet 2).
  • Fulfilling with the returned promise instead of adopting it — .then(console.log) prints a promise object (fails snippet 3).
  • catch(reject) wiring that can't recover — chains where one rejection poisons everything downstream even through handlers (fails snippet 4).
  • Bolting on static all/race/any with bugs (empty-array hangs are near-universal). The combinators are their own questions with their own edge cases — Promise.all, Promise.race, Promise.any, Promise.allSettled — and in an interview it's better to nail one than to sketch five.

Follow-up questions

  • "Why microtasks and not setTimeout(fn, 0)?" — ordering guarantees: all microtasks drain before the next macrotask, so promise chains complete before timers and rendering. Full story in the event loop.
  • "What is 'releasing Zalgo'?" — an API that calls back synchronously on some paths and asynchronously on others; the spec's unconditional-async rule exists to kill this bug class.
  • "How does async/await relate?"async functions are compiled into this machinery: each await is a then, the function body after it is the handler, and Promise.withResolvers exposes the raw resolve/reject the executor pattern hides.
  • "What about unhandled rejections?" — real engines track whether a rejection ever gets a handler and fire unhandledrejection; a userland implementation can approximate it with a microtask-deferred check. Knowing the event's name is usually enough.
  • "Where would you take this next?" — cancellation doesn't exist in promises by design; the composable answer is AbortController.

  • Promise.all

    intermediate

    Implement Promise.all: aggregate results in order, fail fast on the first rejection.

  • Promise.allSettled

    intermediate

    Implement allSettled: wait for every promise and report per-promise status objects.

  • Promise.any

    intermediate

    Implement Promise.any with AggregateError semantics: first success wins, all failures reject.

  • Promise.race

    beginner

    Implement Promise.race and use it for the timeout pattern every senior interview touches.