Implement Promise from Scratch
Why Build a Promise?
You already use promises every day. But in a FAANG interview, you're asked to build one. Not because anyone ships a custom Promise to production, but because building one reveals whether you understand the machinery underneath async/await, microtask scheduling, and error propagation. It separates people who memorize APIs from people who understand systems.
We'll build it piece by piece, and by the end you'll have a working Promises/A+ implementation. Every decision along the way maps to a specific requirement in the spec.
A Promise is a state machine with a mailbox. While the machine is in the "pending" state, incoming mail (.then callbacks) gets queued. The moment the machine transitions to "fulfilled" or "rejected," it delivers all queued mail and locks itself forever. Any mail that arrives after the lock is delivered immediately (via microtask). That's the entire model. Everything else is implementation detail.
Step 1: The State Machine
Every Promise has three possible states: pending, fulfilled, and rejected. The transition is one-way and irreversible.
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';
class MyPromise {
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.onFulfilled(value));
};
const reject = (reason) => {
if (this.state !== PENDING) return;
this.state = REJECTED;
this.value = reason;
this.callbacks.forEach(cb => cb.onRejected(reason));
};
try {
executor(resolve, reject);
} catch (err) {
reject(err);
}
}
}
A few things to notice:
- The
if (this.state !== PENDING) returnguard is critical. Once settled, callingresolveorrejectagain is a no-op. This is how the spec enforces immutability. - The
executorruns synchronously inside the constructor. This surprises people.new Promise(resolve => resolve(42))resolves the promise during construction. - The
try/catcharound the executor means throwing insidenew Promise(() => { throw new Error('boom') })produces a rejected promise, not an uncaught exception. - The
callbacksarray is the "mailbox" from our mental model. It stores handlers registered by.then()while the promise is still pending.
Step 2: The then() Method with Callback Queuing
.then() is where promises get interesting. It needs to handle two cases: the promise is still pending (queue the callbacks), or it's already settled (schedule them immediately).
class MyPromise {
// ...constructor from Step 1
then(onFulfilled, onRejected) {
if (this.state === FULFILLED) {
onFulfilled(this.value);
}
if (this.state === REJECTED) {
onRejected(this.value);
}
if (this.state === PENDING) {
this.callbacks.push({
onFulfilled: (value) => onFulfilled(value),
onRejected: (reason) => onRejected(reason),
});
}
}
}
This basic version works for simple cases, but it has three major problems:
- Callbacks run synchronously. The spec requires them to run asynchronously.
- No chaining.
.then()doesn't return a new promise. - No default handlers. If you call
.then(undefined, onRejected), it crashes when trying to callundefined.
We'll fix all three. Let's start with async scheduling.
Step 3: Microtask Scheduling
The Promises/A+ spec (section 2.2.4) says: onFulfilled or onRejected must not be called until the execution context stack contains only platform code. Translation: callbacks must be asynchronous, even if the promise is already settled.
Native promises use the microtask queue for this. We use queueMicrotask:
const resolve = (value) => {
if (this.state !== PENDING) return;
this.state = FULFILLED;
this.value = value;
queueMicrotask(() => {
this.callbacks.forEach(cb => cb.onFulfilled(value));
});
};
const reject = (reason) => {
if (this.state !== PENDING) return;
this.state = REJECTED;
this.value = reason;
queueMicrotask(() => {
this.callbacks.forEach(cb => cb.onRejected(reason));
});
};
And in then(), when the promise is already settled:
then(onFulfilled, onRejected) {
if (this.state === FULFILLED) {
queueMicrotask(() => onFulfilled(this.value));
}
if (this.state === REJECTED) {
queueMicrotask(() => onRejected(this.value));
}
if (this.state === PENDING) {
this.callbacks.push({
onFulfilled: (value) => onFulfilled(value),
onRejected: (reason) => onRejected(reason),
});
}
}
setTimeout puts callbacks on the macrotask queue. Native promises use the microtask queue, which drains completely between each macrotask. Using setTimeout would make our implementation behave differently from native promises in ordering tests. queueMicrotask matches the real behavior.
Step 4: Chaining (.then() Returns a New Promise)
This is the heart of the promise model. Every .then() call returns a brand new promise. The fate of that new promise depends on what the callback returns (or throws).
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
const handleFulfilled = (value) => {
try {
if (typeof onFulfilled === 'function') {
const result = onFulfilled(value);
resolvePromise(resolve, reject, result);
} else {
resolve(value);
}
} catch (err) {
reject(err);
}
};
const handleRejected = (reason) => {
try {
if (typeof onRejected === 'function') {
const result = onRejected(reason);
resolvePromise(resolve, reject, result);
} else {
reject(reason);
}
} catch (err) {
reject(err);
}
};
if (this.state === FULFILLED) {
queueMicrotask(() => handleFulfilled(this.value));
}
if (this.state === REJECTED) {
queueMicrotask(() => handleRejected(this.value));
}
if (this.state === PENDING) {
this.callbacks.push({
onFulfilled: handleFulfilled,
onRejected: handleRejected,
});
}
});
}
Three things happening here:
- Default handlers. If
onFulfilledis not a function, we pass the value through (resolve(value)). IfonRejectedis not a function, we pass the rejection through (reject(reason)). This is how rejections propagate down a chain until they hit a.catch(). - Error capture. If the callback throws, the returned promise rejects with that error. This is why you can throw inside
.then()and it gets caught by a downstream.catch(). - Resolution procedure. The
resolvePromisehelper handles the case where the callback returns a promise or thenable. We'll build that next.
Step 5: The Resolution Procedure (Handling Thenables)
This is section 2.3 of the Promises/A+ spec. When a .then() callback returns a value, we need to figure out what to do with it. If it returns a plain value, we fulfill. If it returns a promise or a "thenable" (any object with a .then method), we need to "follow" it.
function resolvePromise(resolve, reject, value) {
if (value instanceof MyPromise) {
value.then(resolve, reject);
return;
}
if (value !== null && (typeof value === 'object' || typeof value === 'function')) {
let called = false;
try {
const then = value.then;
if (typeof then === 'function') {
then.call(
value,
(y) => {
if (called) return;
called = true;
resolvePromise(resolve, reject, y);
},
(r) => {
if (called) return;
called = true;
reject(r);
}
);
} else {
resolve(value);
}
} catch (err) {
if (called) return;
called = true;
reject(err);
}
} else {
resolve(value);
}
}
This looks hairy, but every line maps to a spec requirement:
value instanceof MyPromise— if the callback returned one of our own promises, just adopt its state by calling.then(resolve, reject).- Thenable check — if the value is an object/function with a
.thenmethod, treat it as a thenable. This is how interop works between different promise libraries. calledflag — the spec requires that only the first call to resolve or reject counts. A badly-behaved thenable might call both, or call resolve twice. The flag prevents that.try/catcharoundvalue.thenaccess — a getter for.thencould throw. Seriously. The spec accounts for this because thenables come from untrusted code.- Recursive
resolvePromise— if a thenable resolves with another thenable, we need to unwrap recursively until we hit a plain value.
Why the spec checks for thenables, not just Promises
When Promises/A+ was written, there were dozens of competing promise libraries (Q, Bluebird, RSVP, jQuery Deferred, when.js). Each had its own Promise constructor, so instanceof wouldn't work across libraries. The "thenable" check (typeof value.then === 'function') was the interop solution: any object with a .then method is treated as a promise-like thing. This is duck typing applied to async primitives. It's also why {then: 42} is safely handled (not a function, so just fulfill with the object) but {then() { /* ... */ }} gets the full thenable treatment.
Step 6: Error Handling (.catch() and Self-Resolution Guard)
.catch() is just syntactic sugar for .then(undefined, onRejected):
catch(onRejected) {
return this.then(undefined, onRejected);
}
That's it. Because our .then() already handles the case where onFulfilled is not a function (it passes the value through), .catch() just works.
We also need one safety check. What happens if a .then() callback returns the same promise that .then() itself returns? That would be a circular reference that can never resolve:
const p = Promise.resolve(1).then(() => p); // TypeError: Chaining cycle
Let's add that guard to resolvePromise:
function resolvePromise(promise, resolve, reject, value) {
if (value === promise) {
reject(new TypeError('Chaining cycle detected'));
return;
}
// ...rest of the resolution procedure
}
And update then() to pass the returned promise reference:
then(onFulfilled, onRejected) {
const promise = new MyPromise((resolve, reject) => {
const handleFulfilled = (value) => {
try {
if (typeof onFulfilled === 'function') {
const result = onFulfilled(value);
resolvePromise(promise, resolve, reject, result);
} else {
resolve(value);
}
} catch (err) {
reject(err);
}
};
const handleRejected = (reason) => {
try {
if (typeof onRejected === 'function') {
const result = onRejected(reason);
resolvePromise(promise, resolve, reject, result);
} else {
reject(reason);
}
} catch (err) {
reject(err);
}
};
if (this.state === FULFILLED) {
queueMicrotask(() => handleFulfilled(this.value));
}
if (this.state === REJECTED) {
queueMicrotask(() => handleRejected(this.value));
}
if (this.state === PENDING) {
this.callbacks.push({
onFulfilled: handleFulfilled,
onRejected: handleRejected,
});
}
});
return promise;
}
The Complete Implementation
Here's everything assembled:
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';
function resolvePromise(promise, resolve, reject, value) {
if (value === promise) {
reject(new TypeError('Chaining cycle detected'));
return;
}
if (value instanceof MyPromise) {
value.then(resolve, reject);
return;
}
if (value !== null && (typeof value === 'object' || typeof value === 'function')) {
let called = false;
try {
const then = value.then;
if (typeof then === 'function') {
then.call(
value,
(y) => {
if (called) return;
called = true;
resolvePromise(promise, resolve, reject, y);
},
(r) => {
if (called) return;
called = true;
reject(r);
}
);
} else {
resolve(value);
}
} catch (err) {
if (called) return;
called = true;
reject(err);
}
} else {
resolve(value);
}
}
class MyPromise {
constructor(executor) {
this.state = PENDING;
this.value = undefined;
this.callbacks = [];
const resolve = (value) => {
if (this.state !== PENDING) return;
this.state = FULFILLED;
this.value = value;
queueMicrotask(() => {
this.callbacks.forEach(cb => cb.onFulfilled(value));
});
};
const reject = (reason) => {
if (this.state !== PENDING) return;
this.state = REJECTED;
this.value = reason;
queueMicrotask(() => {
this.callbacks.forEach(cb => cb.onRejected(reason));
});
};
try {
executor(resolve, reject);
} catch (err) {
reject(err);
}
}
then(onFulfilled, onRejected) {
const promise = new MyPromise((resolve, reject) => {
const handleFulfilled = (value) => {
try {
if (typeof onFulfilled === 'function') {
const result = onFulfilled(value);
resolvePromise(promise, resolve, reject, result);
} else {
resolve(value);
}
} catch (err) {
reject(err);
}
};
const handleRejected = (reason) => {
try {
if (typeof onRejected === 'function') {
const result = onRejected(reason);
resolvePromise(promise, resolve, reject, result);
} else {
reject(reason);
}
} catch (err) {
reject(err);
}
};
if (this.state === FULFILLED) {
queueMicrotask(() => handleFulfilled(this.value));
}
if (this.state === REJECTED) {
queueMicrotask(() => handleRejected(this.value));
}
if (this.state === PENDING) {
this.callbacks.push({
onFulfilled: handleFulfilled,
onRejected: handleRejected,
});
}
});
return promise;
}
catch(onRejected) {
return this.then(undefined, onRejected);
}
static resolve(value) {
if (value instanceof MyPromise) return value;
return new MyPromise((resolve) => resolve(value));
}
static reject(reason) {
return new MyPromise((_, reject) => reject(reason));
}
}
Testing It
Let's verify our implementation behaves like native promises:
MyPromise.resolve(1)
.then(x => {
console.log(x);
return x + 1;
})
.then(x => {
console.log(x);
return MyPromise.resolve(x * 10);
})
.then(x => {
console.log(x);
throw new Error('boom');
})
.catch(err => {
console.log('caught:', err.message);
return 'recovered';
})
.then(x => console.log(x));
// Output:
// 1
// 2
// 20
// caught: boom
// recovered
Thenable interop works too:
const fakePromise = {
then(resolve) {
setTimeout(() => resolve('from thenable'), 100);
}
};
MyPromise.resolve()
.then(() => fakePromise)
.then(val => console.log(val)); // "from thenable" (after 100ms)
The Tricky Edge Cases
Edge Case 1: Resolving with the Promise Itself
const p = new MyPromise((resolve) => {
setTimeout(() => resolve(p), 0);
});
p.then(console.log).catch(err => console.log(err.message));
// "Chaining cycle detected"
Without the self-resolution guard, this would loop forever. The spec requires a TypeError rejection.
Edge Case 2: Thenable That Throws During .then Access
const evil = Object.create(null);
Object.defineProperty(evil, 'then', {
get() { throw new Error('gotcha'); }
});
MyPromise.resolve()
.then(() => evil)
.catch(err => console.log(err.message)); // "gotcha"
The try/catch around const then = value.then in our resolution procedure handles this. Without it, a hostile thenable could crash our implementation.
Edge Case 3: Thenable That Calls Both Resolve and Reject
const chaos = {
then(resolve, reject) {
resolve('first');
reject('second');
resolve('third');
}
};
MyPromise.resolve()
.then(() => chaos)
.then(val => console.log(val)); // "first"
The called flag ensures only the first invocation wins. reject('second') and resolve('third') are no-ops.
What Native Promises Add Beyond A+
Our implementation is Promises/A+ compliant, but native ES2015 promises include extra features the spec doesn't cover:
.finally(onFinally)runs a callback regardless of fulfillment or rejection, and passes through the original valuePromise.all(),Promise.race(),Promise.allSettled(),Promise.any()combinators- Unhandled rejection tracking via the
unhandledrejectionevent async/awaitintegration with the engine's microtask scheduling
These are worth implementing as follow-up exercises, but the core machinery is exactly what we built here.
Interview Tips
When whiteboarding this in an interview:
- Start with the state machine. Draw the three states and the transitions. This shows you understand the model before writing code.
- Build incrementally. Don't try to write the complete implementation at once. Start with the constructor and resolve/reject, then add
.then()without chaining, then add chaining. - Explain the spec requirements as you go. "The spec says callbacks must be async, so I'll use
queueMicrotaskhere." This shows you're not just memorizing code. - Know the edge cases. Self-resolution cycles, thenable interop, and the
calledflag are the three things that separate good implementations from great ones. - Don't forget
catch. It's one line, but forgetting it looks bad.
Common Mistakes
| What developers do | What they should do |
|---|---|
| Running .then() callbacks synchronously when the promise is already settled The spec requires consistent async behavior. If callbacks run synchronously on settled promises but asynchronously on pending ones, the same code produces different execution orders depending on timing. This creates subtle, unreproducible bugs. | Always schedule callbacks via queueMicrotask, even for already-settled promises |
| Forgetting to return a new promise from .then() Without returning a new promise, chaining breaks entirely. p.then(a).then(b) would call .then(b) on undefined. The entire promise chain model depends on .then() producing a new promise whose fate is controlled by the callback's return value. | .then() must always return a brand new MyPromise instance |
| Not handling the case where onFulfilled or onRejected is not a function This is how rejection propagation works. When you write .then(fn).then(fn).catch(handleErr), the middle .then() calls have no onRejected handler. Rejections must pass through them unchanged until they reach the .catch(). | If onFulfilled is not a function, pass the value through. If onRejected is not a function, pass the rejection through. |
| Using setTimeout instead of queueMicrotask for async scheduling setTimeout uses the macrotask queue with a minimum delay. queueMicrotask uses the microtask queue, which drains completely before any macrotask runs. Using setTimeout makes your implementation schedule callbacks at the wrong time relative to other microtasks, failing ordering tests. | Use queueMicrotask to match native Promise behavior |
| Not guarding against a thenable calling resolve/reject multiple times Thenables come from external code that you don't control. A broken thenable might call both resolve and reject, or call resolve twice. Without the guard, your promise could change state after settling, violating the fundamental immutability guarantee. | Use a called flag to ensure only the first invocation of resolve or reject takes effect |
Key Rules
- 1A Promise is a state machine with three states: pending, fulfilled, rejected. Transitions are one-way. Once settled, the state and value are locked forever.
- 2The executor runs synchronously. resolve() and reject() are delivered as closures. Throwing inside the executor rejects the promise.
- 3.then() callbacks must ALWAYS run asynchronously via queueMicrotask, even when the promise is already settled. This guarantees consistent execution order.
- 4.then() returns a NEW promise. If the callback returns a value, the new promise fulfills with it. If it throws, the new promise rejects. If it returns a thenable, the new promise follows it.
- 5The resolution procedure handles thenables (objects with a .then method) for cross-library interop. The called flag prevents double-resolution from untrusted thenables.
- 6.catch(fn) is just .then(undefined, fn). Rejection propagation works because .then() without an onRejected handler passes the rejection through to the next promise in the chain.