Promise in NodeJS. Promise allow you to deal with eventual… | by Ajay Kumar  Pandit | Dev Genius

Ever wondered how JavaScript Promises work under the hood? In this blog, we’ll walk through how to implement a Promise from scratch — step-by-step, with simple explanations. By the end, you’ll not only understand Promises deeply, but also how chaining, error handling, and cleanup work.


What is a Promise?

A Promise is a JavaScript object that represents the eventual completion or failureof an asynchronous operation and its resulting value.

A Promise can be in one of three states:

  • Pending – The initial state.
  • Fulfilled – The operation completed successfully.
  • Rejected – The operation failed.

Step 1: Basic Custom Promise Implementation

Let’s build a class called MyPromise with basic functionality.

class MyPromise {
  constructor(executor) {
    this.state = 'pending';
    this.value = undefined;
    this.reason = undefined;

    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    const resolve = (value) => {
      if (this.state === 'pending') {
        this.state = 'fulfilled';
        this.value = value;
        this.onFulfilledCallbacks.forEach(callback => callback(value));
      }
    };

    const reject = (reason) => {
      if (this.state === 'pending') {
        this.state = 'rejected';
        this.reason = reason;
        this.onRejectedCallbacks.forEach(callback => callback(reason));
      }
    };

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

  then(onFulfilled, onRejected) {
    if (this.state === 'fulfilled') {
      onFulfilled(this.value);
    } else if (this.state === 'rejected') {
      onRejected(this.reason);
    } else {
      this.onFulfilledCallbacks.push(onFulfilled);
      this.onRejectedCallbacks.push(onRejected);
    }
  }
}

What Each Part Does

  • state, value, and reason store the Promise status and result.
  • resolve() transitions state to 'fulfilled' and triggers success callbacks.
  • reject() transitions to 'rejected' and triggers failure callbacks.
  • then() allows users to attach .then() callbacks.

Step 2: Add Chaining and Error Handling

We now enhance our then() method to support chaining and error handling, like .then().then().catch().

class MyPromise {
  constructor(executor) {
    this.state = 'pending';
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    const resolve = (value) => {
      if (this.state === 'pending') {
        this.state = 'fulfilled';
        this.value = value;
        this.onFulfilledCallbacks.forEach(cb => cb());
      }
    };

    const reject = (reason) => {
      if (this.state === 'pending') {
        this.state = 'rejected';
        this.reason = reason;
        this.onRejectedCallbacks.forEach(cb => cb());
      }
    };

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

  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : val => val;
    onRejected = typeof onRejected === 'function' ? onRejected : err => { throw err; };

    const promise2 = new MyPromise((resolve, reject) => {
      if (this.state === 'fulfilled') {
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value);
            resolvePromise(promise2, x, resolve, reject);
          } catch (error) {
            reject(error);
          }
        });
      } else if (this.state === 'rejected') {
        setTimeout(() => {
          try {
            const x = onRejected(this.reason);
            resolvePromise(promise2, x, resolve, reject);
          } catch (error) {
            reject(error);
          }
        });
      } else {
        this.onFulfilledCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onFulfilled(this.value);
              resolvePromise(promise2, x, resolve, reject);
            } catch (error) {
              reject(error);
            }
          });
        });

        this.onRejectedCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onRejected(this.reason);
              resolvePromise(promise2, x, resolve, reject);
            } catch (error) {
              reject(error);
            }
          });
        });
      }
    });

    return promise2;
  }

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

Step 3: Implement resolvePromise Helper Function

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

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

Step 4: Add finally() and Static Helpers

MyPromise.prototype.finally = function(callback) {
  return this.then(
    value => MyPromise.resolve(callback()).then(() => value),
    reason => MyPromise.resolve(callback()).then(() => { throw reason; })
  );
};

MyPromise.resolve = function(value) {
  return new MyPromise(resolve => resolve(value));
};

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

Usage Example

new MyPromise((resolve, reject) => {
  setTimeout(() => resolve("Step 1 done"), 1000);
})
  .then(res => {
    console.log(res);
    return "Step 2";
  })
  .then(res => {
    console.log(res);
    throw new Error("Something went wrong");
  })
  .catch(err => {
    console.error("Caught error:", err.message);
  })
  .finally(() => {
    console.log("Cleaned up!");
  });

Alternative Ways to Handle Async Logic

1. Callback Pattern (Old School)

doSomething((err, result) => {
  if (err) {
    handleError(err);
  } else {
    doAnother(result, (err2, res2) => {
      // Callback Hell!
    });
  }
});

2. Async/Await (Modern)

async function runTasks() {
  try {
    const res1 = await doStep1();
    const res2 = await doStep2(res1);
    console.log(res2);
  } catch (err) {
    console.error("Error:", err);
  }
}

Why Build Custom Promises?

  • You deeply understand async patterns
  • You learn what’s happening behind then(), catch(), and await
  • It sharpens your problem-solving and debugging skills

Resources


Final Words

Now that you’ve implemented your own version of JavaScript Promises, you have a powerful new mental model for async JavaScript. You’re not just using promises — you understand how they work from the inside out.

Happy coding!