Day 4: Promises Under the Hood (Building a Custom Polyfill)
Implement a spec-compliant-enough Promise from scratch to internalize state transitions, the microtask scheduling of .then, and chaining.
Study
Concepts
A Promise is a state machine
A Promise has exactly one of three states: pending, fulfilled, or rejected. It starts pending and can transition to fulfilled or rejected exactly once — after that it is settled forever ("immutability after settlement").
`.then(onFulfilled, onRejected)` does not run its callback synchronously, even if the promise is already settled — the spec requires the callback to be scheduled as a microtask. This is what guarantees the ordering you saw on Day 3.
Chaining works because .then returns a NEW promise
Each `.then()` call returns a brand-new Promise, whose resolution depends on what the handler returns: a plain value resolves the new promise with that value; a thrown error rejects it; and returning another thenable/Promise makes the new promise "adopt" that promise's eventual state — this adoption step is what lets you `return fetch(...)` inside a `.then` and keep chaining.
This returned-new-promise mechanic is exactly why Promises solve "callback hell": each step is a value you can pass around, not a nested callback you must be inside of.
See It
Visualizations
Visualization
Promise state machine
executor is running
resolve(value) called — settled forever
reject(reason) called — settled forever
Build It
Code Examples
MyPromise: a working polyfill (educational, ~ES2015 spec subset)
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';
class MyPromise {
#state = PENDING;
#value;
#callbacks = []; // { onFulfilled, onRejected, resolveNext, rejectNext }
constructor(executor) {
const resolve = (value) => this.#settle(FULFILLED, value);
const reject = (reason) => this.#settle(REJECTED, reason);
try {
executor(resolve, reject);
} catch (err) {
reject(err);
}
}
#settle(state, value) {
if (this.#state !== PENDING) return; // settle exactly once
// Adopt the state of a returned thenable instead of settling immediately
if (state === FULFILLED && value && typeof value.then === 'function') {
value.then(
(v) => this.#settle(FULFILLED, v),
(e) => this.#settle(REJECTED, e)
);
return;
}
this.#state = state;
this.#value = value;
this.#flush();
}
#flush() {
// Callbacks must run as microtasks, never synchronously
queueMicrotask(() => {
this.#callbacks.forEach((cb) => this.#run(cb));
this.#callbacks = [];
});
}
#run({ onFulfilled, onRejected, resolveNext, rejectNext }) {
try {
if (this.#state === FULFILLED) {
resolveNext(typeof onFulfilled === 'function' ? onFulfilled(this.#value) : this.#value);
} else {
if (typeof onRejected === 'function') resolveNext(onRejected(this.#value));
else rejectNext(this.#value);
}
} catch (err) {
rejectNext(err);
}
}
then(onFulfilled, onRejected) {
return new MyPromise((resolveNext, rejectNext) => {
const callback = { onFulfilled, onRejected, resolveNext, rejectNext };
if (this.#state === PENDING) this.#callbacks.push(callback);
else this.#flush(), this.#callbacks.push(callback); // settled: schedule anyway
});
}
catch(onRejected) {
return this.then(undefined, onRejected);
}
}
// Usage — behaves like the real thing
new MyPromise((resolve) => setTimeout(() => resolve(42), 100))
.then((v) => v * 2)
.then((v) => console.log('result:', v)); // 84This is deliberately simplified (no static resolve/reject/all yet) — the goal is to see WHY .then always schedules a microtask and WHY chaining returns a new promise.
Remember
Key Takeaways
- pending → fulfilled | rejected, exactly once, forever — model this as a tiny state machine.
- .then callbacks are ALWAYS deferred to the microtask queue, even for already-settled promises.
- Each .then returns a new Promise; its resolution adopts a returned thenable's eventual state.
- Errors thrown inside a handler reject the next promise in the chain — this is how .catch works.
- Promise.all/race/allSettled are built on top of this same primitive — worth implementing next as a stretch goal.
Do It
Practice
- 1Extend MyPromise with static `MyPromise.resolve` and `MyPromise.reject` helpers.
- 2Implement `MyPromise.all(promises)` from scratch using your polyfill.
- 3Write a test that proves .then callbacks run after all synchronous code, using console.log ordering.