, ,

Promises from Scratch: Zero Magic

Posted by

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

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

  1. 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

Your email address will not be published. Required fields are marked *