DEV Community

Sai Swaroop Bijinapalli
Sai Swaroop Bijinapalli

Posted on

Custom Promise in JS

Implementing a Custom Promise from Scratch in JavaScript

Promises are one of the most important features of modern JavaScript. We normally use the built-in Promise object for asynchronous operations, but understanding how a Promise works internally gives us a much deeper understanding of asynchronous JavaScript.

In this project, I implemented a simplified custom Promise class from scratch based on the core ideas of the Promises/A+ specification.

The implementation supports:

  • Promise states
  • resolve()
  • reject()
  • .then()
  • .catch()
  • .finally()
  • Promise chaining
  • Error propagation
  • Thenable resolution
  • Asynchronous .then() execution
  • Multiple .then() handlers

I also created a test suite that compares the behavior of the custom Promise with JavaScript's native Promise.


1. Why Build a Promise From Scratch?

Usually, we simply write:

const promise = new Promise((resolve, reject) => {

    resolve("Success");

});
Enter fullscreen mode Exit fullscreen mode

The JavaScript engine handles all the internal Promise behavior for us.

But implementing one ourselves helps us understand:

  • How Promise states are stored
  • How resolve() changes the state
  • How reject() works
  • How .then() callbacks are stored
  • How Promise chaining works
  • How errors propagate
  • Why .then() executes asynchronously
  • How returned Promises are adopted
  • How .catch() and .finally() are implemented

Instead of treating Promise as a black box, we can understand its internal mechanism.


2. Project Structure

I created a simple project:

custom-promise/
│
├── my-promise.js
└── test.js
Enter fullscreen mode Exit fullscreen mode

The responsibilities are:

my-promise.js
    ↓
Promise implementation

test.js
    ↓
Compare custom Promise with native Promise
Enter fullscreen mode Exit fullscreen mode

3. Promise States

The first thing the implementation needs is Promise state management.

A Promise has three states:

              Pending
             /       \
            /         \
      Fulfilled      Rejected
Enter fullscreen mode Exit fullscreen mode

A Promise starts as:

this.state = "pending";
Enter fullscreen mode Exit fullscreen mode

Then it can transition to:

pending → fulfilled
Enter fullscreen mode Exit fullscreen mode

or:

pending → rejected
Enter fullscreen mode Exit fullscreen mode

Once settled, the Promise cannot change its state again.

For example:

resolve("Success");
reject("Failed");
Enter fullscreen mode Exit fullscreen mode

The first settlement wins.


4. Creating the Custom Promise Class

The basic structure is:

class MyPromise {

    constructor(executor) {

        this.state = "pending";
        this.value = undefined;
        this.reason = undefined;

    }

}
Enter fullscreen mode Exit fullscreen mode

We store three important pieces of information:

this.state
this.value
this.reason
Enter fullscreen mode Exit fullscreen mode

For example, after:

resolve("Hello");
Enter fullscreen mode Exit fullscreen mode

the Promise contains:

state = fulfilled
value = "Hello"
Enter fullscreen mode Exit fullscreen mode

After:

reject("Error");
Enter fullscreen mode Exit fullscreen mode

it contains:

state = rejected
reason = "Error"
Enter fullscreen mode Exit fullscreen mode

5. Implementing resolve() and reject()

The executor function receives:

(resolve, reject)
Enter fullscreen mode Exit fullscreen mode

So we implement these functions ourselves.

const resolve = (value) => {
    fulfill(value);
};

const reject = (reason) => {
    rejectInternal(reason);
};
Enter fullscreen mode Exit fullscreen mode

The fulfillment function:

const fulfill = (value) => {

    if (this.state !== "pending") {
        return;
    }

    this.state = "fulfilled";
    this.value = value;

};
Enter fullscreen mode Exit fullscreen mode

The rejection function:

const rejectInternal = (reason) => {

    if (this.state !== "pending") {
        return;
    }

    this.state = "rejected";
    this.reason = reason;

};
Enter fullscreen mode Exit fullscreen mode

The check:

if (this.state !== "pending") {
    return;
}
Enter fullscreen mode Exit fullscreen mode

ensures that a Promise settles only once.


6. Storing .then() Callbacks

Consider:

const promise = new MyPromise((resolve) => {

    setTimeout(() => {
        resolve("Done");
    }, 2000);

});

promise.then(value => {
    console.log(value);
});
Enter fullscreen mode Exit fullscreen mode

The Promise is initially pending.

The .then() callback needs to wait until the Promise is fulfilled.

Therefore, we store callbacks in arrays:

this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
Enter fullscreen mode Exit fullscreen mode

When .then() is called while the Promise is pending:

Pending

onFulfilledCallbacks
        ↓
   [callback]
