On this page · sections
JavaScript Promise Polyfill
Can you build a Promise from scratch — then the four combinators interviewers actually ask?
Level: Medium · Coding Round · Async Primitives
Why This Question Matters
- Using
await fetchis not enough. Seniors are asked to implement the primitive. - One model covers everything: three states, first settlement wins,
.thenalways async. - Combinators are the same model with different stop rules, not four unrelated APIs.
- This is how you explain dashboards, timeouts, fallbacks, and loading cleanup in a coding round.
Five problems on this page. Start with the class. The combinators hang off it.
Problem 1 — Promise from scratch
“Implement a Promise polyfill. Support
then,catch,finally,resolve, andreject. I will ask combinators next.”
class MyPromise {
constructor(executor) {}
then(onFulfilled, onRejected) {}
catch(onRejected) {}
finally(onFinally) {}
static resolve(value) {}
static reject(reason) {}
}State
- Start
pending.resolve→fulfilled.reject→rejected. - First settlement wins. Later
resolve/rejectare no-ops. - If the executor throws, reject.
then
- Always return a new Promise.
- Handlers run as microtasks, even if already settled.
- Missing handler: propagate the previous value or error.
- Handler return value fulfills the next Promise (adopt if it is a thenable).
- Handler throw rejects the next Promise.
Clarify before you code
- You are not writing the full spec. You are showing the state machine.
catchis not special.finallykeeps the original outcome unless cleanup throws.
Example
new MyPromise((resolve) => {
resolve(1);
})
.then((value) => value + 1)
.then((value) => console.log(value));
// 2console.log("sync");
MyPromise.resolve(1).then(() => console.log("then"));
// sync
// thenpending
├── resolve → fulfilled (locked)
└── reject → rejected (locked)
p1.then(fn) → p2 never mutates p1Solution
Store #state, #value, and #handlers while pending. On settle, flush with queueMicrotask.
resolve is not fulfill. resolve may get a number, another Promise, or a thenable.
class MyPromise {
#state = "pending";
#value;
#handlers = [];
constructor(executor) {
const fulfill = (value) => {
if (this.#state !== "pending") return;
this.#state = "fulfilled";
this.#value = value;
this.#flushHandlers();
};
const reject = (reason) => {
if (this.#state !== "pending") return;
this.#state = "rejected";
this.#value = reason;
this.#flushHandlers();
};
const resolve = (value) => {
if (this.#state !== "pending") return;
if (value === this) {
reject(new TypeError("Promise cannot resolve itself"));
return;
}
if (
value !== null &&
(typeof value === "object" || typeof value === "function")
) {
let then;
try {
then = value.then;
} catch (error) {
reject(error);
return;
}
if (typeof then === "function") {
let called = false;
try {
then.call(
value,
(next) => {
if (called) return;
called = true;
resolve(next);
},
(reason) => {
if (called) return;
called = true;
reject(reason);
}
);
} catch (error) {
if (!called) reject(error);
}
return;
}
}
fulfill(value);
};
try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}
#flushHandlers() {
const handlers = this.#handlers.splice(0);
for (const handler of handlers) {
queueMicrotask(() => this.#runHandler(handler));
}
}
#runHandler({ onFulfilled, onRejected, resolve, reject }) {
const handler =
this.#state === "fulfilled" ? onFulfilled : onRejected;
if (typeof handler !== "function") {
this.#state === "fulfilled"
? resolve(this.#value)
: reject(this.#value);
return;
}
try {
resolve(handler(this.#value));
} catch (error) {
reject(error);
}
}
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
const handler = { onFulfilled, onRejected, resolve, reject };
if (this.#state === "pending") {
this.#handlers.push(handler);
} else {
queueMicrotask(() => this.#runHandler(handler));
}
});
}
catch(onRejected) {
return this.then(undefined, onRejected);
}
finally(callback) {
return this.then(
(value) => MyPromise.resolve(callback()).then(() => value),
(reason) =>
MyPromise.resolve(callback()).then(() => {
throw reason;
})
);
}
static resolve(value) {
if (value instanceof MyPromise) return value;
return new MyPromise((resolve) => resolve(value));
}
static reject(reason) {
return new MyPromise((_, reject) => reject(reason));
}
}Use this class in Problems 2–5. Wrap values with MyPromise.resolve(item) so numbers and thenables work the same as Promises.
Why This Works
First settlement wins:
new MyPromise((resolve, reject) => {
resolve("first");
reject("second");
});
// fulfilled "first"then returns a new Promise. The original is unchanged. Multiple .then on the same Promise all run.
Missing handlers propagate:
promise.then().then().catch(fn)No onFulfilled → pass the value. No onRejected → pass the error.
A thenable that calls both resolve and reject — only the first call counts (called flag). Resolving a Promise with itself is a TypeError. A finally throw replaces the previous outcome.
Follow-ups
Q. Why does then return a new Promise?
Answer: Each .then is a new stage. Chains transform values without changing p1.
Q. Handler returns a Promise?
Answer: Adopt its state. That is how then flattens return fetch(...).
Q. Why is catch not special?
Answer: catch(fn) is then(undefined, fn).
Q. Why queueMicrotask?
Answer: .then must stay async even on an already-fulfilled Promise. Otherwise sync vs then order depends on whether it was already done.
Q. Why is resolve harder than fulfill?
Answer: A number can fulfill now. A thenable must be adopted. called ignores a thenable that settles twice.
Q. finally throws?
Answer: The chain rejects with the cleanup error. The original value is gone.
Problem 2 — Promise.all
“Wait for every input to fulfill. First reject fails the whole thing. Keep input order.”
function promiseAll(list) {}Rules
- Wrap every item with
MyPromise.resolve(bare values count as already fulfilled). - Results stay in input order, not finish order.
- First reject rejects the returned Promise.
promiseAll([])fulfills with[].- Fail-fast does not cancel the other work.
Example
await promiseAll([Promise.resolve(1), 2, Promise.resolve(3)]);
// [1, 2, 3] — even if 3 finishes firstSolution
function promiseAll(list) {
const items = [...list];
if (!items.length) {
return MyPromise.resolve([]);
}
return new MyPromise((resolve, reject) => {
const results = new Array(items.length);
let remaining = items.length;
items.forEach((item, index) => {
MyPromise.resolve(item).then((value) => {
results[index] = value;
if (--remaining === 0) resolve(results);
}, reject);
});
});
}Do not results.push(value) — that is finish order.
Use case
User, orders, and permissions are all required before a dashboard render.
Follow-ups
Q. all vs allSettled?
Answer: all needs every success. Independent widgets that can fail separately use allSettled.
Q. Does all cancel the rest?
Answer: No. Fail-fast only stops waiting. Use AbortController to cancel fetch.
Problem 3 — Promise.allSettled
“Wait until every input settles. Never reject because one of them failed.”
function promiseAllSettled(list) {}Rules
- Wait for every item to fulfill or reject.
- Each slot is
{ status: "fulfilled", value }or{ status: "rejected", reason }. - The returned Promise fulfills with that array. Inputs do not reject it.
- Keep input order.
promiseAllSettled([])fulfills with[].
Example
await promiseAllSettled([
MyPromise.resolve("ok"),
MyPromise.reject("fail"),
]);
// [
// { status: "fulfilled", value: "ok" },
// { status: "rejected", reason: "fail" },
// ]Solution
function promiseAllSettled(list) {
const items = [...list];
if (!items.length) {
return MyPromise.resolve([]);
}
return new MyPromise((resolve) => {
const results = new Array(items.length);
let remaining = items.length;
items.forEach((item, index) => {
MyPromise.resolve(item).then(
(value) => {
results[index] = { status: "fulfilled", value };
if (--remaining === 0) resolve(results);
},
(reason) => {
results[index] = { status: "rejected", reason };
if (--remaining === 0) resolve(results);
}
);
});
});
}Same counter as all. The reject path records instead of failing the outer Promise.
Use case
Profile widget can fail; still show notifications.
Follow-ups
Q. Empty list?
Answer: all and allSettled both fulfill with [].
Problem 4 — Promise.race
“First to settle wins — fulfill or reject.”
function promiseRace(list) {}Rules
- First settlement wins (success or failure).
- Empty
promiseRace([])never settles. - A reject can win.
- This is not
AbortController. The losers keep running.
Example
await promiseRace([
new Promise((r) => setTimeout(() => r("slow"), 100)),
new Promise((r) => setTimeout(() => r("fast"), 20)),
]);
// "fast"Solution
function promiseRace(list) {
return new MyPromise((resolve, reject) => {
for (const item of list) {
MyPromise.resolve(item).then(resolve, reject);
}
});
}No remaining counter. First then callback that fires locks the outer Promise (first settlement on MyPromise is a no-op after that).
Use case
Timeout: race the request against a timer. This does not abort fetch.
Follow-ups
Q. A rejects, then B fulfills. Who wins race?
Answer: A. First settlement, not first success.
Problem 5 — Promise.any
“First fulfill wins. Reject only when every input has rejected.”
function promiseAny(list) {}Rules
- First fulfill resolves the returned Promise.
- Rejects are ignored until every input has rejected.
- Then reject with
AggregateError(errors in input order). promiseAny([])rejects immediately withAggregateError.
Example
A reject, B fulfill → any = B race = A
any([]) → AggregateErrorSolution
function promiseAny(list) {
const items = [...list];
if (!items.length) {
return MyPromise.reject(
new AggregateError([], "All promises were rejected")
);
}
return new MyPromise((resolve, reject) => {
const errors = new Array(items.length);
let rejectedCount = 0;
items.forEach((item, index) => {
MyPromise.resolve(item).then(resolve, (reason) => {
errors[index] = reason;
if (++rejectedCount === items.length) {
reject(
new AggregateError(errors, "All promises were rejected")
);
}
});
});
});
}Use case
First successful CDN / backup is enough.
Follow-ups
Q. race vs any?
Answer: race → first settle. any → first fulfill. A rejects then B fulfills: race is A, any is B.
Hang them on the class (after all four functions exist):
MyPromise.all = promiseAll;
MyPromise.allSettled = promiseAllSettled;
MyPromise.race = promiseRace;
MyPromise.any = promiseAny;Combinator map
| API | Completes when | Rejects when |
|---|---|---|
all | Every fulfill | First reject |
allSettled | Every settle | Not from inputs |
race | First settle | First reject can win |
any | First fulfill | All reject |
all([]) → []
allSettled([]) → []
race([]) → pending forever
any([]) → AggregateErrorUse results[index] = value in all / allSettled. push is completion order.
Fail-fast is not cancel: all / race stop waiting. Other fetches may still run.
| Piece | Time | Space |
|---|---|---|
Register .then | O(1) | O(1) extra |
| Flush n handlers | O(n) | O(n) |
| Combinator of n | O(n) | O(n) |
Senior Engineer Interview Answer
“I’d treat Promise as a three-state machine. First settle wins.
thenalways returns a new Promise and runs as a microtask. Missing handlers propagate. Resolve adopts thenables and rejects self-resolution.catchisthen(undefined, fn).finallyruns cleanup and keeps the original outcome unless cleanup throws. Combinators share that core:allneeds every fulfill,allSettledevery settle,racefirst settle,anyfirst fulfill.”
Interview Checklist
✓ pending / fulfilled / rejected
✓ first settlement wins
✓ then returns a new Promise
✓ microtasks
✓ missing handlers propagate
✓ catch / finally
✓ thenables + called guard
✓ all index order, not push
✓ empty list contracts
✓ race vs any
✓ fail-fast ≠ cancelOne state machine. Five problems. Combinators are stop rules on top of it.