Building your own JavaScript Promise implementation step by step — to truly understand how they work under the hood.

Introduction
Promises are everywhere in modern JavaScript: fetching data, file I/O, timers, React’s async boundaries, Node APIs, and more. But to many developers, they feel like magic wrappers around async code.
The reality? A Promise is “just” an object managing state (pending → fulfilled/rejected) and a list of callbacks to run later. No sorcery.
In this post, we’ll implement a working Promise from scratch, following the Promises/A+ spec, so you’ll know exactly how .then
, .catch
, and chaining really work. Along the way, we’ll contrast with the built-in Promise and show real-world scenarios where this knowledge saves debugging hours.
1. What Is a Promise? (Plain English)
- A placeholder for a value that might not exist yet.
- It has three possible states:
- pending → waiting.
- fulfilled → resolved with a value.
- rejected → failed with a reason.
Once settled (fulfilled or rejected), the state is final.
👉 Key features:
- Chaining:
.then
returns another Promise. - Error bubbling:
.catch
handles rejections anywhere in the chain. - Async execution: callbacks run later, not immediately.
2. The Smallest Fake Promise (Concept)
We’ll start ultra-simple:
class FakePromise {
constructor(executor) {
this.state = "pending";
this.value = undefined;
this.callbacks = [];
const resolve = (value) => {
if (this.state !== "pending") return;
this.state = "fulfilled";
this.value = value;
this.callbacks.forEach(cb => cb(value));
};
executor(resolve);
}
then(onFulfilled) {
if (this.state === "fulfilled") {
onFulfilled(this.value);
} else {
this.callbacks.push(onFulfilled);
}
}
}
Usage:
const p = new FakePromise((res) => {
setTimeout(() => res("Hello"), 1000);
});
p.then(val => console.log(val)); // "Hello"
It works! But we’re missing rejection, chaining, async semantics, error handling.
3. Adding Rejection
class MyPromise {
constructor(executor) {
this.state = "pending";
this.value = undefined;
this.reason = undefined;
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (this.state !== "pending") return;
this.state = "fulfilled";
this.value = value;
this.onFulfilledCallbacks.forEach(fn => fn(value));
};
const reject = (reason) => {
if (this.state !== "pending") return;
this.state = "rejected";
this.reason = reason;
this.onRejectedCallbacks.forEach(fn => fn(reason));
};
try {
executor(resolve, reject);
} catch (err) {
reject(err);
}
}
then(onFulfilled, onRejected) {
if (this.state === "fulfilled" && onFulfilled) {
onFulfilled(this.value);
} else if (this.state === "rejected" && onRejected) {
onRejected(this.reason);
} else {
if (onFulfilled) this.onFulfilledCallbacks.push(onFulfilled);
if (onRejected) this.onRejectedCallbacks.push(onRejected);
}
}
}
Usage:
const p = new MyPromise((res, rej) => {
setTimeout(() => rej("Oops!"), 1000);
});
p.then(null, (err) => console.error(err));
4. Making .then
Chainable
Per spec, .then
must return a new Promise:
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
const fulfilledTask = (value) => {
try {
const x = onFulfilled ? onFulfilled(value) : value;
resolve(x);
} catch (err) {
reject(err);
}
};
const rejectedTask = (reason) => {
try {
const x = onRejected ? onRejected(reason) : reason;
reject(x);
} catch (err) {
reject(err);
}
};
if (this.state === "fulfilled") {
fulfilledTask(this.value);
} else if (this.state === "rejected") {
rejectedTask(this.reason);
} else {
this.onFulfilledCallbacks.push(fulfilledTask);
this.onRejectedCallbacks.push(rejectedTask);
}
});
}
Now you can chain:
new MyPromise((res) => res(1))
.then((v) => v + 1)
.then((v) => { throw new Error("fail"); })
.then(null, (err) => console.error("Caught:", err.message));
5. Asynchronous Resolution (Spec Requirement)
The spec requires .then
callbacks to run after the current call stack, not immediately. We mimic with queueMicrotask
(or setTimeout
fallback):
const asyncRun = (fn) =>
typeof queueMicrotask === "function" ? queueMicrotask(fn) : setTimeout(fn, 0);
Use inside .then
:
if (this.state === "fulfilled") {
asyncRun(() => fulfilledTask(this.value));
} else if (this.state === "rejected") {
asyncRun(() => rejectedTask(this.reason));
} else {
this.onFulfilledCallbacks.push((val) => asyncRun(() => fulfilledTask(val)));
this.onRejectedCallbacks.push((err) => asyncRun(() => rejectedTask(err)));
}
6. Resolving Thenables (Core of Promises/A+)
If a .then
handler returns another Promise (or thenable), we must adopt its state.
function resolvePromise(resolve, reject, x) {
if (x instanceof MyPromise) {
x.then(resolve, reject);
} else if (x && typeof x === "object" && typeof x.then === "function") {
try {
x.then(resolve, reject);
} catch (e) {
reject(e);
}
} else {
resolve(x);
}
}
Update .then
:
const fulfilledTask = (value) => {
try {
const x = onFulfilled ? onFulfilled(value) : value;
resolvePromise(resolve, reject, x);
} catch (err) {
reject(err);
}
};
7. Adding .catch
and .finally
catch(onRejected) {
return this.then(null, onRejected);
}
finally(cb) {
return this.then(
(value) => MyPromise.resolve(cb()).then(() => value),
(reason) => MyPromise.resolve(cb()).then(() => { throw reason; })
);
}
static resolve(value) {
return new MyPromise((res) => res(value));
}
static reject(reason) {
return new MyPromise((_, rej) => rej(reason));
}
8. Polyfill-Level MyPromise (Final Form)
class MyPromise {
constructor(executor) {
this.state = "pending";
this.value = undefined;
this.reason = undefined;
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (this.state !== "pending") return;
this.state = "fulfilled";
this.value = value;
this.onFulfilledCallbacks.forEach(fn => fn(value));
};
const reject = (reason) => {
if (this.state !== "pending") return;
this.state = "rejected";
this.reason = reason;
this.onRejectedCallbacks.forEach(fn => fn(reason));
};
try {
executor(resolve, reject);
} catch (e) {
reject(e);
}
}
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
const fulfilledTask = (value) => {
try {
const x = onFulfilled ? onFulfilled(value) : value;
resolvePromise(resolve, reject, x);
} catch (err) {
reject(err);
}
};
const rejectedTask = (reason) => {
try {
if (onRejected) {
const x = onRejected(reason);
resolvePromise(resolve, reject, x);
} else {
reject(reason);
}
} catch (err) {
reject(err);
}
};
if (this.state === "fulfilled") {
queueMicrotask(() => fulfilledTask(this.value));
} else if (this.state === "rejected") {
queueMicrotask(() => rejectedTask(this.reason));
} else {
this.onFulfilledCallbacks.push((val) => queueMicrotask(() => fulfilledTask(val)));
this.onRejectedCallbacks.push((err) => queueMicrotask(() => rejectedTask(err)));
}
});
}
catch(onRejected) {
return this.then(null, onRejected);
}
finally(cb) {
return this.then(
(value) => MyPromise.resolve(cb()).then(() => value),
(reason) => MyPromise.resolve(cb()).then(() => { throw reason; })
);
}
static resolve(val) {
return new MyPromise((res) => res(val));
}
static reject(err) {
return new MyPromise((_, rej) => rej(err));
}
}
function resolvePromise(resolve, reject, x) {
if (x === null || (typeof x !== "object" && typeof x !== "function")) {
return resolve(x);
}
let then;
try {
then = x.then;
} catch (e) {
return reject(e);
}
if (typeof then === "function") {
try {
then.call(x, (y) => resolvePromise(resolve, reject, y), reject);
} catch (e) {
reject(e);
}
} else {
resolve(x);
}
}
9. Real-World Examples
Example A: Timeout Utility
function delay(ms) {
return new MyPromise((res) => setTimeout(res, ms));
}
delay(1000).then(() => console.log("Waited 1s"));
Example B: Sequential Async Flow
delay(500)
.then(() => "Step 1 done")
.then((msg) => {
console.log(msg);
return delay(500).then(() => "Step 2 done");
})
.then(console.log);
Example C: Error Handling
new MyPromise((_, rej) => rej("Fail"))
.catch((err) => console.error("Caught:", err));
10. Key Takeaways
- A Promise is just:
- State (
pending → fulfilled/rejected
) - Value or reason
- Callback queues
.then
always returns a new Promise.
2. Microtasks guarantee async execution of handlers.
3. Thenables interop makes Promises universal.
Once you build it yourself, Promises stop being magic — they’re just disciplined state machines with a nice API.
Conclusion
Understanding Promises deeply makes you a better async developer:
- You’ll debug chain/order issues faster.
- You’ll know when to reach for
Promise.all
,allSettled
,race
,any
. - You’ll grasp why
async/await
is “just syntax sugar” over Promises.
Pro tip: Whenever async code behaves strangely, imagine the Promise state machine: pending → fulfilled/rejected → handlers. That mental model reveals the bug 9 times out of 10.
Call to Action
Have you ever had a “Promise bug” that took hours to figure out (like double resolution or missed errors)?
💬 Share the story in the comments.
🔖 Bookmark this for your next interview or deep dive.
👩💻 Share it with a teammate who still thinks Promises are magic.
Leave a Reply