JavaScript Promises From Scratch: A Beginner’s Walkthrough for Technical Interviews

Ronak MathurRonak Mathur
8 min read

Demystifying a Custom Promise in JavaScript

Why build your own promise?
Before we dive in, let’s remember that native Promises in JavaScript help us handle asynchronous work—things like fetching data from a server or waiting for a timer. But implementing your own is a fantastic exercise to understand how Promises really work under the hood.


🔗 Prerequisites

If any of these concepts are brand‑new to you, take a quick detour through the MDN docs before proceeding:


The Big Picture

A CustomePromise has three main pieces:

  1. State tracking (PENDINGFULFILLED or REJECTED)

  2. Queuing up callbacks (the functions you pass into .then())

  3. Executing those callbacks once the promise settles

By the end of this post, you’ll see how it all fits together.


1. Defining the States

const STATES = {
  PENDING:  "pending",
  FULFILLED: "fulfilled",
  REJECT:   "rejected"
}

We start by giving our promise three possible moods:

  • Pending: nothing’s happened yet

  • Fulfilled: it succeeded

  • Rejected: it failed


2. The Constructor

class CustomePromise {
  #handleExecutor = [];
  #state         = STATES.PENDING;
  #value         = "";

  constructor(callback) {
    try {
      callback(this.#resolve, this.#reject); //  executors obtains the result either resolve and reject
      this.#handleUpdates(this.#state, this.#value);
    } catch (error) {
      this.#reject(error);
    }
  }
  // …
}
  • We accept an executor function (just like native new Promise((resolve, reject) => {…})).

  • Immediately call it with our own #resolve and #reject methods.

  • We wrap it in try…catch so any synchronous error goes to rejection.


3. Resolving & Rejecting

#resolve = (data) => {
  this.#handleUpdates(STATES.FULFILLED, data);
}

#reject = (error) => {
  this.#handleUpdates(STATES.REJECT, error);
}

Both these methods simply hand off to a shared helper, #handleUpdates, telling it the new state and value.


4. Handling Updates & Simulating Async

#handleUpdates = (state, value) => {
    if (this.#state !== STATES.PENDING) return;

    // as we know then() method handle async data return value and return it...
    // here to mimic the async operation we just used the setTimeout
    setTimeout(() => {
      if (value instanceof CustomePromise) {
        // if the value is instance of CustomePromise then we need to wait for it to resolve
        // this will wait for the value to resolve and then return the value
        // this will also handle the case where the value is a promise
        // and we need to wait for it to resolve before returning it
        value.then();
      }

      // these fields are used to track the state of promise
      // and the value that is returned from the executor function
      // this will be used to track the state of promise and return the value to then() method
      this.#value = value;
      this.#state = state;
      console.log("state ", this.#state, " value ", this.#value);
      this.#handleExcutor(); // this will handle and call all the executor functions that are pushed in the array
    }, 1000);
  };
  • Only run once (we bail out if not pending).

  • Use setTimeout to ensure .then() callbacks never fire synchronously.

  • Support chaining promises: if you resolve( anotherPromise ), we wait for it.


5. Queuing Handlers

Every time you do .then(onSuccess, onFailure), you want to remember those two callbacks. We do that here:

#pushHandler = (executor) => {
  this.#handleExecutor.push(executor);
  this.#handleExcutor(); // this to support late .then()
}

We don’t invoke them immediately, because the promise might still be pending.


6. Running the Queue

Once we settle, we loop through every queued handler:

 #handleExcutor = () => {
    if (this.#state === STATES.PENDING) return;

    // this will handle the case where the promise is resolved or rejected
    if (this.#state === STATES.FULFILLED) {
      this.#handleExecutor.forEach((executor) => {
        executor.onSucess(this.#value);
      });
    } else if (this.#state === STATES.REJECT) {
      // this will handle the case where the promise is rejected
      // this will call the onFailure method of the executor function that is pushed in the array
      // this will handle the case where the promise is rejected and return the value to then() method
      // this will also handle the case where the value is a promise and we need to wait for it to resolve before returning it
      this.#handleExecutor.forEach((executor) => {
        executor.onFailure(this.#value);
      });
    }
    // reset the handleExecutor array to avoid memory leak
    this.#handleExecutor = [];
    this.#state = STATES.PENDING; // reset the state to pending to allow new promise to be created
    this.#value = ""; // reset the value to empty string to allow new promise to be created
  };
  • If fulfilled, call each handler’s onSucess(value).

  • If rejected, call each handler’s onFailure(value).

  • Then clear out everything so a new promise can start fresh.


7. The .then() Method

Here’s where we return a brand‑new CustomePromise, enabling chaining:

then = (onSucess, onFailure) => {
  return new CustomePromise((resolve, reject) => {
    const handleSuccess = {
      onSucess: (value) => {
        if (!onSucess) return resolve(value);
        try {
          const result = onSucess(value);
          if (result instanceof CustomePromise) {
            result.then(resolve, reject);
          } else {
            resolve(result);
          }
        } catch (error) {
          reject(error);
        }
      },
      onFailure: (error) => {
        if (!onFailure) return reject(error);
        try {
          const result = onFailure(error);
          if (result instanceof CustomePromise) {
            result.then(resolve, reject);
          } else {
            resolve(result);
          }
        } catch (error) {
          reject(error);
        }
      },
    };

    this.#pushHandler(handleSuccess);
  });
};
  • We wrap your handlers in a wrapper that:

    1. Calls your onSuccess/onFailure if provided

    2. Catches exceptions and rejects accordingly

    3. If you return another promise, we chain it


