JavaScriptHard

Implement a Promise from scratch (states, then, chaining)

01

The Short Answer

A Promise is a state machine with three states — pending, fulfilled, and rejected — that can transition only once, from pending to either settled state. Building one from scratch means modelling those states, storing the value or reason, queuing the callbacks registered through then, and making then return a new Promise so chaining works. That last part is where most of the real complexity lives.

The reference below is the behaviour we are recreating: an executor that receives resolve and reject, and a then that can be chained. Keep it in mind as the target — every piece of our implementation exists to make this pattern work.

native-promise.tstypescript
new Promise((resolve, reject) => {
  setTimeout(() => resolve(10), 100);
})
  .then((value) => value * 2)
  .then((doubled) => console.log(doubled)) // 20
  .catch((err) => console.error(err));
02

The State Machine

Everything starts with the three states and the one-way transitions between them. Once a Promise settles it is frozen forever — later calls to resolve or reject are ignored. Picturing this as a sequence of allowed moves makes the guard logic in our code obvious.

Allowed transitions

  • Starts in `pending`
  • `pending` → `fulfilled` when resolve(value) is called
  • `pending` → `rejected` when reject(reason) is called
  • Once settled, the state and value can never change again
03

The Constructor and resolve/reject

The constructor runs the executor immediately, passing it our internal resolve and reject functions. Each transition is guarded so it only fires while still pending. We also keep two queues of callbacks — handlers registered by then before the Promise settled need to fire the moment it does. Read how resolve and reject flush those queues.

my-promise-core.tstypescript
class MyPromise {
  constructor(executor) {
    this.state = "pending";
    this.value = undefined;
    this.onFulfilledCallbacks = []; // queued .then success handlers
    this.onRejectedCallbacks = [];  // queued .then failure handlers

    const resolve = (value) => {
      if (this.state !== "pending") return; // transition only once
      this.state = "fulfilled";
      this.value = value;
      this.onFulfilledCallbacks.forEach((cb) => cb());
    };

    const reject = (reason) => {
      if (this.state !== "pending") return;
      this.state = "rejected";
      this.value = reason;
      this.onRejectedCallbacks.forEach((cb) => cb());
    };

    try {
      executor(resolve, reject); // run the user's code immediately
    } catch (err) {
      reject(err); // a throw inside the executor rejects the promise
    }
  }
}

Why we queue callbacks

If a Promise is still pending when .then() runs, the handler cannot fire yet. We stash it in a queue; when resolve or reject eventually runs, it drains the queue. If the Promise is already settled, .then() schedules the handler right away.

04

Implementing then and Chaining

then is the heart of the Promise. It must return a new Promise so calls can chain, and the value returned from a handler becomes the resolution of that next Promise. We also defer every handler with a microtask (queueMicrotask) to honour the guarantee that .then callbacks always run asynchronously, never in the same tick. Trace how the returned Promise resolves with the handler's result and rejects if the handler throws.

my-promise-then.tstypescript
then(onFulfilled, onRejected) {
  // a thrown handler must propagate, so default handlers pass through
  onFulfilled = typeof onFulfilled === "function" ? onFulfilled : (v) => v;
  onRejected = typeof onRejected === "function" ? onRejected : (e) => { throw e; };

  return new MyPromise((resolve, reject) => {
    const handleFulfilled = () => {
      queueMicrotask(() => {
        try {
          resolve(onFulfilled(this.value)); // result feeds the next promise
        } catch (err) {
          reject(err);
        }
      });
    };

    const handleRejected = () => {
      queueMicrotask(() => {
        try {
          resolve(onRejected(this.value)); // catch can recover the chain
        } catch (err) {
          reject(err);
        }
      });
    };

    if (this.state === "fulfilled") handleFulfilled();
    else if (this.state === "rejected") handleRejected();
    else {
      // still pending — queue for later
      this.onFulfilledCallbacks.push(handleFulfilled);
      this.onRejectedCallbacks.push(handleRejected);
    }
  });
}

catch(onRejected) {
  return this.then(undefined, onRejected); // catch is just then with no success handler
}

Notice two design choices. First, catch is nothing more than then(undefined, onRejected) — that is exactly how the real Promise defines it. Second, the default pass-through handlers ((v) => v and (e) => { throw e; }) are what let a value or an error skip past .then calls that do not handle them, so a rejection can travel down the chain until it hits a .catch.

Production note

A fully spec-compliant Promise (Promises/A+) also unwraps thenables — if a handler returns another promise, the outer one waits for it. The version here covers states, then, catch, and chaining; resolving nested thenables is the natural next step to mention in an interview.

05

Common Mistakes

🔁

Letting a Promise settle more than once

Without the `if (this.state !== 'pending') return` guard, a second resolve or reject call can overwrite the value, breaking the core one-way contract.

Guard every transition so only the first resolve or reject takes effect.

⏱️

Running then handlers synchronously

Calling the handler inline makes your Promise fire in the same tick, which contradicts the spec and causes ordering bugs against native promises.

Defer handlers with queueMicrotask so they always run asynchronously.

06

Why Interviewers Ask This

Promises sit under async/await and nearly every data-fetching path, so building one proves you understand the asynchronous model end to end — state transitions, callback queues, the microtask timing guarantee, and how chaining threads a value through then. Candidates who can articulate why catch is just then(undefined, fn) and why handlers must be deferred stand out immediately.

Quick Revision Cheat Sheet

States: pending → fulfilled or rejected, one-way only

Executor: Runs immediately; a throw inside it rejects the promise

then: Returns a NEW promise so calls can chain

catch: Sugar for then(undefined, onRejected)

Timing: Handlers run as microtasks, never synchronously

Pending case: Queue handlers and flush them on settle