ESC

Type to search the knowledge base.

Custom Promise From Scratch

Build a tiny Promise: executor, then chaining, microtask reactions, resolve-thenable unwrapping, and the states you are not allowed to leave.

advanced6 min read
  • javascript
  • promises
  • polyfill
  • async

If then runs in the same turn as resolve, this snippet prints 1 then 2. Native Promises print 2 then 1. That single ordering bug is how interviewers know the custom Promise is a callback queue with a state machine, not a setTimeout wrapper.

You do not need a full Promises/A+ test suite on a whiteboard. You need three states, an executor that runs now, then that always returns a new promise, reactions on the microtask queue, thenable unwrapping, and a TypeError on a chaining cycle.

This is not a recap of all / race. Those are helpers on top of this object.

Key takeaways

  • pending → fulfilled or rejected, once. Later resolve / reject are no-ops.
  • then always returns a new promise. Missing callbacks pass the value or reason through.
  • Reactions run as microtasks (queueMicrotask), never synchronously from resolve.
  • Resolving with a thenable adopts it. Resolving with the promise itself is a TypeError.
  • Executor throws become rejections. A called flag makes thenable then safe.

Three states and a queue of reactions

A promise is an observer: work starts in the executor, subscribers land in two queues, settlement flushes one queue and freezes the other.

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

function isObject(x) {
  return x !== null && (typeof x === 'object' || typeof x === 'function');
}

function MyPromise(executor) {
  if (typeof executor !== 'function') {
    throw new TypeError('Promise resolver is not a function');
  }

  this.state = PENDING;
  this.result = undefined;
  this.fulfillQ = [];
  this.rejectQ = [];

  const resolve = (value) => settle(this, FULFILLED, value);
  const reject = (reason) => settle(this, REJECTED, reason);

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

function settle(promise, state, result) {
  if (promise.state !== PENDING) return;

  if (state === FULFILLED) {
    if (result === promise) {
      settle(promise, REJECTED, new TypeError('Chaining cycle detected'));
      return;
    }
    if (isObject(result)) {
      let then;
      try {
        then = result.then;
      } catch (err) {
        settle(promise, REJECTED, err);
        return;
      }
      if (typeof then === 'function') {
        unwrapThenable(promise, result, then);
        return;
      }
    }
  }

  promise.state = state;
  promise.result = result;
  const queue = state === FULFILLED ? promise.fulfillQ : promise.rejectQ;
  promise.fulfillQ = [];
  promise.rejectQ = [];
  queueMicrotask(() => {
    for (const job of queue) job(result);
  });
}

function unwrapThenable(promise, thenable, then) {
  let called = false;
  queueMicrotask(() => {
    try {
      then.call(
        thenable,
        (value) => {
          if (called) return;
          called = true;
          settle(promise, FULFILLED, value);
        },
        (reason) => {
          if (called) return;
          called = true;
          settle(promise, REJECTED, reason);
        },
      );
    } catch (err) {
      if (called) return;
      called = true;
      settle(promise, REJECTED, err);
    }
  });
}

MyPromise.prototype.then = function (onFulfilled, onRejected) {
  return new MyPromise((resolve, reject) => {
    const handle = (cb, passthrough, value) => {
      if (typeof cb !== 'function') {
        passthrough(value);
        return;
      }
      try {
        resolve(cb(value));
      } catch (err) {
        reject(err);
      }
    };

    const onF = (v) => handle(onFulfilled, resolve, v);
    const onR = (r) => handle(onRejected, reject, r);

    if (this.state === PENDING) {
      this.fulfillQ.push(onF);
      this.rejectQ.push(onR);
    } else if (this.state === FULFILLED) {
      queueMicrotask(() => onF(this.result));
    } else {
      queueMicrotask(() => onR(this.result));
    }
  });
};

What that code is doing

The executor runs synchronously. new MyPromise((resolve) => resolve(1)) has already settled (or queued a thenable job) before the next line. Handlers must still wait a microtask, which is why then on a fulfilled promise uses queueMicrotask, and why settle flushes queues inside one.

Piece Rule
Executor Runs now. Throw → reject.
resolve / reject First call wins. Later calls are no-ops.
then New promise. Non-functions pass through.
Reactions queueMicrotask, never the current stack.
Thenable Stay pending, adopt then, called flag.
Cycle resolve(this) → TypeError.

then returns a new MyPromise. Returning a value from onFulfilled resolves the next promise with that value (including thenable unwrap). Throwing rejects it. Omitting onFulfilled passes the value through; omitting onRejected passes the reason through. That is how .then(fn) skips, and how a later .then(null, fn) still sees the rejection.

resolve is not fulfill. If you resolve a thenable, you stay pending until that thenable settles. Native engines enqueue a thenable job; the queueMicrotask in unwrapThenable is that job. The called flag matches the spec: if then invokes both callbacks, or throws after already calling one, the first call wins.

Cycle: resolve(promise) where promise is the same object is a TypeError. A thenable that later resolves to that same promise hits the same check on the recursive settle. You do not need a novel about every Promises/A+ edge; you do need to name this one.

const p = new MyPromise((resolve) => resolve(1));
p.then((v) => {
  console.log(v);
  return v + 1;
}).then((v) => console.log(v));
console.log('sync');
// sync, 1, 2  (both logs after the current turn)

new MyPromise((_, reject) => reject(new Error('x'))).then(
  null,
  (err) => err.message,
);

new MyPromise((resolve) => {
  resolve({
    then(res) {
      res(10);
    },
  });
}).then((v) => v); // 10

MyPromise.resolve = (v) => new MyPromise((res) => res(v)) and reject analog are optional helpers. catch is then(undefined, onRejected). Leave finally to the finally polyfill.

Footguns in a custom Promise

Sync onFulfilled from resolve is the classic miss. It breaks const x = getState(); p.then(() => use(x)) patterns that assume the current stack finished.

Forgetting to return a new promise from then makes chaining impossible and is not a Promise.

Resolving with a native Promise must still unwrap (result.then is callable). If you special-case only instanceof MyPromise, mixed thenables stall or nest.

Not using a called flag: a buggy thenable that calls resolve then throws will reject after fulfill if you handle the throw. State would try to move twice; your pending guard saves you only if throw happens after settle set the state. The throw in then.call happens in the job, and if resolve already ran, called must ignore the throw.

queueMicrotask vs setTimeout(fn, 0): timers are macrotasks. Using setTimeout makes your Promise lose to native promises and fail the “promise before timeout” output question. See the event loop.

Do not implement all inside this page. Point at the Promise.all polyfill if they ask for combinators next.

This object is a teaching subset. Species, unhandledrejection, and assimilation of foreign thenables under every Get then throw are the rest of the spec. Say what you omitted.

Interview answer (out loud)

“A Promise is pending, then fulfilled or rejected once. The executor runs now; then always returns a new promise and queues reactions as microtasks. resolve adopts thenables and rejects with TypeError if you resolve with the same promise. A called flag makes thenable then safe if it invokes twice or throws after settling. I would not flush handlers synchronously, and I would not use setTimeout for reactions.”

Further reading

Related guides