8. The .catch() Shortcut

catch = (onError) => {
  return this.then(null, onError);
}

Exactly like native promises, .catch(fn) is just .then(undefined, fn) under the hood.


Putting It All Together

// using # infront of methods or fieldname

const STATES = {
  PENDING: "pending",
  FULFILLED: "fulfilled",
  REJECT: "rejected",
};

class CustomePromise {
  #handleExecutor = [];
  #state = STATES.PENDING;
  #value = "";
  constructor(callback) {
    try {
      callback(this.#resolve, this.#reject); // callback function i.e. executors functions which accept two params resolve and reject
      this.#handleUpdates(this.#state, this.#value);
    } catch (error) {
      this.#reject(error);
    }
  }

  #resolve = (data) => {
    this.#handleUpdates(STATES.FULFILLED, data);
  };

  #reject = (error) => {
    // console.error(error)
    this.#handleUpdates(STATES.REJECT, error);
  };

  // this method handle whatever the updated value we get from callback function (i.e. either resolve or reject...)
  #handleUpdates = (state, value) => {
    if (this.#state !== STATES.PENDING) return;
    // as we know then() method handle async data return value and return it...
    // here to mimic the async operation we just used the setTimeout
    setTimeout(() => {
      if (value instanceof CustomePromise) {
        // if the value is instance of CustomePromise then we need to wait for it to resolve
        // this will wait for the value to resolve and then return the value
        // this will also handle the case where the value is a promise
        // and we need to wait for it to resolve before returning it
        value.then();
      }
      // these fields are used to track the state of promise
      // and the value that is returned from the executor function
      // this will be used to track the state of promise and return the value to then() method
      this.#value = value;
      this.#state = state;
      this.#handleExcutor(); // this will handle and call all the executor functions that are pushed in the array
    }, 1000);
  };

  // main executor function that handle tboth resolve and reject cases
  #pushHandler = (executor) => {
    this.#handleExecutor.push(executor);
    this.#handleExcutor();  // this to support late .then()
  };

  #handleExcutor = () => {
    if (this.#state === STATES.PENDING) return;

    // this will handle the case where the promise is resolved or rejected
    if (this.#state === STATES.FULFILLED) {
      this.#handleExecutor.forEach((executor) => {
        executor.onSucess(this.#value);
      });
    } else if (this.#state === STATES.REJECT) {
      // this will handle the case where the promise is rejected
      // this will call the onFailure method of the executor function that is pushed in the array
      // this will handle the case where the promise is rejected and return the value to then() method
      // this will also handle the case where the value is a promise and we need to wait for it to resolve before returning it
      this.#handleExecutor.forEach((executor) => {
        executor.onFailure(this.#value);
      });
    }
    // reset the handleExecutor array to avoid memory leak
    this.#handleExecutor = [];
    this.#state = STATES.PENDING; // reset the state to pending to allow new promise to be created
    this.#value = ""; // reset the value to empty string to allow new promise to be created
  };

  then = (onSucess, onFailure) => {
  return new CustomePromise((resolve, reject) => {
    const handleSuccess = {
      onSucess: (value) => {
        if (!onSucess) return resolve(value);
        try {
          const result = onSucess(value); 
          // support promise chaining
          if (result instanceof CustomePromise) {
            result.then(resolve, reject);
          } else {
            resolve(result);
          }
        } catch (error) {
          reject(error);
        }
      },
      onFailure: (error) => {
        if (!onFailure) return reject(error);
        try {
          const result = onFailure(error);
          if (result instanceof CustomePromise) {
            result.then(resolve, reject);
          } else {
            resolve(result);
          }
        } catch (error) {
          reject(error);
        }
      },
    };

    this.#pushHandler(handleSuccess);
  });
};


  catch = (onError) => {
    this.then(null, onError);
  };
}

const customePromise = new CustomePromise((res, rej) => {
  res("Done!")
})
  .then(msg => {
    console.log(msg)   
  })
  .catch(err => console.error("Oops:", err));
 // Output -> Done
  1. Executor runs immediately, schedules a 500 ms callback.

  2. After 500 ms, #resolve("Done!")#handleUpdates → queue flush.

  3. First .then() handler logs “Done!” and returns "Next step".

  4. That value gets passed to the next .then(), logging “Next step”.


Why Build a Custom Promise?

  • Deepens understanding: You see how state, queues, and chaining work.

  • Hands‑on experience with async patterns.

  • Appreciation for the polished behavior of native Promise.

I hope this guide makes custom Promises click for you! Feel free to implement by yourself with this reference, you will definetly learn tons along the way.

I'd love to hear what you think!
Leave a comment below or connect with me on GitHub / LinkedIn

0
Subscribe to my newsletter

Read articles from Ronak Mathur directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ronak Mathur
Ronak Mathur