Enter fullscreen mode Exit fullscreen mode

When resolve() is eventually called, the stored callback can be executed.


7. Making .then() Asynchronous

Native Promise callbacks don't execute immediately.

For example:

const promise = MyPromise.resolve("Hello");

console.log("A");

promise.then(() => {
    console.log("B");
});

console.log("C");
Enter fullscreen mode Exit fullscreen mode

The expected output is:

A
C
B
Enter fullscreen mode Exit fullscreen mode

Therefore, the implementation uses:

queueMicrotask(callback);
Enter fullscreen mode Exit fullscreen mode

Instead of executing the callback immediately, it schedules it as a microtask.

This makes the custom Promise behave more like a native Promise.


8. Implementing .then()

The most important method is .then().

Its basic structure is:

then(onFulfilled, onRejected) {

    const child = new MyPromise((resolve, reject) => {

        // handle parent Promise

    });

    return child;
}
Enter fullscreen mode Exit fullscreen mode

The important part is:

return child;
Enter fullscreen mode Exit fullscreen mode

Why?

Because every .then() creates a new Promise.

For example:

promise
    .then(...)
    .then(...)
    .then(...);
Enter fullscreen mode Exit fullscreen mode

Conceptually:

Promise A
    ↓
  .then()
    ↓
Promise B
    ↓
  .then()
    ↓
Promise C
Enter fullscreen mode Exit fullscreen mode

This is what makes Promise chaining possible.


9. Handling Fulfilled Promises

When the parent Promise is fulfilled:

const handleFulfilled = () => {

    try {

        const result = onFulfilled(parent.value);

        resolve(result);

    } catch (error) {

        reject(error);

    }
};
Enter fullscreen mode Exit fullscreen mode

The resolved value is passed to the callback:

onFulfilled(parent.value)
Enter fullscreen mode Exit fullscreen mode

For example:

MyPromise.resolve(10)
    .then(value => {
        return value * 2;
    });
Enter fullscreen mode Exit fullscreen mode

The callback receives:

value = 10
Enter fullscreen mode Exit fullscreen mode

It returns:

20
Enter fullscreen mode Exit fullscreen mode

The child Promise is then resolved with 20.


10. Handling Rejected Promises

If the parent Promise is rejected:

const handleRejected = () => {

    try {

        if (typeof onRejected !== "function") {

            reject(parent.reason);
            return;

        }

        const result = onRejected(parent.reason);

        resolve(result);

    } catch (error) {

        reject(error);

    }
};
Enter fullscreen mode Exit fullscreen mode

This allows:

MyPromise.reject("Network error")
    .catch(error => {
        console.log(error);
    });
Enter fullscreen mode Exit fullscreen mode

to handle the rejection.


11. Promise Chaining

Promise chaining depends on .then() returning a new Promise.

Consider:

MyPromise.resolve(10)

    .then(value => {

        return value * 2;

    })

    .then(value => {

        return value + 5;

    })

    .then(value => {

        console.log(value);

    });
Enter fullscreen mode Exit fullscreen mode

The flow is:

10
 ↓
20
 ↓
25
Enter fullscreen mode Exit fullscreen mode

The important rule is:

The value returned from one .then() becomes the input to the next .then().

Internally:

const result = onFulfilled(parent.value);

resolve(result);
Enter fullscreen mode Exit fullscreen mode

The returned value resolves the child Promise.


12. Handling Returned Promises

Consider a more complicated example:

MyPromise.resolve(10)

    .then(value => {

        return new MyPromise(resolve => {

            setTimeout(() => {
                resolve(value * 2);
            }, 1000);

        });

    })

    .then(value => {

        console.log(value);

    });
Enter fullscreen mode Exit fullscreen mode

The first .then() returns another Promise.

Therefore, our custom Promise must understand when a resolved value is itself a Promise or Promise-like object.

This is called Promise resolution or thenable assimilation.


13. Thenables

A thenable is an object containing a .then() method.

Example:

const thenable = {

    then(resolve) {
        resolve("Hello");
    }

};
Enter fullscreen mode Exit fullscreen mode

Our Promise needs to recognize this:

