DevLift
Back to Blog

Build Your Own Promise

Build a spec-compliant Promise from scratch in 75 lines of JavaScript. Understand state machines, microtask scheduling, thenable resolution, and why .then(s,e) and .then(s).catch(e) aren't the same.

Admin
July 31, 20265 min read0 views

Build Your Own Promise

You've probably used Promises every single day for years. fetch().then().catch() — muscle memory at this point. But do you know what actually happens when you call .then()? Or why console.log('sync') always runs before your .then() callback, even when the promise is already resolved?

Building a Promise from scratch is one of the most instructive things you can do as a JavaScript developer. Not because you'll ever ship a hand-rolled Promise to production, but because once you do it, a dozen async bugs you've seen before suddenly make sense.

Let's build one. We'll implement the Promises/A+ spec — the standard that native Promises follow — in about 120 lines of JavaScript.

How a Promise Actually Works

Before writing code, here's the shape of the thing we're building:

Rendering diagram...

The key insight: a Promise is a state machine with exactly three states. Once it leaves pending, it's locked in — no state reversals, no value mutations. Every .then() call returns a new promise. The original never changes.

Step 1: The State Machine

Start with the skeleton: state tracking and the executor.

1

Set up state and the executor

// my-promise.js
 
const STATE = {
  PENDING: 'pending',
  FULFILLED: 'fulfilled',
  REJECTED: 'rejected',
};
 
class MyPromise {
  #state = STATE.PENDING;
  #value = undefined;
  #handlers = []; // { onFulfilled, onRejected, resolve, reject }
 
  constructor(executor) {
    // executor is the function you pass: new MyPromise((resolve, reject) => ...)
    try {
      executor(this.#resolve.bind(this), this.#reject.bind(this));
    } catch (err) {
      this.#reject(err);
    }
  }
 
  // Step 3 replaces this with the full Promise Resolution Procedure.
  // For now, treat every value as a plain value.
  #resolve(value) {
    this.#settle(STATE.FULFILLED, value);
  }
 
  #reject(reason) {
    this.#settle(STATE.REJECTED, reason);
  }
 
  #settle(state, value) {
    if (this.#state !== STATE.PENDING) return; // Once settled, never change
    this.#state = state;
    this.#value = value;
    this.#processHandlers();
  }
 
  #processHandlers() {
    // We'll fill this in next
  }
}

The guard at the top of #settle is load-bearing. Without it, calling both resolve and reject in the executor (or resolving twice) would corrupt the promise state. The rule: first settlement wins, all others are silently dropped.

Step 2: The then() Method

This is the hard part. .then() has to return a new promise, queue the callbacks asynchronously, and run the Promise Resolution Procedure on whatever the callback returns.

2

Implement then()

  then(onFulfilled, onRejected) {
    // If handlers aren't functions, pass the value/reason through
    const _onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : (v) => v;
    const _onRejected =
      typeof onRejected === 'function'
        ? onRejected
        : (r) => {
            throw r;
          };
 
    return new MyPromise((resolve, reject) => {
      // Store the handler + the resolve/reject of the NEW promise
      this.#handlers.push({
        onFulfilled: _onFulfilled,
        onRejected: _onRejected,
        resolve,
        reject,
      });
 
      // If this promise already settled, process immediately
      this.#processHandlers();
    });
  }

