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");
});
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
The responsibilities are:
my-promise.js
↓
Promise implementation
test.js
↓
Compare custom Promise with native Promise
3. Promise States
The first thing the implementation needs is Promise state management.
A Promise has three states:
Pending
/ \
/ \
Fulfilled Rejected
A Promise starts as:
this.state = "pending";
Then it can transition to:
pending → fulfilled
or:
pending → rejected
Once settled, the Promise cannot change its state again.
For example:
resolve("Success");
reject("Failed");
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;
}
}
We store three important pieces of information:
this.state
this.value
this.reason
For example, after:
resolve("Hello");
the Promise contains:
state = fulfilled
value = "Hello"
After:
reject("Error");
it contains:
state = rejected
reason = "Error"
5. Implementing resolve() and reject()
The executor function receives:
(resolve, reject)
So we implement these functions ourselves.
const resolve = (value) => {
fulfill(value);
};
const reject = (reason) => {
rejectInternal(reason);
};
The fulfillment function:
const fulfill = (value) => {
if (this.state !== "pending") {
return;
}
this.state = "fulfilled";
this.value = value;
};
The rejection function:
const rejectInternal = (reason) => {
if (this.state !== "pending") {
return;
}
this.state = "rejected";
this.reason = reason;
};
The check:
if (this.state !== "pending") {
return;
}
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);
});
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 = [];
When .then() is called while the Promise is pending:
Pending
onFulfilledCallbacks
↓
[callback]
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");
The expected output is:
A
C
B
Therefore, the implementation uses:
queueMicrotask(callback);
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;
}
The important part is:
return child;
Why?
Because every .then() creates a new Promise.
For example:
promise
.then(...)
.then(...)
.then(...);
Conceptually:
Promise A
↓
.then()
↓
Promise B
↓
.then()
↓
Promise C
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);
}
};
The resolved value is passed to the callback:
onFulfilled(parent.value)
For example:
MyPromise.resolve(10)
.then(value => {
return value * 2;
});
The callback receives:
value = 10
It returns:
20
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);
}
};
This allows:
MyPromise.reject("Network error")
.catch(error => {
console.log(error);
});
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);
});
The flow is:
10
↓
20
↓
25
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);
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);
});
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");
}
};
Our Promise needs to recognize this:
if (
value !== null &&
(typeof value === "object" ||
typeof value === "function")
) {
Then we check:
const then = value.then;
If it is a function, we call it and adopt its result.
This allows:
MyPromise.resolve(thenable)
.then(value => {
console.log(value);
});
to produce:
Hello
14. Self-Resolution Protection
A Promise should not resolve with itself.
For example:
let promise;
promise = new MyPromise(resolve => {
resolve(promise);
});
This would create a circular situation.
Therefore, the implementation checks:
if (value === this) {
rejectInternal(
new TypeError("Promise cannot resolve itself")
);
return;
}
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");
}
};
A Promise must settle only once.
So we use:
let called = false;
Then:
if (called) {
return;
}
called = true;
The result becomes:
First → accepted
Second → ignored
Third → ignored
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);
});
Output:
Something went wrong
Internally, .then() uses try...catch:
try {
const result = onFulfilled(parent.value);
resolve(result);
} catch (error) {
reject(error);
}
So the flow becomes:
throw Error
↓
catch(error)
↓
reject(error)
↓
next .then() skipped
↓
.catch()
This is called error propagation.
17. Implementing .catch()
.catch() can be implemented using .then():
catch(onRejected) {
return this.then(undefined, onRejected);
}
This works because .then() already accepts two callbacks:
then(onFulfilled, onRejected)
Therefore:
promise.catch(handler);
is essentially:
promise.then(undefined, handler);
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;
});
}
);
}
The important behavior is:
Success
↓
finally()
↓
original value continues
Failure
↓
finally()
↓
original error continues
For example:
MyPromise.resolve("Success")
.finally(() => {
console.log("Cleanup");
})
.then(value => {
console.log(value);
});
Output:
Cleanup
Success
19. Static resolve() and reject()
To make the custom class easier to use, I also implemented:
MyPromise.resolve()
MyPromise.reject()
resolve()
static resolve(value) {
if (value instanceof MyPromise) {
return value;
}
return new MyPromise((resolve) => {
resolve(value);
});
}
Usage:
MyPromise.resolve("Hello");
reject()
static reject(reason) {
return new MyPromise((resolve, reject) => {
reject(reason);
});
}
Usage:
MyPromise.reject("Failed");
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
and:
Promise
Then their results are compared.
For example:
MyPromise.resolve(10)
.then(value => value * 2);
is compared with:
Promise.resolve(10)
.then(value => value * 2);
Both should produce:
20
21. Basic Resolve Test
compare(
"Basic resolve",
MyPromise.resolve("Hello"),
Promise.resolve("Hello")
);
Expected:
✅ Basic resolve
22. Rejection Test
compare(
"Basic rejection",
MyPromise.reject("Error")
.catch(error => error),
Promise.reject("Error")
.catch(error => error)
);
Both should produce:
Error
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)
);
Expected:
25
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)
);
Both should produce:
Boom
25. Thenable Test
compare(
"Thenable resolution",
MyPromise.resolve({
then(resolve) {
resolve("Thenable");
}
}),
Promise.resolve({
then(resolve) {
resolve("Thenable");
}
})
);
Expected:
Thenable
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");
The expected order is:
sync
then
not:
then
sync
This verifies that queueMicrotask() is being used correctly.
27. Final Test Results
After running:
node test.js
the test suite should report results similar to:
✅ Basic resolve
✅ Basic rejection
✅ then chaining
✅ Error propagation
✅ finally
✅ Returned Promise
✅ Thenable resolution
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
The most important concepts I learned were:
Promise state management
pending → fulfilled
pending → rejected
Resolution
resolve(value)
doesn't simply assign a value. It may need to handle another Promise or thenable.
Chaining
.then()
↓
new Promise
↓
.then()
Error propagation
throw
↓
reject
↓
.catch()
Asynchronous execution
.then()
↓
Microtask Queue
↓
Callback execution
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()
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)