if (
    value !== null &&
    (typeof value === "object" ||
     typeof value === "function")
) {
Enter fullscreen mode Exit fullscreen mode

Then we check:

const then = value.then;
Enter fullscreen mode Exit fullscreen mode

If it is a function, we call it and adopt its result.

This allows:

MyPromise.resolve(thenable)
    .then(value => {
        console.log(value);
    });
Enter fullscreen mode Exit fullscreen mode

to produce:

Hello
Enter fullscreen mode Exit fullscreen mode

14. Self-Resolution Protection

A Promise should not resolve with itself.

For example:

let promise;

promise = new MyPromise(resolve => {

    resolve(promise);

});
Enter fullscreen mode Exit fullscreen mode

This would create a circular situation.

Therefore, the implementation checks:

if (value === this) {

    rejectInternal(
        new TypeError("Promise cannot resolve itself")
    );

    return;
}
Enter fullscreen mode Exit fullscreen mode

This prevents infinite resolution.


15. Protecting Against Multiple Resolution

A thenable could theoretically call both callbacks:

const badThenable = {

    then(resolve, reject) {

        resolve("First");

        reject("Second");

        resolve("Third");

    }

};
Enter fullscreen mode Exit fullscreen mode

A Promise must settle only once.

So we use:

let called = false;
Enter fullscreen mode Exit fullscreen mode

Then:

if (called) {
    return;
}

called = true;
Enter fullscreen mode Exit fullscreen mode

The result becomes:

First → accepted
Second → ignored
Third → ignored
Enter fullscreen mode Exit fullscreen mode

This follows the Promise settlement rule.


16. Error Propagation

Errors inside .then() must reject the next Promise.

Example:

MyPromise.resolve(10)

    .then(() => {

        throw new Error("Something went wrong");

    })

    .then(() => {

        console.log("This will not execute");

    })

    .catch(error => {

        console.log(error.message);

    });
Enter fullscreen mode Exit fullscreen mode

Output:

Something went wrong
Enter fullscreen mode Exit fullscreen mode

Internally, .then() uses try...catch:

try {

    const result = onFulfilled(parent.value);

    resolve(result);

} catch (error) {

    reject(error);

}
Enter fullscreen mode Exit fullscreen mode

So the flow becomes:

throw Error
     ↓
catch(error)
     ↓
reject(error)
     ↓
next .then() skipped
     ↓
.catch()
Enter fullscreen mode Exit fullscreen mode

This is called error propagation.


17. Implementing .catch()

.catch() can be implemented using .then():

catch(onRejected) {

    return this.then(undefined, onRejected);

}
Enter fullscreen mode Exit fullscreen mode

This works because .then() already accepts two callbacks:

then(onFulfilled, onRejected)
Enter fullscreen mode Exit fullscreen mode

Therefore:

promise.catch(handler);
Enter fullscreen mode Exit fullscreen mode

is essentially:

promise.then(undefined, handler);
Enter fullscreen mode Exit fullscreen mode

18. Implementing .finally()

.finally() should run regardless of whether the Promise succeeds or fails.

The implementation:

finally(onFinally) {

    return this.then(

        (value) => {

            return MyPromise.resolve(onFinally())
                .then(() => value);

        },

        (reason) => {

            return MyPromise.resolve(onFinally())
                .then(() => {
                    throw reason;
                });

        }

    );
}
Enter fullscreen mode Exit fullscreen mode

The important behavior is:

Success
   ↓
finally()
   ↓
original value continues


Failure
   ↓
finally()
   ↓
original error continues
Enter fullscreen mode Exit fullscreen mode

For example:

MyPromise.resolve("Success")
    .finally(() => {
        console.log("Cleanup");
    })
    .then(value => {
        console.log(value);
    });
Enter fullscreen mode Exit fullscreen mode

Output:

Cleanup
Success
Enter fullscreen mode Exit fullscreen mode

19. Static resolve() and reject()

To make the custom class easier to use, I also implemented:

MyPromise.resolve()
MyPromise.reject()
Enter fullscreen mode Exit fullscreen mode

resolve()

static resolve(value) {

    if (value instanceof MyPromise) {
        return value;
    }

    return new MyPromise((resolve) => {
        resolve(value);
    });
}
Enter fullscreen mode Exit fullscreen mode

Usage:

MyPromise.resolve("Hello");
Enter fullscreen mode Exit fullscreen mode

reject()

static reject(reason) {

    return new MyPromise((resolve, reject) => {
        reject(reason);
    });
}
Enter fullscreen mode Exit fullscreen mode

Usage:

MyPromise.reject("Failed");
Enter fullscreen mode Exit fullscreen mode

20. Testing Against Native Promise

Implementing a custom Promise isn't enough. We need to verify its behavior.

I created a test suite that executes the same scenarios using:

MyPromise
Enter fullscreen mode Exit fullscreen mode

and:

Promise
Enter fullscreen mode Exit fullscreen mode

Then their results are compared.

For example:

MyPromise.resolve(10)
    .then(value => value * 2);
Enter fullscreen mode Exit fullscreen mode

is compared with:

Promise.resolve(10)
    .then(value => value * 2);
Enter fullscreen mode Exit fullscreen mode

Both should produce:

20
Enter fullscreen mode Exit fullscreen mode

21. Basic Resolve Test

compare(
    "Basic resolve",

    MyPromise.resolve("Hello"),

    Promise.resolve("Hello")
);
Enter fullscreen mode Exit fullscreen mode

Expected:

✅ Basic resolve
Enter fullscreen mode Exit fullscreen mode

22. Rejection Test

compare(
    "Basic rejection",

    MyPromise.reject("Error")
        .catch(error => error),

    Promise.reject("Error")
        .catch(error => error)
);
Enter fullscreen mode Exit fullscreen mode

Both should produce:

Error
Enter fullscreen mode Exit fullscreen mode

23. Chaining Test

compare(
    "then chaining",

    MyPromise.resolve(10)
        .then(value => value * 2)
        .then(value => value + 5),

    Promise.resolve(10)
        .then(value => value * 2)
        .then(value => value + 5)
);
Enter fullscreen mode Exit fullscreen mode

Expected:

25
Enter fullscreen mode Exit fullscreen mode

24. Error Propagation Test

compare(
    "Error propagation",

    MyPromise.resolve(10)
        .then(() => {
            throw new Error("Boom");
        })
        .catch(error => error.message),

    Promise.resolve(10)
        .then(() => {
            throw new Error("Boom");
        })
        .catch(error => error.message)
);
Enter fullscreen mode Exit fullscreen mode

Both should produce:

Boom
Enter fullscreen mode Exit fullscreen mode

25. Thenable Test

compare(
    "Thenable resolution",

    MyPromise.resolve({
        then(resolve) {
            resolve("Thenable");
        }
    }),

    Promise.resolve({
        then(resolve) {
            resolve("Thenable");
        }
    })
);
Enter fullscreen mode Exit fullscreen mode

Expected:

Thenable
Enter fullscreen mode Exit fullscreen mode

26. Async Behavior Test

Native Promise .then() callbacks execute asynchronously.

Therefore, the custom implementation should behave the same way.

const order = [];

MyPromise.resolve("Done")
    .then(() => {
        order.push("then");
    });

order.push("sync");
Enter fullscreen mode Exit fullscreen mode

The expected order is:

sync
then
Enter fullscreen mode Exit fullscreen mode

not:

then
sync
Enter fullscreen mode Exit fullscreen mode

This verifies that queueMicrotask() is being used correctly.


27. Final Test Results

After running:

node test.js
Enter fullscreen mode Exit fullscreen mode

the test suite should report results similar to:

✅ Basic resolve
✅ Basic rejection
✅ then chaining
✅ Error propagation
✅ finally
✅ Returned Promise
✅ Thenable resolution
Enter fullscreen mode Exit fullscreen mode

This shows that the custom implementation behaves similarly to native Promises for the scenarios covered by the test suite.


28. What I Learned From This Implementation

Implementing a Promise from scratch helped me understand that a Promise is more than just an object containing a value.

The implementation needs to manage:

Promise State
     ↓
Callbacks
     ↓
Resolution
     ↓
Chaining
     ↓
Error Propagation
     ↓
Asynchronous Execution
Enter fullscreen mode Exit fullscreen mode

The most important concepts I learned were:

Promise state management

pending → fulfilled
pending → rejected
Enter fullscreen mode Exit fullscreen mode

Resolution

resolve(value)
Enter fullscreen mode Exit fullscreen mode

doesn't simply assign a value. It may need to handle another Promise or thenable.

Chaining

.then()
   ↓
new Promise
   ↓
.then()
Enter fullscreen mode Exit fullscreen mode

Error propagation

throw
 ↓
reject
 ↓
.catch()
Enter fullscreen mode Exit fullscreen mode

Asynchronous execution

.then()
 ↓
Microtask Queue
 ↓
Callback execution
Enter fullscreen mode Exit fullscreen mode

Conclusion

Building a custom Promise from scratch is a useful exercise for understanding asynchronous JavaScript at a deeper level.

The final implementation supports the core Promise behavior required for this project:

              MyPromise
                  |
        ┌─────────┼─────────┐
        ↓         ↓         ↓
     Pending   Fulfilled  Rejected
        |         |          |
        |       .then()    .catch()
        |         |
        |      chaining
        |         |
        └─────────┼─────────┘
                  ↓
              .finally()
Enter fullscreen mode Exit fullscreen mode

The biggest takeaway is that Promise chaining works because each .then() creates and returns a new Promise, while the resolution procedure determines whether that new Promise should fulfill with a normal value or adopt the state of another Promise/thenable.

Comparing the implementation with native Promise also provides a practical way to verify whether the custom implementation behaves correctly.

This project transformed Promises from something I simply used into something I could understand and implement at a fundamental level.

Top comments (0)