And now #processHandlers, which does the actual work:

  #processHandlers() {
    if (this.#state === STATE.PENDING) return; // Not settled yet, wait
 
    for (const handler of this.#handlers) {
      this.#runHandler(handler);
    }
    this.#handlers = []; // Clear processed handlers
  }
 
  #runHandler({ onFulfilled, onRejected, resolve, reject }) {
    const callback = this.#state === STATE.FULFILLED ? onFulfilled : onRejected;
 
    // THE KEY: always run callbacks asynchronously, even if already settled
    queueMicrotask(() => {
      try {
        // `resolve` belongs to the downstream promise. Step 3 teaches it what
        // to do when the callback hands back a promise instead of a value.
        resolve(callback(this.#value));
      } catch (err) {
        reject(err);
      }
    });
  }

Two things to notice:

  1. queueMicrotask is mandatory. Even if this promise is already settled, callbacks must run after the current synchronous code finishes. This is why Promise.resolve(42).then(x => console.log(x)); console.log('sync') always prints sync first.

  2. The handler stores resolve and reject from the returned promise — not the current one. When the callback finishes, we resolve (or reject) that downstream promise.

Step 3: The Resolution Procedure

Here's where most tutorials cut corners — and where the compliance suite does all its damage. Resolving a promise with a value x is not one operation, it's four, and which one you get depends entirely on what x is.

If x is a plain value, we're done: fulfill with it. If x is another promise or any "thenable", we have to wait for that to settle first. And if x is the promise we're resolving, there's nothing to wait for and we have to say so.

Rendering diagram...

The place to put this is #resolve itself. That's the one decision that keeps the rest of the class small: the resolution procedure belongs to resolving a promise, not to running a then callback. Do it there and everything downstream inherits it for free — the resolve you get in the executor, the resolve that #runHandler calls, MyPromise.resolve, all of it.

3

Implement the Resolution Procedure

  #resolve(x) {
    // `this` is the promise being resolved, so the self-reference check is direct.
    // Without it, `const p = q.then(() => p)` would recurse until the stack died.
    if (x === this) {
      this.#settle(STATE.REJECTED, new TypeError('Chaining cycle detected for promise'));
      return;
    }
 
    if (x instanceof MyPromise) {
      // x is one of ours — adopt its state
      x.then(this.#resolve.bind(this), this.#reject.bind(this));
      return;
    }
 
    if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
      // x might be a foreign thenable (a non-MyPromise with a .then method)
      let then;
      try {
        then = x.then;
      } catch (err) {
        // Merely *reading* .then threw — a getter can do that. Reject with it.
        this.#settle(STATE.REJECTED, err);
        return;
      }
 
      if (typeof then === 'function') {
        let called = false; // Only honor the first settlement
 
        try {
          then.call(
            x,
            (y) => {
              if (called) return;
              called = true;
              // Recurse: the thenable might resolve to another thenable
              this.#resolve(y);
            },
            (r) => {
              if (called) return;
              called = true;
              this.#reject(r);
            }
          );
        } catch (err) {
          if (!called) {
            called = true;
            this.#settle(STATE.REJECTED, err);
          }
        }
        return;
      }
    }
 
    // Plain value — fulfill
    this.#settle(STATE.FULFILLED, x);
  }

The called flag is critical. A badly-behaved thenable could call both resolve and reject, or call resolve three times. We honour the first and drop the rest.

Note what this buys you beyond .then(). new MyPromise((resolve) => resolve(fetchSomething())) now waits for that promise instead of fulfilling with the promise object — which is what people actually write, and the single easiest thing to get wrong. Put the procedure in #runHandler instead and that pattern silently hands your callback a Promise where you expected a value.

Step 4: catch() and finally()

Both of these are pure sugar on top of then(). No new machinery needed.

4

Add catch() and finally()

  catch(onRejected) {
    return this.then(null, onRejected);
  }
 
  finally(onFinally) {
    return this.then(
      (value) => MyPromise.resolve(onFinally()).then(() => value),
      (reason) =>
        MyPromise.resolve(onFinally()).then(() => {
          throw reason;
        })
    );
  }

finally is trickier than it looks. It has to:

  • Call onFinally regardless of how the promise settled
  • Pass the original value/reason through (as if finally wasn't there)
  • But if onFinally throws or returns a rejected promise, that rejection wins

Wrapping onFinally() in MyPromise.resolve() handles the case where it returns a promise — we wait for it to settle before passing the value through.

Step 5: Static Methods

5

Add static resolve() and reject()

  static resolve(value) {
    // If it's already one of ours, return it directly — no wrapping
    if (value instanceof MyPromise) return value;
    return new MyPromise((resolve) => resolve(value));
  }
 
  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason));
  }
 
  static all(promises) {
    return new MyPromise((resolve, reject) => {
      const results = [];
      let remaining = promises.length;
 
      if (remaining === 0) {
        resolve(results);
        return;
      }
 
      promises.forEach((p, i) => {
        MyPromise.resolve(p).then((value) => {
          results[i] = value;
          if (--remaining === 0) resolve(results);
        }, reject);
      });
    });
  }

