Understanding Promises by Building Them from Scratch
According to the
MDN Web Docs, a Promise represents
"the eventual completion (or failure) of an asynchronous operation and its resulting value."
Promises are fundamental to modern JavaScript development, yet many developers use them without understanding their internal mechanics. This tutorial will demystify Promises by guiding you through building a complete implementation from scratch. By the end, you'll have a deep understanding of how Promises work, which will help you write better asynchronous code and debug complex Promise-related issues.
Understanding Native Promise Behavior
Before we start building, let's examine how the built-in Promise works with a comprehensive example that demonstrates its key characteristics:
console.log("1. Script Start (Sync)");
const p = new Promise((resolve, reject) => {
console.log("2. Executor function runs immediately (Sync)");
resolve("Data A");
reject("Error B");
resolve("Data C");
setTimeout(() => {
console.log("5. setTimeout (Macrotask) runs");
}, 0);
});
p.then((value) => {
console.log("4. 1st .then() handler (Microtask) runs. Value:", value);
throw new Error("Chaining Error!");
})
.catch((error) => {
console.log(
"4. 2nd .catch() handler (Microtask) runs. Error:",
error.message
);
return 500;
})
.then((recoveredValue) => {
console.log(
"4. 3rd .then() handler (Microtask) runs after recovery. Value:",
recoveredValue
);
});
console.log("3. Script End (Sync)");
Output:
1. Script Start (Sync)
2. Executor function runs immediately (Sync)
3. Script End (Sync)
4. 1st .then() handler (Microtask) runs. Value: Data A
4. 2nd .catch() handler (Microtask) runs. Error: Chaining Error!
4. 3rd .then() handler (Microtask) runs after recovery. Value: 500
5. setTimeout (Macrotask) runs
This example demonstrates several key Promise characteristics:
- Immediate execution: The executor function runs synchronously when the Promise is created
- Immutability: Only the first
resolve() or reject() call takes effect; subsequent calls are ignored
- Microtask queue: Promise callbacks execute as microtasks, running before macrotasks like
setTimeout
- Error propagation: Errors thrown in
.then() handlers automatically propagate to the next .catch() handler
- Recovery: A
.catch() handler can return a value, which fulfills the chain and allows subsequent .then() handlers to execute
- Chaining: Each
.then() and .catch() returns a new Promise, enabling sequential asynchronous operations
Now, let's build our own MyPromise implementation that replicates this behavior step by step!
Step 1: Basic Constructor
Right now, we don't have any foundation for our Promise implementation. We need something that can run the executor function immediately when the Promise is created, just like how native Promises work.
We're starting by creating a simple class with a constructor that takes an executor function and runs it right away. This gives us the basic structure we can build upon.
The reason we're doing this first is because every Promise journey begins with the executor function being called synchronously. Without this immediate execution, we wouldn't be able to capture the asynchronous operation that the Promise represents.
class MyPromise {
constructor(fn) {
fn();
}
}
const p1 = new MyPromise(() => {
console.log(123);
});
Output:
Step 2: Adding State Management
The issue right now is that our constructor runs the executor function, but it doesn't give us any way to capture what happens inside that function. We need a mechanism to track whether the asynchronous operation succeeded or failed, and what the result was.
What we're looking for is a way to store the Promise's current state and any value it produces, so we can access this information later when we need to handle the results.
We're adding state tracking to our Promise class - a simple state property that starts as "pending" and can change to "resolved" or "rejected", along with a value property to store the result. We're also creating resolve and reject functions that the executor can call to update this state.
This is important because without knowing the state and value, our Promise would be like a black box - we could start asynchronous operations but never know when they finish or what the outcome was.
class MyPromise {
constructor(fn) {
this.state = "pending";
this.value = undefined;
const resolve = (value) => {
this.state = "resolved";
this.value = value;
};
const reject = (reason) => {
this.state = "rejected";
this.value = reason;
};
fn(resolve, reject);
}
}
const p1 = new MyPromise((resolve) => {
resolve(123);
});
console.log(p1);
Output:
{
"state": "resolved",
"value": 123
}
Step 3: Error Handling
The problem we're facing now is that if something goes wrong inside the executor function and it throws a synchronous error, our Promise just crashes instead of handling it gracefully. We need to make sure that any errors, whether they happen synchronously or asynchronously, are properly captured and treated as rejections.
What we want is for our Promise to automatically reject when the executor throws an error, just like the native Promise does. This creates a consistent experience where all errors - synchronous or asynchronous - are handled through the same Promise rejection mechanism.
We're adding a try-catch block around the executor function call, so that if any synchronous error occurs, we immediately reject the Promise with that error.
This matters because it ensures that developers using our Promise don't have to worry about different types of errors being handled differently. Whether an error happens right away or later, it all flows through the same .catch() mechanism.
class MyPromise {
constructor(fn) {
this.state = "pending";
this.value = undefined;
const resolve = (value) => {
this.state = "resolved";
this.value = value;
};
const reject = (reason) => {
this.state = "rejected";
this.value = reason;
};
try {
fn(resolve, reject);
} catch (error) {
reject(error);
}
}
}
const p1 = new MyPromise((resolve, reject) => {
throw Error("Some error");
resolve(123);
});
console.log(p1);
Output:
{
"state": "rejected",
"value": "[object Error] { ... }"
}
Step 4: Implementing the then() Method
At this point, we have Promises that can track their state and handle errors, but there's no way for anyone using our Promise to actually do something with the result. We need a mechanism to register callbacks that will run when the Promise resolves or rejects.
What we need is the ability to attach success and failure handlers that will be called at the appropriate time, giving users a way to react to the Promise's outcome.
We're implementing the `then()` method that accepts two optional callback functions - one for when the Promise resolves successfully, and one for when it rejects. For now, we're keeping it simple by only handling the case where the Promise is already settled when then() is called.
This is crucial because without then(), our Promise would be completely useless - we'd have no way to access the asynchronous results or handle errors that occur.
class MyPromise {
constructor(fn) {
this.state = "pending";
this.value = undefined;
const resolve = (value) => {
this.state = "resolved";
this.value = value;
};
const reject = (reason) => {
this.state = "rejected";
this.value = reason;
};
try {
fn(resolve, reject);
} catch (error) {
reject(error);
}
}
then = (onSuccess, onFailure) => {
if (this.state === "resolved") {
onSuccess(this.value);
} else if (this.state === "rejected") {
onFailure(this.value);
}
};
}
const p1 = new MyPromise((resolve, reject) => {
resolve(123);
});
p1.then(
(value) => {
console.log(value);
},
(error) => {
console.log(error);
}
);
console.log(p1);
const p2 = new MyPromise((resolve, reject) => {
throw Error("Some Error");
});
p2.then(
(value) => {
console.log(value);
},
(error) => {
console.log(error);
}
);
console.log(p2);
Output:
{
"state": "resolved",
"then": "[Function]",
"value": 123
}
{
"state": "rejected",
"then": "[Function]",
"value": "[object Error] { ... }"
}
Step 5: Implementing the catch() Method
While we now have the then() method for handling both success and failure cases, it's not very convenient when you only care about errors. You have to pass undefined as the first argument and your error handler as the second, which feels clunky and unclear.
What we really want is a cleaner, more intuitive way to handle just the rejection case, making error handling code more readable and explicit.
We're adding a `catch()` method that's simply a shorthand for calling then() with only the rejection handler. It's not adding new functionality, just making the API more developer-friendly.
This matters for code readability - when you see .catch(), it's immediately clear that this is handling errors, whereas .then(undefined, errorHandler) is less obvious and more verbose.
class MyPromise {
constructor(fn) {
this.state = "pending";
this.value = undefined;
const resolve = (value) => {
this.state = "resolved";
this.value = value;
};
const reject = (reason) => {
this.state = "rejected";
this.value = reason;
};
try {
fn(resolve, reject);
} catch (error) {
reject(error);
}
}
then = (onSuccess, onFailure) => {
if (this.state === "resolved") {
onSuccess(this.value);
} else if (this.state === "rejected") {
onFailure(this.value);
}
};
catch = (onFailure) => {
this.then(undefined, onFailure);
};
}
const p1 = new MyPromise((resolve, reject) => {
throw Error("Some Error");
});
p1.catch((error) => {
console.log(error);
});
console.log(p1);
Output:
{
"catch": "[Function]",
"state": "rejected",
"then": "[Function]",
"value": "[object Error] { ... }"
}
Step 6: One-Time Settlement Guarantee
The current implementation has a serious problem - our Promise can be resolved or rejected multiple times. If the executor calls resolve() and then reject(), or vice versa, both calls would change the state, which could lead to unpredictable behavior and confusion about what the "real" result is.
What we need is a guarantee that once a Promise is settled (either resolved or rejected), it stays that way forever. This immutability ensures that the outcome is definitive and trustworthy.
We're adding checks in both the `resolve()` and `reject()` functions to only change the state if the Promise is still pending. Any subsequent calls are simply ignored.
This is essential for reliable asynchronous programming - you need to be able to trust that when a Promise settles, that's the final answer and it won't change unexpectedly.
class MyPromise {
constructor(fn) {
this.state = "pending";
this.value = undefined;
const resolve = (value) => {
if (this.state !== "pending") return;
this.state = "resolved";
this.value = value;
};
const reject = (reason) => {
if (this.state !== "pending") return;
this.state = "rejected";
this.value = reason;
};
try {
fn(resolve, reject);
} catch (error) {
reject(error);
}
}
then = (onSuccess, onFailure) => {
if (this.state === "resolved" && onSuccess) {
onSuccess(this.value);
} else if (this.state === "rejected" && onFailure) {
onFailure(this.value);
}
};
catch = (onFailure) => {
this.then(undefined, onFailure);
};
}
const p1 = new MyPromise((resolve, reject) => {
resolve(123);
resolve(456);
reject("Some error");
});
p1.then(
(value) => {
console.log(value);
},
(error) => {
console.log(error);
}
);
Output:
Step 7: Supporting Asynchronous Resolution
We're running into a fundamental limitation with our current approach. If someone calls `then()` on a Promise that's still pending (like when waiting for a network request or timer), our current implementation does nothing because it only handles already-settled Promises. The callback gets registered but never executes when the Promise finally resolves.
What we need is a way to remember the callbacks that are registered before the Promise settles, so we can execute them later when the resolution actually happens.
We're introducing callback queues - arrays that store the success and failure callbacks. When then() is called on a pending Promise, we add the callbacks to these queues. When the Promise finally settles, we execute all the queued callbacks.
This is absolutely essential because most real-world Promises are asynchronous - they represent operations like API calls, file reads, or database queries that take time to complete. Without this, our Promise implementation would only work for synchronous operations.
class MyPromise {
constructor(fn) {
this.state = "pending";
this.value = undefined;
this.onResolvedCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (this.state !== "pending") return;
this.state = "resolved";
this.value = value;
this.onResolvedCallbacks.forEach((callback) => {
callback(value);
});
};
const reject = (reason) => {
if (this.state !== "pending") return;
this.state = "rejected";
this.value = reason;
this.onRejectedCallbacks.forEach((callback) => {
callback(reason);
});
};
try {
fn(resolve, reject);
} catch (error) {
reject(error);
}
}
then = (onSuccess, onFailure) => {
if (this.state === "resolved") {
onSuccess(this.value);
} else if (this.state === "rejected") {
onFailure(this.value);
} else {
if (onSuccess) this.onResolvedCallbacks.push(onSuccess);
if (onFailure) this.onRejectedCallbacks.push(onFailure);
}
};
catch = (onFailure) => {
this.then(undefined, onFailure);
};
}
const p1 = new MyPromise((resolve, reject) => {
setTimeout(() => {
resolve(123);
}, 2000);
});
p1.then(
(value) => {
console.log(value);
},
(error) => {
console.log(error);
}
);
Output:
Step 8: Promise Chaining
Our Promise implementation can handle asynchronous operations and execute callbacks, but it can't be chained together. If you try to call .then().then(), you'll get an error because our current then() method doesn't return anything.
What we want is the ability to sequence asynchronous operations - to take the result of one Promise and use it as input for another, creating a clean, readable flow of dependent operations.
We're completely rewriting the `then()` method to return a new Promise. This new Promise will resolve with whatever value the success callback returns, or reject if the callback throws an error. We're also updating catch() to maintain the chaining capability.
This chaining ability is what makes Promises so powerful - it allows you to transform complex nested callback patterns into elegant, linear sequences of asynchronous operations that are much easier to read and maintain.
class MyPromise {
constructor(fn) {
this.state = "pending";
this.value = undefined;
this.onResolvedCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (this.state !== "pending") return;
this.state = "resolved";
this.value = value;
this.onResolvedCallbacks.forEach((callback) => {
callback(value);
});
};
const reject = (reason) => {
if (this.state !== "pending") return;
this.state = "rejected";
this.value = reason;
this.onRejectedCallbacks.forEach((callback) => {
callback(reason);
});
};
try {
fn(resolve, reject);
} catch (error) {
reject(error);
}
}
then = (onSuccess, onFailure) => {
return new MyPromise((resolve, reject) => {
if (this.state === "resolved") {
try {
if (!onSuccess) resolve(this.value);
else {
const result = onSuccess(this.value);
resolve(result);
}
} catch (error) {
reject(error);
}
} else if (this.state === "rejected") {
try {
if (!onFailure) reject(this.value);
else {
const result = onFailure(this.value);
resolve(result);
}
} catch (error) {
reject(error);
}
} else {
this.onResolvedCallbacks.push((value) => {
try {
if (!onSuccess) resolve(value);
else {
const result = onSuccess(value);
resolve(result);
}
} catch (error) {
reject(error);
}
});
this.onRejectedCallbacks.push((reason) => {
try {
if (!onFailure) reject(reason);
else {
const result = onFailure(reason);
resolve(result);
}
} catch (error) {
reject(error);
}
});
}
});
};
catch = (onFailure) => {
return this.then(undefined, onFailure);
};
}
const p1 = new MyPromise((resolve, reject) => {
setTimeout(() => {
resolve(123);
}, 2000);
});
p1.then((value) => {
console.log("Value at 1st then", value);
return value + 2;
})
.then((value) => {
console.log("Value at 2nd then", value);
return value + 2;
})
.catch((error) => {
console.log("Caught error:", error);
});
Output:
"Value at 1st then", 123
"Value at 2nd then", 125
Step 9: Microtask Queue Integration
Even though our Promise is functionally complete, there's a subtle but critical timing issue. Our callbacks are executing synchronously when the Promise settles, but native Promises defer their callbacks to run as microtasks. This means our implementation might execute callbacks at the wrong time relative to other asynchronous operations.
What we need is for our Promise callbacks to follow the proper event loop ordering - running after the current synchronous code but before macrotasks like setTimeout.
We're wrapping all callback execution in `queueMicrotask()` calls. This ensures that even when a Promise resolves synchronously, its callbacks still run as microtasks in the next tick of the event loop.
This timing precision is important for maintaining consistency with how the JavaScript runtime schedules different types of asynchronous work. Without it, our Promises might behave slightly differently from native ones in complex scenarios involving multiple async operations.
class MyPromise {
constructor(fn) {
this.state = "pending";
this.value = undefined;
this.onResolvedCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (this.state !== "pending") return;
queueMicrotask(() => {
this.state = "resolved";
this.value = value;
this.onResolvedCallbacks.forEach((callback) => {
callback(value);
});
});
};
const reject = (reason) => {
if (this.state !== "pending") return;
queueMicrotask(() => {
this.state = "rejected";
this.value = reason;
this.onRejectedCallbacks.forEach((callback) => {
callback(reason);
});
});
};
try {
fn(resolve, reject);
} catch (error) {
reject(error);
}
}
then = (onSuccess, onFailure) => {
return new MyPromise((resolve, reject) => {
if (this.state === "resolved") {
queueMicrotask(() => {
try {
if (!onSuccess) resolve(this.value);
else {
const result = onSuccess(this.value);
resolve(result);
}
} catch (error) {
reject(error);
}
});
} else if (this.state === "rejected") {
queueMicrotask(() => {
try {
if (!onFailure) reject(this.value);
else {
const result = onFailure(this.value);
resolve(result);
}
} catch (error) {
reject(error);
}
});
} else {
this.onResolvedCallbacks.push((value) => {
queueMicrotask(() => {
try {
if (!onSuccess) resolve(value);
else {
const result = onSuccess(value);
resolve(result);
}
} catch (error) {
reject(error);
}
});
});
this.onRejectedCallbacks.push((reason) => {
queueMicrotask(() => {
try {
if (!onFailure) reject(reason);
else {
const result = onFailure(reason);
resolve(result);
}
} catch (error) {
reject(error);
}
});
});
}
});
};
catch = (onFailure) => {
return this.then(undefined, onFailure);
};
}
console.log("Script start (Sync)");
setTimeout(() => {
console.log("setTimeout (Macrotask) runs");
}, 0);
Promise.resolve().then(() => {
console.log("Real Promise .then() (Microtask) runs");
});
const p = new MyPromise((resolve) => {
console.log("MyPromise constructor calls resolve (Sync)");
resolve("Check Microtask");
});
p.then(() => {
console.log("MyPromise .then() (Microtask) runs");
});
console.log("Script end (Sync)");
Output:
"Script start (Sync)"
"MyPromise constructor calls resolve (Sync)"
"Script end (Sync)"
"Real Promise .then() (Microtask) runs"
"MyPromise .then() (Microtask) runs"
"setTimeout (Macrotask) runs"
Perfect! Our MyPromise implementation produces the exact same output and behavior as the native Promise.
Conclusion
Through this 9-step journey, we've built a fully functional Promise implementation that demonstrates:
- Immediate executor execution - The executor function runs synchronously when the Promise is created
- State management - Tracking the Promise's state (pending → resolved/rejected) and value
- Error handling - Automatically converting synchronous errors into rejections
- Callback registration - The
then() method for handling fulfillment and rejection
- Error handling API - The
catch() method as syntactic sugar for error handling
- Immutability - Ensuring a Promise can only be settled once
- Asynchronous support - Storing and executing callbacks when the Promise settles
- Promise chaining - Returning new Promises from
then() to enable sequential operations
- Microtask integration - Using the microtask queue for proper execution order
Key Takeaways
By building Promises from scratch, you've gained a deep understanding of:
- How Promises manage state and handle asynchronous operations
- The importance of the microtask queue in JavaScript's event loop
- How Promise chaining works under the hood
- Why immutability is crucial for predictable asynchronous behavior
This knowledge will help you write better asynchronous JavaScript code, debug complex Promise-related issues, and understand how modern async/await syntax works (since it's built on Promises).
Next Steps
While our implementation covers the core Promise functionality, the native Promise API includes additional methods:
Promise.all() - Wait for all Promises to resolve
Promise.race() - Resolve with the first settled Promise
Promise.allSettled() - Wait for all Promises to settle (resolve or reject)
Promise.any() - Resolve with the first fulfilled Promise
For the complete specification, refer to the
MDN Promise documentation.
Happy coding!