Implement Promise from scratch
State machine: pending → fulfilled or rejected (transitions once). then/catch/finally chain by returning a new Promise. Resolve thenables. Schedule callbacks as microtasks (queueMicrotask) to match spec timing.
Implementing Promise from scratch is the senior version of the polyfill question. The naive version (then runs the callback) gets you 50%; the rest is state-machine correctness, microtask scheduling, and thenable assimilation.
State machine. A promise is in one of three states: "pending", "fulfilled", or "rejected". Transitions happen at most once (pending → fulfilled or pending → rejected); subsequent calls to resolve/reject are no-ops. Once settled, value/reason is locked in.
The minimum API.
type State = "pending" | "fulfilled" | "rejected";
class MyPromise<T> {
private state: State = "pending";
private value: T | undefined;
private reason: unknown;
private callbacks: Array<{ onF?: any; onR?: any; resolve: any; reject: any }> = [];
constructor(executor: (resolve: (v: T | PromiseLike<T>) => void, reject: (e: unknown) => void) => void) {
try { executor(this._resolve.bind(this), this._reject.bind(this)); }
catch (e) { this._reject(e); }
}
private _resolve(value: T | PromiseLike<T>) {
if (this.state !== "pending") return;
// If value is itself a thenable, adopt its state.
if (value && (typeof value === "object" || typeof value === "function") && typeof (value as any).then === "function") {
try { (value as any).then(this._resolve.bind(this), this._reject.bind(this)); }
catch (e) { this._reject(e); }
return;
}
this.state = "fulfilled";
this.value = value as T;
this._flush();
}
private _reject(reason: unknown) {
if (this.state !== "pending") return;
this.state = "rejected";
this.reason = reason;
this._flush();
}
private _flush() {
for (const cb of this.callbacks) this._schedule(cb);
this.callbacks = [];
}
private _schedule(cb: { onF?: any; onR?: any; resolve: any; reject: any }) {
queueMicrotask(() => {
try {
if (this.state === "fulfilled") {
const v = cb.onF ? cb.onF(this.value) : this.value;
cb.resolve(v);
} else {
if (cb.onR) { const v = cb.onR(this.reason); cb.resolve(v); }
else cb.reject(this.reason);
}
} catch (e) { cb.reject(e); }
});
}
then<U>(onF?: (v: T) => U | PromiseLike<U>, onR?: (e: unknown) => U | PromiseLike<U>): MyPromise<U> {
return new MyPromise<U>((resolve, reject) => {
const cb = { onF, onR, resolve, reject };
if (this.state === "pending") this.callbacks.push(cb);
else this._schedule(cb);
});
}
catch<U>(onR: (e: unknown) => U | PromiseLike<U>) { return this.then(undefined, onR); }
finally(onFinally: () => void) {
return this.then(
v => { onFinally(); return v; },
e => { onFinally(); throw e; }
);
}
static resolve<T>(v: T | PromiseLike<T>) { return new MyPromise<T>(r => r(v)); }
static reject<T = never>(e: unknown) { return new MyPromise<T>((_, r) => r(e)); }
}The four senior details.
- Microtask scheduling. Spec says
thencallbacks run as microtasks, not synchronously. UsequeueMicrotask(orPromise.resolve().thenif polyfilling for old environments). Without this, callbacks would run before the rest of the current synchronous code — breaks ordering.
- Thenable assimilation in
_resolve. If youresolve(somePromise), your promise must adopt that promise's state, not become a fulfilled promise of a promise. Same for any object with a.thenmethod (a "thenable") — callingthenon it kicks off the chain.
thenreturns a new promise. Each call must return a fresh promise so chains compose. The new promise resolves with the callback's return value, or rejects if the callback throws.
- Single transition.
_resolveand_rejectare no-ops if the promise is no longer pending. The lock prevents a buggy executor from settling twice.
Common interview follow-ups.
- Promise.all — wait for all to fulfill, fail-fast on first reject. Tracking remaining count + result array, capture each index.
- Promise.allSettled — already covered separately.
- Promise.race — first to settle wins. Iterate and call
thenon each, pass through to outerresolve/reject. - Promise.any — first to fulfill wins; if all reject, throw
AggregateError.
Edge cases worth mentioning.
- Resolving with self → infinite loop in spec; production polyfills detect and reject with
TypeError. - Throwing inside
thencallback → next promise rejects. - Awaiting a non-promise → wrapped in
Promise.resolveautomatically. - Unhandled rejection event — the runtime fires
unhandledrejectionif no.catchis attached by the next microtask; production code should add a global handler that logs to Sentry.
When you'd actually do this. Almost never. Native Promise exists everywhere. The polyfill exercise tests whether you understand the semantics — chaining, state machine, microtask timing — not whether you should ship it.
Code
Follow-up questions
- •Why must then callbacks run as microtasks, not synchronously?
- •What happens if you resolve a promise with another promise?
- •Implement Promise.race / any using your MyPromise.
- •How does the unhandledrejection event work?
Common mistakes
- •Calling onF/onR synchronously — breaks await ordering and surprises callers.
- •Forgetting then must return a new promise — chains stop composing.
- •Allowing the executor to call resolve twice — must lock state.
- •Not handling thenables — passing a non-Promise then-able breaks chains.
Performance considerations
- •queueMicrotask is cheap; avoid setTimeout in scheduling — measurably slower and adds 4ms minimum.
- •Native Promises are highly optimized in V8; user-land polyfills can be 10x slower.
Edge cases
- •Resolving with self → spec demands TypeError.
- •Thenable's then throws synchronously → reject.
- •finally onFinally throwing → resulting promise rejects with that throw.
Real-world examples
- •Bluebird, RSVP, when.js — historical Promise libraries before native support shipped.