Promise.all is a good stress test — it exercises multiple pending promises, correct index ordering of results, and the "first rejection wins" behavior.

Testing It Out

6

Run the demo

// demo.js
// (paste the MyPromise class above, then add this)
 
// Basic chaining
new MyPromise((resolve) => {
  setTimeout(() => resolve(1), 100);
})
  .then((x) => x * 2)
  .then((x) => x + 10)
  .then((x) => console.log('Chain result:', x)); // 12
 
// Catch
new MyPromise((_, reject) => reject(new Error('boom')))
  .then(() => console.log('should not run'))
  .catch((err) => console.log('Caught:', err.message)); // Caught: boom
 
// then(success, error) vs then(success).catch(error)
const dangerous = new MyPromise((resolve) => resolve('ok'));
 
// This will NOT catch the error thrown by the success handler:
dangerous.then(
  () => { throw new Error('thrown in then'); },
  (err) => console.log('then(s,e) caught:', err.message) // Never runs
);
 
// This WILL catch it:
dangerous
  .then(() => { throw new Error('thrown in then'); })
  .catch((err) => console.log('.catch() caught:', err.message)); // Runs!
 
// Async resolution (thenable interop)
const foreignThenable = {
  then(resolve) {
    setTimeout(() => resolve(42), 50);
  },
};
 
MyPromise.resolve(foreignThenable).then((v) => console.log('Thenable:', v)); // 42
 
// Promise.all
MyPromise.all([
  MyPromise.resolve(1),
  new MyPromise((resolve) => setTimeout(() => resolve(2), 50)),
  MyPromise.resolve(3),
]).then((values) => console.log('All:', values)); // [1, 2, 3]
 
// finally
new MyPromise((resolve) => resolve('data'))
  .then((v) => v.toUpperCase())
  .finally(() => console.log('Cleanup always runs'))
  .then((v) => console.log('Value preserved:', v)); // DATA

Run it with node demo.js — no dependencies needed. The output:

Caught: boom
.catch() caught: thrown in then
Cleanup always runs
Value preserved: DATA
Thenable: 42
All: [ 1, 2, 3 ]
Chain result: 12

The order is the interesting part, and it isn't the order the code is written in. The first four lines come from promises that were already settled, so their callbacks run on the microtask queue as soon as the synchronous pass finishes. Thenable and All wait on 50ms timers. Chain result is last because its executor sits on a 100ms timer — the chain of three .then() calls costs nothing, it's the setTimeout that dominates.

The Full Implementation

Here's everything in one place:

// my-promise.js
 
const STATE = { PENDING: 'pending', FULFILLED: 'fulfilled', REJECTED: 'rejected' };
 
class MyPromise {
  #state = STATE.PENDING;
  #value = undefined;
  #handlers = [];
 
