Topics
v6.0.0
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 fetch is not enough. Seniors are asked to implement the primitive.
  • One model covers everything: three states, first settlement wins, .then always 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, and reject. 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 / reject are 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.
  • catch is not special. finally keeps the original outcome unless cleanup throws.

Example

new MyPromise((resolve) => { resolve(1); }) .then((value) => value + 1) .then((value) => console.log(value)); // 2
console.log("sync"); MyPromise.resolve(1).then(() => console.log("then")); // sync // then
pending ├── resolve → fulfilled (locked) └── reject → rejected (locked) p1.then(fn) → p2 never mutates p1

Solution

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 first

Solution

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 with AggregateError.

Example

A reject, B fulfill → any = B race = A any([]) → AggregateError

Solution

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

APICompletes whenRejects when
allEvery fulfillFirst reject
allSettledEvery settleNot from inputs
raceFirst settleFirst reject can win
anyFirst fulfillAll reject
all([]) → [] allSettled([]) → [] race([]) → pending forever any([]) → AggregateError

Use results[index] = value in all / allSettled. push is completion order.

Fail-fast is not cancel: all / race stop waiting. Other fetches may still run.

PieceTimeSpace
Register .thenO(1)O(1) extra
Flush n handlersO(n)O(n)
Combinator of nO(n)O(n)

Senior Engineer Interview Answer

“I’d treat Promise as a three-state machine. First settle wins. then always returns a new Promise and runs as a microtask. Missing handlers propagate. Resolve adopts thenables and rejects self-resolution. catch is then(undefined, fn). finally runs cleanup and keeps the original outcome unless cleanup throws. Combinators share that core: all needs every fulfill, allSettled every settle, race first settle, any first 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 ≠ cancel

One state machine. Five problems. Combinators are stop rules on top of it.

Last updated on