Skip to content

Implement Promise from Scratch

advanced25 min read

Why Build a Promise?

You already use promises every day. But in a FAANG interview, you're asked to build one. Not because anyone ships a custom Promise to production, but because building one reveals whether you understand the machinery underneath async/await, microtask scheduling, and error propagation. It separates people who memorize APIs from people who understand systems.

We'll build it piece by piece, and by the end you'll have a working Promises/A+ implementation. Every decision along the way maps to a specific requirement in the spec.

Mental Model

A Promise is a state machine with a mailbox. While the machine is in the "pending" state, incoming mail (.then callbacks) gets queued. The moment the machine transitions to "fulfilled" or "rejected," it delivers all queued mail and locks itself forever. Any mail that arrives after the lock is delivered immediately (via microtask). That's the entire model. Everything else is implementation detail.

Step 1: The State Machine

Every Promise has three possible states: pending, fulfilled, and rejected. The transition is one-way and irreversible.

const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';

class MyPromise {
  constructor(executor) {
    this.state = PENDING;
    this.value = undefined;
    this.callbacks = [];

    const resolve = (value) => {
      if (this.state !== PENDING) return;
      this.state = FULFILLED;
      this.value = value;
      this.callbacks.forEach(cb => cb.onFulfilled(value));
    };

    const reject = (reason) => {
      if (this.state !== PENDING) return;
      this.state = REJECTED;
      this.value = reason;
      this.callbacks.forEach(cb => cb.onRejected(reason));
    };

    try {
      executor(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }
}

A few things to notice:

  • The if (this.state !== PENDING) return guard is critical. Once settled, calling resolve or reject again is a no-op. This is how the spec enforces immutability.
  • The executor runs synchronously inside the constructor. This surprises people. new Promise(resolve => resolve(42)) resolves the promise during construction.
  • The try/catch around the executor means throwing inside new Promise(() => { throw new Error('boom') }) produces a rejected promise, not an uncaught exception.
  • The callbacks array is the "mailbox" from our mental model. It stores handlers registered by .then() while the promise is still pending.
Quiz
What happens if you call resolve() twice inside a Promise executor?

Step 2: The then() Method with Callback Queuing

.then() is where promises get interesting. It needs to handle two cases: the promise is still pending (queue the callbacks), or it's already settled (schedule them immediately).

class MyPromise {
  // ...constructor from Step 1

  then(onFulfilled, onRejected) {
    if (this.state === FULFILLED) {
      onFulfilled(this.value);
    }

    if (this.state === REJECTED) {
      onRejected(this.value);
    }

    if (this.state === PENDING) {
      this.callbacks.push({
        onFulfilled: (value) => onFulfilled(value),
        onRejected: (reason) => onRejected(reason),
      });
    }
  }
}

This basic version works for simple cases, but it has three major problems:

  1. Callbacks run synchronously. The spec requires them to run asynchronously.
  2. No chaining. .then() doesn't return a new promise.
  3. No default handlers. If you call .then(undefined, onRejected), it crashes when trying to call undefined.

We'll fix all three. Let's start with async scheduling.

Step 3: Microtask Scheduling

The Promises/A+ spec (section 2.2.4) says: onFulfilled or onRejected must not be called until the execution context stack contains only platform code. Translation: callbacks must be asynchronous, even if the promise is already settled.

Native promises use the microtask queue for this. We use queueMicrotask:

const resolve = (value) => {
  if (this.state !== PENDING) return;
  this.state = FULFILLED;
  this.value = value;
  queueMicrotask(() => {
    this.callbacks.forEach(cb => cb.onFulfilled(value));
  });
};

const reject = (reason) => {
  if (this.state !== PENDING) return;
  this.state = REJECTED;
  this.value = reason;
  queueMicrotask(() => {
    this.callbacks.forEach(cb => cb.onRejected(reason));
  });
};

And in then(), when the promise is already settled:

then(onFulfilled, onRejected) {
  if (this.state === FULFILLED) {
    queueMicrotask(() => onFulfilled(this.value));
  }

  if (this.state === REJECTED) {
    queueMicrotask(() => onRejected(this.value));
  }

  if (this.state === PENDING) {
    this.callbacks.push({
      onFulfilled: (value) => onFulfilled(value),
      onRejected: (reason) => onRejected(reason),
    });
  }
}
Why queueMicrotask and not setTimeout?

setTimeout puts callbacks on the macrotask queue. Native promises use the microtask queue, which drains completely between each macrotask. Using setTimeout would make our implementation behave differently from native promises in ordering tests. queueMicrotask matches the real behavior.

Quiz
Why does the Promises/A+ spec require .then() callbacks to run asynchronously, even when the promise is already settled?

Step 4: Chaining (.then() Returns a New Promise)

This is the heart of the promise model. Every .then() call returns a brand new promise. The fate of that new promise depends on what the callback returns (or throws).

then(onFulfilled, onRejected) {
  return new MyPromise((resolve, reject) => {
    const handleFulfilled = (value) => {
      try {
        if (typeof onFulfilled === 'function') {
          const result = onFulfilled(value);
          resolvePromise(resolve, reject, result);
        } else {
          resolve(value);
        }
      } catch (err) {
        reject(err);
      }
    };

    const handleRejected = (reason) => {
      try {
        if (typeof onRejected === 'function') {
          const result = onRejected(reason);
          resolvePromise(resolve, reject, result);
        } else {
          reject(reason);
        }
      } catch (err) {
        reject(err);
      }
    };

    if (this.state === FULFILLED) {
      queueMicrotask(() => handleFulfilled(this.value));
    }

    if (this.state === REJECTED) {
      queueMicrotask(() => handleRejected(this.value));
    }

    if (this.state === PENDING) {
      this.callbacks.push({
        onFulfilled: handleFulfilled,
        onRejected: handleRejected,
      });
    }
  });
}

Three things happening here:

  1. Default handlers. If onFulfilled is not a function, we pass the value through (resolve(value)). If onRejected is not a function, we pass the rejection through (reject(reason)). This is how rejections propagate down a chain until they hit a .catch().
  2. Error capture. If the callback throws, the returned promise rejects with that error. This is why you can throw inside .then() and it gets caught by a downstream .catch().
  3. Resolution procedure. The resolvePromise helper handles the case where the callback returns a promise or thenable. We'll build that next.
Execution Trace
p1 = MyPromise.resolve(10)
p1: fulfilled with 10
First promise created and immediately fulfilled
p2 = p1.then(x => x * 2)
p2: pending. Callback queued as microtask.
.then() returns a NEW promise (p2)
p3 = p2.then(x => x + 5)
p3: pending. Waiting for p2.
.then() returns another NEW promise (p3)
Microtask: p1 callback runs
Callback returns 20. p2 fulfills with 20.
p2's resolve(20) is called
Microtask: p2 callback runs
Callback returns 25. p3 fulfills with 25.
p3's resolve(25) is called
Chain complete
p1=10, p2=20, p3=25. Three separate promise objects.
Each .then() created its own promise

Step 5: The Resolution Procedure (Handling Thenables)

This is section 2.3 of the Promises/A+ spec. When a .then() callback returns a value, we need to figure out what to do with it. If it returns a plain value, we fulfill. If it returns a promise or a "thenable" (any object with a .then method), we need to "follow" it.

function resolvePromise(resolve, reject, value) {
  if (value instanceof MyPromise) {
    value.then(resolve, reject);
    return;
  }

  if (value !== null && (typeof value === 'object' || typeof value === 'function')) {
    let called = false;

    try {
      const then = value.then;

      if (typeof then === 'function') {
        then.call(
          value,
          (y) => {
            if (called) return;
            called = true;
            resolvePromise(resolve, reject, y);
          },
          (r) => {
            if (called) return;
            called = true;
            reject(r);
          }
        );
      } else {
        resolve(value);
      }
    } catch (err) {
      if (called) return;
      called = true;
      reject(err);
    }
  } else {
    resolve(value);
  }
}

This looks hairy, but every line maps to a spec requirement:

  • value instanceof MyPromise — if the callback returned one of our own promises, just adopt its state by calling .then(resolve, reject).
  • Thenable check — if the value is an object/function with a .then method, treat it as a thenable. This is how interop works between different promise libraries.
  • called flag — the spec requires that only the first call to resolve or reject counts. A badly-behaved thenable might call both, or call resolve twice. The flag prevents that.
  • try/catch around value.then access — a getter for .then could throw. Seriously. The spec accounts for this because thenables come from untrusted code.
  • Recursive resolvePromise — if a thenable resolves with another thenable, we need to unwrap recursively until we hit a plain value.
Why the spec checks for thenables, not just Promises

When Promises/A+ was written, there were dozens of competing promise libraries (Q, Bluebird, RSVP, jQuery Deferred, when.js). Each had its own Promise constructor, so instanceof wouldn't work across libraries. The "thenable" check (typeof value.then === 'function') was the interop solution: any object with a .then method is treated as a promise-like thing. This is duck typing applied to async primitives. It's also why {then: 42} is safely handled (not a function, so just fulfill with the object) but {then() { /* ... */ }} gets the full thenable treatment.

Quiz
Why does the resolution procedure use a 'called' boolean flag?

Step 6: Error Handling (.catch() and Self-Resolution Guard)

.catch() is just syntactic sugar for .then(undefined, onRejected):

catch(onRejected) {
  return this.then(undefined, onRejected);
}

That's it. Because our .then() already handles the case where onFulfilled is not a function (it passes the value through), .catch() just works.

We also need one safety check. What happens if a .then() callback returns the same promise that .then() itself returns? That would be a circular reference that can never resolve:

const p = Promise.resolve(1).then(() => p); // TypeError: Chaining cycle

Let's add that guard to resolvePromise:

function resolvePromise(promise, resolve, reject, value) {
  if (value === promise) {
    reject(new TypeError('Chaining cycle detected'));
    return;
  }
  // ...rest of the resolution procedure
}

And update then() to pass the returned promise reference:

then(onFulfilled, onRejected) {
  const promise = new MyPromise((resolve, reject) => {
    const handleFulfilled = (value) => {
      try {
        if (typeof onFulfilled === 'function') {
          const result = onFulfilled(value);
          resolvePromise(promise, resolve, reject, result);
        } else {
          resolve(value);
        }
      } catch (err) {
        reject(err);
      }
    };

    const handleRejected = (reason) => {
      try {
        if (typeof onRejected === 'function') {
          const result = onRejected(reason);
          resolvePromise(promise, resolve, reject, result);
        } else {
          reject(reason);
        }
      } catch (err) {
        reject(err);
      }
    };

    if (this.state === FULFILLED) {
      queueMicrotask(() => handleFulfilled(this.value));
    }

    if (this.state === REJECTED) {
      queueMicrotask(() => handleRejected(this.value));
    }

    if (this.state === PENDING) {
      this.callbacks.push({
        onFulfilled: handleFulfilled,
        onRejected: handleRejected,
      });
    }
  });

  return promise;
}

The Complete Implementation

Here's everything assembled:

const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';

function resolvePromise(promise, resolve, reject, value) {
  if (value === promise) {
    reject(new TypeError('Chaining cycle detected'));
    return;
  }

  if (value instanceof MyPromise) {
    value.then(resolve, reject);
    return;
  }

  if (value !== null && (typeof value === 'object' || typeof value === 'function')) {
    let called = false;
    try {
      const then = value.then;
      if (typeof then === 'function') {
        then.call(
          value,
          (y) => {
            if (called) return;
            called = true;
            resolvePromise(promise, resolve, reject, y);
          },
          (r) => {
            if (called) return;
            called = true;
            reject(r);
          }
        );
      } else {
        resolve(value);
      }
    } catch (err) {
      if (called) return;
      called = true;
      reject(err);
    }
  } else {
    resolve(value);
  }
}

class MyPromise {
  constructor(executor) {
    this.state = PENDING;
    this.value = undefined;
    this.callbacks = [];

    const resolve = (value) => {
      if (this.state !== PENDING) return;
      this.state = FULFILLED;
      this.value = value;
      queueMicrotask(() => {
        this.callbacks.forEach(cb => cb.onFulfilled(value));
      });
    };

    const reject = (reason) => {
      if (this.state !== PENDING) return;
      this.state = REJECTED;
      this.value = reason;
      queueMicrotask(() => {
        this.callbacks.forEach(cb => cb.onRejected(reason));
      });
    };

    try {
      executor(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }

  then(onFulfilled, onRejected) {
    const promise = new MyPromise((resolve, reject) => {
      const handleFulfilled = (value) => {
        try {
          if (typeof onFulfilled === 'function') {
            const result = onFulfilled(value);
            resolvePromise(promise, resolve, reject, result);
          } else {
            resolve(value);
          }
        } catch (err) {
          reject(err);
        }
      };

      const handleRejected = (reason) => {
        try {
          if (typeof onRejected === 'function') {
            const result = onRejected(reason);
            resolvePromise(promise, resolve, reject, result);
          } else {
            reject(reason);
          }
        } catch (err) {
          reject(err);
        }
      };

      if (this.state === FULFILLED) {
        queueMicrotask(() => handleFulfilled(this.value));
      }

      if (this.state === REJECTED) {
        queueMicrotask(() => handleRejected(this.value));
      }

      if (this.state === PENDING) {
        this.callbacks.push({
          onFulfilled: handleFulfilled,
          onRejected: handleRejected,
        });
      }
    });

    return promise;
  }

  catch(onRejected) {
    return this.then(undefined, onRejected);
  }

  static resolve(value) {
    if (value instanceof MyPromise) return value;
    return new MyPromise((resolve) => resolve(value));
  }

  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason));
  }
}

Testing It

Let's verify our implementation behaves like native promises:

MyPromise.resolve(1)
  .then(x => {
    console.log(x);
    return x + 1;
  })
  .then(x => {
    console.log(x);
    return MyPromise.resolve(x * 10);
  })
  .then(x => {
    console.log(x);
    throw new Error('boom');
  })
  .catch(err => {
    console.log('caught:', err.message);
    return 'recovered';
  })
  .then(x => console.log(x));

// Output:
// 1
// 2
// 20
// caught: boom
// recovered

Thenable interop works too:

const fakePromise = {
  then(resolve) {
    setTimeout(() => resolve('from thenable'), 100);
  }
};

MyPromise.resolve()
  .then(() => fakePromise)
  .then(val => console.log(val)); // "from thenable" (after 100ms)
Quiz
In our implementation, what happens if the executor function throws an error?

The Tricky Edge Cases

Edge Case 1: Resolving with the Promise Itself

const p = new MyPromise((resolve) => {
  setTimeout(() => resolve(p), 0);
});

p.then(console.log).catch(err => console.log(err.message));
// "Chaining cycle detected"

Without the self-resolution guard, this would loop forever. The spec requires a TypeError rejection.

Edge Case 2: Thenable That Throws During .then Access

const evil = Object.create(null);
Object.defineProperty(evil, 'then', {
  get() { throw new Error('gotcha'); }
});

MyPromise.resolve()
  .then(() => evil)
  .catch(err => console.log(err.message)); // "gotcha"

The try/catch around const then = value.then in our resolution procedure handles this. Without it, a hostile thenable could crash our implementation.

Edge Case 3: Thenable That Calls Both Resolve and Reject

const chaos = {
  then(resolve, reject) {
    resolve('first');
    reject('second');
    resolve('third');
  }
};

MyPromise.resolve()
  .then(() => chaos)
  .then(val => console.log(val)); // "first"

The called flag ensures only the first invocation wins. reject('second') and resolve('third') are no-ops.

Quiz
What would happen if our resolvePromise function did NOT check value === promise?

What Native Promises Add Beyond A+

Our implementation is Promises/A+ compliant, but native ES2015 promises include extra features the spec doesn't cover:

  • .finally(onFinally) runs a callback regardless of fulfillment or rejection, and passes through the original value
  • Promise.all(), Promise.race(), Promise.allSettled(), Promise.any() combinators
  • Unhandled rejection tracking via the unhandledrejection event
  • async/await integration with the engine's microtask scheduling

These are worth implementing as follow-up exercises, but the core machinery is exactly what we built here.

Interview Tips

When whiteboarding this in an interview:

  1. Start with the state machine. Draw the three states and the transitions. This shows you understand the model before writing code.
  2. Build incrementally. Don't try to write the complete implementation at once. Start with the constructor and resolve/reject, then add .then() without chaining, then add chaining.
  3. Explain the spec requirements as you go. "The spec says callbacks must be async, so I'll use queueMicrotask here." This shows you're not just memorizing code.
  4. Know the edge cases. Self-resolution cycles, thenable interop, and the called flag are the three things that separate good implementations from great ones.
  5. Don't forget catch. It's one line, but forgetting it looks bad.

Common Mistakes

What developers doWhat they should do
Running .then() callbacks synchronously when the promise is already settled
The spec requires consistent async behavior. If callbacks run synchronously on settled promises but asynchronously on pending ones, the same code produces different execution orders depending on timing. This creates subtle, unreproducible bugs.
Always schedule callbacks via queueMicrotask, even for already-settled promises
Forgetting to return a new promise from .then()
Without returning a new promise, chaining breaks entirely. p.then(a).then(b) would call .then(b) on undefined. The entire promise chain model depends on .then() producing a new promise whose fate is controlled by the callback's return value.
.then() must always return a brand new MyPromise instance
Not handling the case where onFulfilled or onRejected is not a function
This is how rejection propagation works. When you write .then(fn).then(fn).catch(handleErr), the middle .then() calls have no onRejected handler. Rejections must pass through them unchanged until they reach the .catch().
If onFulfilled is not a function, pass the value through. If onRejected is not a function, pass the rejection through.
Using setTimeout instead of queueMicrotask for async scheduling
setTimeout uses the macrotask queue with a minimum delay. queueMicrotask uses the microtask queue, which drains completely before any macrotask runs. Using setTimeout makes your implementation schedule callbacks at the wrong time relative to other microtasks, failing ordering tests.
Use queueMicrotask to match native Promise behavior
Not guarding against a thenable calling resolve/reject multiple times
Thenables come from external code that you don't control. A broken thenable might call both resolve and reject, or call resolve twice. Without the guard, your promise could change state after settling, violating the fundamental immutability guarantee.
Use a called flag to ensure only the first invocation of resolve or reject takes effect

Key Rules

Key Rules
  1. 1A Promise is a state machine with three states: pending, fulfilled, rejected. Transitions are one-way. Once settled, the state and value are locked forever.
  2. 2The executor runs synchronously. resolve() and reject() are delivered as closures. Throwing inside the executor rejects the promise.
  3. 3.then() callbacks must ALWAYS run asynchronously via queueMicrotask, even when the promise is already settled. This guarantees consistent execution order.
  4. 4.then() returns a NEW promise. If the callback returns a value, the new promise fulfills with it. If it throws, the new promise rejects. If it returns a thenable, the new promise follows it.
  5. 5The resolution procedure handles thenables (objects with a .then method) for cross-library interop. The called flag prevents double-resolution from untrusted thenables.
  6. 6.catch(fn) is just .then(undefined, fn). Rejection propagation works because .then() without an onRejected handler passes the rejection through to the next promise in the chain.
1/10