  constructor(executor) {
    try {
      executor(this.#resolve.bind(this), this.#reject.bind(this));
    } catch (err) {
      this.#reject(err);
    }
  }
 
  #resolve(x) {
    if (x === this) {
      this.#settle(STATE.REJECTED, new TypeError('Chaining cycle detected for promise'));
      return;
    }
 
    if (x instanceof MyPromise) {
      x.then(this.#resolve.bind(this), this.#reject.bind(this));
      return;
    }
 
    if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
      let then;
      try { then = x.then; } catch (err) { this.#settle(STATE.REJECTED, err); return; }
 
      if (typeof then === 'function') {
        let called = false;
        try {
          then.call(
            x,
            (y) => { if (!called) { called = true; this.#resolve(y); } },
            (r) => { if (!called) { called = true; this.#reject(r); } }
          );
        } catch (err) {
          if (!called) { called = true; this.#settle(STATE.REJECTED, err); }
        }
        return;
      }
    }
 
    this.#settle(STATE.FULFILLED, x);
  }
 
  #reject(reason) { this.#settle(STATE.REJECTED, reason); }
 
  #settle(state, value) {
    if (this.#state !== STATE.PENDING) return;
    this.#state = state;
    this.#value = value;
    this.#processHandlers();
  }
 
  #processHandlers() {
    if (this.#state === STATE.PENDING) return;
    for (const handler of this.#handlers) this.#runHandler(handler);
    this.#handlers = [];
  }
 
  #runHandler({ onFulfilled, onRejected, resolve, reject }) {
    const cb = this.#state === STATE.FULFILLED ? onFulfilled : onRejected;
    queueMicrotask(() => {
      try {
        resolve(cb(this.#value));
      } catch (err) {
        reject(err);
      }
    });
  }
 
  then(onFulfilled, onRejected) {
    const _onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : (v) => v;
    const _onRejected  = typeof onRejected  === 'function' ? onRejected  : (r) => { throw r; };
    return new MyPromise((resolve, reject) => {
      this.#handlers.push({ onFulfilled: _onFulfilled, onRejected: _onRejected, resolve, reject });
      this.#processHandlers();
    });
  }
 
  catch(onRejected) { return this.then(null, onRejected); }
 
  finally(onFinally) {
    return this.then(
      (value)  => MyPromise.resolve(onFinally()).then(() => value),
      (reason) => MyPromise.resolve(onFinally()).then(() => { throw reason; })
    );
  }
 
  static resolve(value) {
    if (value instanceof MyPromise) return value;
    return new MyPromise((resolve) => resolve(value));
  }
 
  static reject(reason) { return new MyPromise((_, reject) => reject(reason)); }
 
  static all(promises) {
    return new MyPromise((resolve, reject) => {
      const results = [];
      let remaining = promises.length;
      if (remaining === 0) { resolve(results); return; }
      promises.forEach((p, i) => {
        MyPromise.resolve(p).then((value) => {
          results[i] = value;
          if (--remaining === 0) resolve(results);
        }, reject);
      });
    });
  }
}
 
module.exports = { MyPromise };

Just under 100 lines of logic, no dependencies. It also passes the full Promises/A+ suite — npx promises-aplus-tests adapter.js against an adapter that exposes deferred, resolved and rejected. Do that before you trust any hand-rolled promise, including this one: the failures are almost always in the resolution procedure, and they are the failures you'd never think to test by hand.

What We Skipped

This is a teaching implementation, not production code. Here's what the real thing adds:

  • Promise.allSettled, Promise.race, Promise.any — Different "combining" semantics on top of the same machinery
  • Unhandled rejection tracking — The runtime detects when a rejected promise has no .catch() and fires unhandledRejection events. Requires a GC-aware bookkeeping layer
  • Stack trace preservation — V8's native Promise implementation does significant work to give you useful async stack traces (Error.captureStackTrace, async hooks)
  • Performance — V8's Promise is implemented in C++. queueMicrotask exists, but native microtask scheduling is orders of magnitude faster than JS-land scheduling
  • Promise.withResolvers() — The new static that exposes { promise, resolve, reject } directly

If you want to go deeper, look at the Promises/A+ spec and run the official compliance test suite against your implementation.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Build a pub/sub system from 60 lines of JavaScript: subscribe, publish, unsubscribe, once(), namespace wildcards, error isolation, and async publish.
AdminJuly 31, 20266 min read
An Observable is just a function. Build one from scratch in about 120 lines of JavaScript and finally understand why unsubscribing stops work, how operators chain, and what 'lazy' actually means.
AdminJuly 31, 20265 min read
Picture this: a live dashboard that polls for new notifications every 3 seconds. The count increments in state, the UI updates, everything looks right — except the interval callback is silently reading count = 0 on every tick, because it…
AdminJuly 31, 20266 min read