Aman Chandel
AboutBlog
Aman Chandel
Back

Building Your Own JavaScript Promises

Learn how JavaScript Promises work under the hood by building a complete Promise implementation from scratch. This step-by-step tutorial covers state management, asynchronous resolution, chaining, microtask queues, and more.

November 9, 2025
3,463 words

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:
javascript
console.log("1. Script Start (Sync)"); const p = new Promise((resolve, reject) => { console.log("2. Executor function runs immediately (Sync)"); // a) Immutability Check: Only 'resolve("Data A")' will take effect. resolve("Data A"); reject("Error B"); resolve("Data C"); // b) Macrotask Queue: This is lowest priority. 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!"); // c) Error Propagation }) .catch((error) => { console.log( "4. 2nd .catch() handler (Microtask) runs. Error:", error.message ); return 500; // d) Recovery: returns a value, fulfilling the chain }) .then((recoveredValue) => { console.log( "4. 3rd .then() handler (Microtask) runs after recovery. Value:", recoveredValue ); }); console.log("3. Script End (Sync)");
Output:
text
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.
javascript
class MyPromise { constructor(fn) { fn(); } } const p1 = new MyPromise(() => { console.log(123); });
Output:
text
123

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.
javascript
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:
json
{ "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.
javascript
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:
json
{ "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.
javascript
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); } }; } // Test with resolution const p1 = new MyPromise((resolve, reject) => { resolve(123); }); p1.then( (value) => { console.log(value); }, (error) => { console.log(error); } ); console.log(p1); // Test with rejection const p2 = new MyPromise((resolve, reject) => { throw Error("Some Error"); }); p2.then( (value) => { console.log(value); }, (error) => { console.log(error); } ); console.log(p2);
Output:
text
123
json
{ "state": "resolved", "then": "[Function]", "value": 123 }
text
[object Error] { ... }
json
{ "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.
javascript
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:
text
[object Error] { ... }
json
{ "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.
javascript
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:
text
123

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.
javascript
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:
text
123 // after 2 seconds

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.
javascript
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:
text
"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.
javascript
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:
text
"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:
  1. Immediate executor execution - The executor function runs synchronously when the Promise is created
  2. State management - Tracking the Promise's state (pending → resolved/rejected) and value
  3. Error handling - Automatically converting synchronous errors into rejections
  4. Callback registration - The then() method for handling fulfillment and rejection
  5. Error handling API - The catch() method as syntactic sugar for error handling
  6. Immutability - Ensuring a Promise can only be settled once
  7. Asynchronous support - Storing and executing callbacks when the Promise settles
  8. Promise chaining - Returning new Promises from then() to enable sequential operations
  9. 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!